Skip to content
Swingback, home
Docs / REST API

REST API

Sync your customers from your own product, create posts and votes on their behalf, and automate your workflow, from your servers.

Every path starts with /v1, at this base URL:

https://api.swingback.dev/v1

API keys#

Owners and admins create keys under Settings → API keys, with Create API key:

  • Name: where the key is used, so you know what breaks if you revoke it.
  • Access: Read fetches data; Read and write also creates and changes it.
  • Expires: never, or in 30 days, 90 days or a year.

The key (s_live_…) is shown once. Keep it in your secrets manager, on your servers only: never in a browser or an app.

Paths name your workspace, as in /v1/workspaces/{workspace_id}/posts. Your workspace's id (ws_…) is shown under Settings → Sign-in. Send the key as a bearer token:

curl https://api.swingback.dev/v1/workspaces/$SWINGBACK_WORKSPACE_ID \
  -H "Authorization: Bearer $SWINGBACK_API_KEY"

A key acts for the member who created it. It can do what its access allows, never more than that member's role, and it stops working when they leave the workspace. It only sees its own workspace. Keys never manage settings, the team, API keys, webhooks, integrations, billing or the custom domain: those stay in the dashboard. The keys page shows when each key was last used, and Revoke stops one at once. A workspace has up to 50 live keys.

Conventions#

  • JSON field names are snake_case.
  • Ids are typed strings, such as post_01h455vb4pex5vsknk084sn02q. An id of the wrong kind is a 404.
  • Timestamps are RFC 3339, in UTC, with milliseconds: 2026-09-28T14:03:12.482Z.
  • Money is an integer in minor units, never a float. A company's value of 120000 can be $1,200.00 of monthly revenue in cents; the unit is yours.
  • Durations are in fields named *_seconds.
  • PATCH changes only the fields you send, and null clears a field. On a board, {"name": "Ideas", "color": null} renames it and removes its color; everything else stays.

Pagination#

Lists take limit (1 to 100, 25 by default) and cursor, and answer:

{ "data": [], "next_cursor": "…", "has_more": true }

While has_more is true, pass next_cursor as cursor to get the next page. A cursor only continues the list it came from, with the same filters (400 invalid_cursor otherwise). Lists give no totals: GET .../stats counts posts, posts by status, votes, boards and end users.

Errors#

Errors are problem details (application/problem+json) with a stable code to branch on, and the request_id of the request, also in the X-Request-Id header. An invalid request lists what's wrong in errors: a JSON pointer into the body, or a query parameter, with its own code.

{
  "type": "about:blank",
  "title": "Bad Request",
  "status": 400,
  "detail": "The request body is invalid.",
  "code": "validation_failed",
  "request_id": "req_01h455vb4pex5vsknk084sn02q",
  "errors": [{ "pointer": "/title", "code": "required", "message": "is required" }]
}
StatusCodesMeaning
400validation_failed, invalid_cursorThe request is invalid.
401unauthenticated, invalid_tokenNo key, or a key that's wrong, expired or revoked.
403forbiddenThe key can't do this: a read key writing, or an operation that stays in the dashboard.
403feature_not_in_plan, plan_limit_reachedThe plan doesn't include this, or its limit is reached.
404not_foundIt doesn't exist for you, which includes every id of another workspace.
409conflict, and others such as in_trashThe change conflicts with the current state.
422Such as idempotency_key_reusedThe request is well formed but can't be done.
429rate_limitedToo many requests: wait and retry.
503unavailable, timeoutRetry, with exponential backoff.

New codes may be added; an existing code never changes meaning.

Idempotency#

A POST takes an Idempotency-Key header: 1 to 255 printable ASCII characters, such as a UUID or an id from your own system. For 24 hours, a retry of the same request (same method, path, query and body) with the same key gets the first response again, marked Idempotent-Replayed: true, instead of repeating the change.

  • The same key with another request is 422 idempotency_key_reused.
  • While the first request still runs, a retry is 409 idempotency_request_in_progress, with Retry-After.
  • A response of 500 or above isn't kept: a retry with the same key runs the request again.

Keys are remembered per API key.

Rate limits#

Each API key can make 300 requests a minute, refilled continuously, shared with its MCP calls. Every response says where you stand:

RateLimit-Policy: "default";q=300;w=60
RateLimit: "default";r=299;t=1

