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/v1API 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
valueof120000can 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
nullclears 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" }]
}| Status | Codes | Meaning |
|---|---|---|
| 400 | validation_failed, invalid_cursor | The request is invalid. |
| 401 | unauthenticated, invalid_token | No key, or a key that's wrong, expired or revoked. |
| 403 | forbidden | The key can't do this: a read key writing, or an operation that stays in the dashboard. |
| 403 | feature_not_in_plan, plan_limit_reached | The plan doesn't include this, or its limit is reached. |
| 404 | not_found | It doesn't exist for you, which includes every id of another workspace. |
| 409 | conflict, and others such as in_trash | The change conflicts with the current state. |
| 422 | Such as idempotency_key_reused | The request is well formed but can't be done. |
| 429 | rate_limited | Too many requests: wait and retry. |
| 503 | unavailable, timeout | Retry, 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, withRetry-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=1r 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 codeThe resources#
Under /v1/workspaces/{workspace_id}:
| Resource | Paths | What you can do |
|---|---|---|
| Boards | /boards | List, create, update, move to the trash and restore. Private boards need Pro. |
| Posts | /posts | Filter and sort, create, update, change the status with a note (/status), replace tags (/tags), merge, moderate, trash. |
| Votes | /posts/{post_id}/votes | Vote on behalf of a customer (end_user). Voting again returns the existing vote. |
| Comments | /posts/{post_id}/comments, /comments | Public comments, replies (parent_id) and internal notes ("visibility": "internal"); edit, pin, moderate, trash. |
| Tags | /tags | Tags for posts or for changelog entries (scope). |
| End users | /end-users | Create or update customers by external_id, else email, with traits and companies; ban or erase them. |
| Companies | /companies | Create or update companies by external_id, with their value. |
| Changelog | /changelog | Draft 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.
