TailSpend Developer API
Script TailSpend for your own account: read your groups and their members, and list, create, update and delete expenses — from a spreadsheet sync, a budgeting tool, or a bank-export importer.
| Base URL | https://developer-api.tailspend.kinesis.studio/v1 |
|---|---|
| Format | JSON over HTTPS. Money is in the currency's smallest unit (cents for USD). |
| Authentication | Personal access token: Authorization: Bearer tsp_… |
| Versioning | The /v1 path is stable; additive changes (new fields, new operations) can appear without a version bump. |
Tokens act for your own account. Apps that act for other people (OAuth) aren't supported yet.
Quick start
-
Create a token
In the TailSpend web or desktop app, open Profile → Developer access and choose Create token. Give it a name, pick the groups it may use, select only the scopes you need, and choose an expiry (up to 365 days).
Copy the secret when it's shown — it is displayed only once. TailSpend keeps only a hash and can never show it again.
-
Keep it out of your code
export TAILSPEND_TOKEN="tsp_…"Treat the token like a password: keep it in an environment variable or secret manager, never in source control or in a browser or mobile app.
-
List your groups
curl -H "Authorization: Bearer $TAILSPEND_TOKEN" \ https://developer-api.tailspend.kinesis.studio/v1/users/me/groupsYou'll see only the groups the token was granted (needs
groups:read). Note a group'sid. -
Find participant ids
curl -H "Authorization: Bearer $TAILSPEND_TOKEN" \ https://developer-api.tailspend.kinesis.studio/v1/groups/$GROUP_ID/participantsExpenses refer to people by
participantId— use these to say who paid and who shares each item. -
Create an expense
curl -X POST \ -H "Authorization: Bearer $TAILSPEND_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: lunch-2026-10-10" \ -d @expense.json \ https://developer-api.tailspend.kinesis.studio/v1/groups/$GROUP_ID/expenseswith
expense.json(needsexpenses:create):{ "label": "Team lunch", "currency": "USD", "expenseDate": "2026-10-10", "payments": [ { "participantId": "PAYER_PARTICIPANT_ID", "amount": 4500, "charge": 4500 } ], "items": [ { "label": "Lunch", "cost": 4500, "split": { "type": "EQUAL", "divisions": [ { "participantId": "PAYER_PARTICIPANT_ID" }, { "participantId": "OTHER_PARTICIPANT_ID" } ] } } ] }The response is the created expense (
201). Retrying with the sameIdempotency-Keyreturns the same expense instead of a duplicate.
Authentication
Send the token on every request: Authorization: Bearer tsp_<tokenId>_<secret>. Missing, malformed, expired or revoked tokens get 401.
- Least privilege. A token only reaches the groups and scopes you chose. Every request must also be allowed by your current role in the group — if you leave a group or become a viewer, the token loses that access on its next request. A token never gives more access than your own account has.
- Rotate a token to get a new secret with the same settings; the old secret stops working immediately. Revoke takes effect immediately too.
- You can hold up to 10 active tokens, each with 1–25 groups.
- Tokens can't manage tokens, read usage, change account settings, manage participants, use receipt scanning or reach admin features.
Scopes & operations
| Scope | Allows |
|---|---|
groups:read | List the token's groups; read a group and its participants |
expenses:read | List and read expenses |
expenses:create | Create expenses |
expenses:update | Update expenses |
expenses:delete | Delete expenses |
| Operation | Required scope |
|---|---|
GET /users/me/groups | groups:read |
GET /groups/{groupId} | groups:read |
GET /groups/{groupId}/participants | groups:read |
GET /groups/{groupId}/expenses | expenses:read |
GET /groups/{groupId}/expenses/{expenseId} | expenses:read |
POST /groups/{groupId}/expenses | expenses:create |
PATCH /groups/{groupId}/expenses/{expenseId} | expenses:update |
DELETE /groups/{groupId}/expenses/{expenseId} | expenses:delete |
Group-scoped operations also require the token to be granted that groupId. Anything else returns 404. Full request and response schemas are in the API reference.
Working with expenses
- Amounts are integers in the smallest currency unit:
4500USD is $45.00. - Payments say who paid:
amountin the expense currency andchargein the group's currency (equal when they're the same currency). Paymentamounts must add up to the items'costs. - Items say what was bought and how it's shared. Split types:
EQUAL,AMOUNTS(exact amounts),PERCENTAGES(sum to 100),SHARES(proportional), andITEM_TOTALS(in proportion to what each person bought). Discounts are items with a negativecost. - Members can edit and delete the expenses they created; group owners can edit any; viewers can only read.
Updating safely
Every expense has a version. Send the version you last read with your change; if someone else changed the expense first you'll get 409 — read it again and reapply your change.
curl -X PATCH \
-H "Authorization: Bearer $TAILSPEND_TOKEN" \
-H "Content-Type: application/json" \
-d '{"label": "Team lunch (Friday)", "version": 1}' \
https://developer-api.tailspend.kinesis.studio/v1/groups/$GROUP_ID/expenses/$EXPENSE_ID
Idempotent creates
POST /groups/{groupId}/expenses requires an Idempotency-Key header (1–255 printable ASCII characters). Use one key per logical expense — for an import, derive it from the source record (e.g. a bank transaction id) — and reuse it when you retry.
| Situation | Result |
|---|---|
| Same key, same body | 201 with the original expense and Idempotent-Replayed: true. Concurrent retries never create duplicates. |
| Same key, different body or group | 422 |
| The key's expense was deleted since | 409 |
| Key older than 24 hours | Treated as new |
Pagination
List operations return { "items": [...], "nextCursor": "…" }. Pass nextCursor back as ?cursor= to get the next page, and stop when it's absent. ?limit= sets the page size (capped per endpoint, e.g. 200 for expenses). Cursors are opaque — don't parse or construct them. GET /users/me/groups may return short pages because it only lists the token's groups; keep following the cursor.
Errors
Every error has the same shape:
{ "error": { "code": "VALIDATION_ERROR", "message": "Request validation failed",
"details": [{ "path": "items.0.cost", "message": "Expected number" }] } }
| Status | Code | Meaning |
|---|---|---|
| 400 | VALIDATION_ERROR | Malformed body, query or header (e.g. a missing Idempotency-Key) |
| 401 | UNAUTHENTICATED | Missing, invalid, expired or revoked token |
| 403 | FORBIDDEN | Missing scope, group not granted, or your role doesn't allow it |
| 404 | NOT_FOUND | No such resource or operation |
| 409 | CONFLICT | Stale version, or a conflicting change |
| 422 | UNPROCESSABLE | Valid shape but inconsistent (payments don't fund items; reused idempotency key) |
| 429 | TOO_MANY_REQUESTS | An allowance is used up — see below |
| 5xx | INTERNAL | Our fault; safe to retry creates with the same idempotency key |
Rate limits
| Allowance | Default |
|---|---|
| Per user, across all your tokens | 60 units per minute |
| Per token | 30 units per minute |
| Expense creates | 500 per user per UTC day |
| Request body | 64 KiB |
Most operations cost 1 unit; GET /users/me/groups costs 10. Developer traffic has its own allowance, so a busy integration never slows down your own use of the app.
When a limit is reached you get 429. If a Retry-After header is present, wait at least that many seconds; if you're limited again, back off exponentially with jitter. These allowances are starting values and may be tuned.
Usage
Profile → Developer access shows your requests today and this month, a daily table, per-token totals, error and rate-limit counts — by UTC day, for the last 90 days. Counts are approximate (they're for monitoring, not billing) and only include authenticated requests that reached the API; requests rejected earlier, such as with an invalid token, aren't counted.
Example client (Node.js 18+)
const BASE = "https://developer-api.tailspend.kinesis.studio/v1";
const TOKEN = process.env.TAILSPEND_TOKEN;
async function tailspend(path, { method = "GET", body, idempotencyKey } = {}) {
for (let attempt = 0; ; attempt++) {
const res = await fetch(`${BASE}${path}`, {
method,
headers: {
Authorization: `Bearer ${TOKEN}`,
...(body ? { "Content-Type": "application/json" } : {}),
...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
if (res.status === 429 && attempt < 5) {
// Wait for the allowance to reset, then back off with jitter.
const retryAfter = Number(res.headers.get("retry-after") ?? 1);
const backoff = Math.min(60, 2 ** attempt) * Math.random();
await new Promise((r) => setTimeout(r, (retryAfter + backoff) * 1000));
continue;
}
const json = await res.json();
if (!res.ok) throw Object.assign(new Error(json.error.message), json.error);
return json;
}
}
// List the groups this token can see, then the latest expenses in the first.
const { items: groups } = await tailspend("/users/me/groups");
const { items: expenses } = await tailspend(`/groups/${groups[0].id}/expenses?limit=20`);
console.log(expenses.map((e) => `${e.expenseDate} ${e.label}`));