r is how many requests you can make now, and t how many seconds until the full quota is back. Past the limit, the API answers 429 rate_limited, with a Retry-After header and retry_after_seconds in the body: wait that long, then retry.

TypeScript client#

@swingback/api is a typed client of this API, generated from the same contract the API runs on and built on openapi-fetch. It needs Node.js 20 or later, and isn't published to npm: the package is private for now and ships its TypeScript sources.

import { createRESTClient } from "@swingback/api";

const api = createRESTClient({ baseUrl: "https://api.swingback.dev", apiKey: process.env.SWINGBACK_API_KEY! });
const { data, error } = await api.GET("/v1/workspaces/{workspace_id}/posts", {
  params: { path: { workspace_id: process.env.SWINGBACK_WORKSPACE_ID! }, query: { sort: "customer_value", limit: 10 } },
});
if (error) throw new Error(error.code); // problem details: branch on the stable code

The resources#

Under /v1/workspaces/{workspace_id}:

ResourcePathsWhat you can do
Boards/boardsList, create, update, move to the trash and restore. Private boards need Pro.
Posts/postsFilter and sort, create, update, change the status with a note (/status), replace tags (/tags), merge, moderate, trash.
Votes/posts/{post_id}/votesVote on behalf of a customer (end_user). Voting again returns the existing vote.
Comments/posts/{post_id}/comments, /commentsPublic comments, replies (parent_id) and internal notes ("visibility": "internal"); edit, pin, moderate, trash.
Tags/tagsTags for posts or for changelog entries (scope).
End users/end-usersCreate or update customers by external_id, else email, with traits and companies; ban or erase them.
Companies/companiesCreate or update companies by external_id, with their value.
Changelog/changelogDraft entries, then publish now or schedule (PUT .../publication), or unpublish (DELETE .../publication).

There's more: the roadmap (/roadmap), counts (/stats), traits (/traits), merge suggestions (/merge-suggestions), image uploads (/assets) and the team's members (/members, read only).

List posts by customer value#

A post's customer_value is the sum of the value of the distinct companies whose members vote for it. Sort by it to see what your most valuable customers want:

curl "https://api.swingback.dev/v1/workspaces/$SWINGBACK_WORKSPACE_ID/posts?sort=customer_value&status=review&status=planned&limit=10" \
  -H "Authorization: Bearer $SWINGBACK_API_KEY"

The answer, trimmed to a few fields:

{
  "data": [
    {
      "id": "post_01h455vb4pex5vsknk084sn02q",
      "number": 42,
      "title": "Export reports to CSV",
      "status": "planned",
      "vote_count": 17,
      "customer_value": 1250000
    }
  ],
  "next_cursor": "…",
  "has_more": true
}

Other sorts are newest (the default), oldest, top (votes), trending, and relevance with a search (q). Repeat a filter to match any of its values, as status does here.

Keep a customer in sync#

Create or update a customer, found by external_id, else by email. companies lists all of their companies, each created or updated by its own external_id. The answer is 201 for a new customer, 200 for an existing one.

curl https://api.swingback.dev/v1/workspaces/$SWINGBACK_WORKSPACE_ID/end-users \
  -H "Authorization: Bearer $SWINGBACK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "external_id": "u-8412",
    "email": "dana@example.com",
    "name": "Dana Kim",
    "companies": [{ "external_id": "acme", "name": "Acme", "value": 120000 }]
  }'

Create a post for a customer#

With an API key, author is required: it names the customer the post is from, created if new. The author follows the post. The Idempotency-Key makes a retry safe.

curl https://api.swingback.dev/v1/workspaces/$SWINGBACK_WORKSPACE_ID/posts \
  -H "Authorization: Bearer $SWINGBACK_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: ticket-8812" \
  -d '{
    "board_id": "board_01h455vb4pex5vsknk084sn02q",
    "title": "Export reports to CSV",
    "body_md": "Our finance team exports every report by hand each month.",
    "author": { "external_id": "u-8412", "name": "Dana Kim" }
  }'

The answer is the new post, with 201. To add a vote from another customer, POST to /posts/{post_id}/votes with {"end_user": {"external_id": "u-9031"}}.

See Customers and companies for traits and values, and Webhooks to hear about changes instead of polling for them.