TailSpend Developers

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 URLhttps://developer-api.tailspend.kinesis.studio/v1
FormatJSON over HTTPS. Money is in the currency's smallest unit (cents for USD).
AuthenticationPersonal access token: Authorization: Bearer tsp_…
VersioningThe /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

  1. 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.

  2. 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.

  3. List your groups

    curl -H "Authorization: Bearer $TAILSPEND_TOKEN" \
      https://developer-api.tailspend.kinesis.studio/v1/users/me/groups

    You'll see only the groups the token was granted (needs groups:read). Note a group's id.

  4. Find participant ids

    curl -H "Authorization: Bearer $TAILSPEND_TOKEN" \
      https://developer-api.tailspend.kinesis.studio/v1/groups/$GROUP_ID/participants

    Expenses refer to people by participantId — use these to say who paid and who shares each item.

  5. 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/expenses

    with expense.json (needs expenses: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 same Idempotency-Key returns 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

ScopeAllows
groups:readList the token's groups; read a group and its participants
expenses:readList and read expenses
expenses:createCreate expenses
expenses:updateUpdate expenses
expenses:deleteDelete expenses
OperationRequired scope
GET /users/me/groupsgroups:read
GET /groups/{groupId}groups:read
GET /groups/{groupId}/participantsgroups:read
GET /groups/{groupId}/expensesexpenses:read
GET /groups/{groupId}/expenses/{expenseId}expenses:read
POST /groups/{groupId}/expensesexpenses: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: 4500 USD is $45.00.
  • Payments say who paid: amount in the expense currency and charge in the group's currency (equal when they're the same currency). Payment amounts 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), and ITEM_TOTALS (in proportion to what each person bought). Discounts are items with a negative cost.
  • 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.

SituationResult
Same key, same body201 with the original expense and Idempotent-Replayed: true. Concurrent retries never create duplicates.
Same key, different body or group422
The key's expense was deleted since409
Key older than 24 hoursTreated 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" }] } }
StatusCodeMeaning
400VALIDATION_ERRORMalformed body, query or header (e.g. a missing Idempotency-Key)
401UNAUTHENTICATEDMissing, invalid, expired or revoked token
403FORBIDDENMissing scope, group not granted, or your role doesn't allow it
404NOT_FOUNDNo such resource or operation
409CONFLICTStale version, or a conflicting change
422UNPROCESSABLEValid shape but inconsistent (payments don't fund items; reused idempotency key)
429TOO_MANY_REQUESTSAn allowance is used up — see below
5xxINTERNALOur fault; safe to retry creates with the same idempotency key

Rate limits

AllowanceDefault
Per user, across all your tokens60 units per minute
Per token30 units per minute
Expense creates500 per user per UTC day
Request body64 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}`));