Sumly API

Connect this workspace to your own systems — read and write your records, categories, businesses, and members over HTTP.

Authentication

Every request needs an API integration key, sent as a bearer token:

Authorization: Bearer spk_your_key_here

An admin generates a key in Settings → API integration keys inside the app. A key is shown once — store it somewhere safe. It authenticates as a workspace admin: it can read and write everything in this workspace, so treat it like a password. Revoke a key any time from the same screen; a revoked key stops working immediately.

A key cannot manage other keys, cannot open support conversations, and cannot decide approvals (approve/decline a staged change, or change who requires approval) — those are signed-in-person actions. Requests for them answer 403.

Base URL & conventions

All endpoints live under your workspace:

https://YOUR_HANDLE.rooche.biz/sumly/api/orgs/YOUR_HANDLE

Records

A record is an expense (money out), an income (money in), or a transfer (money moving between two of your businesses — no category, and it nets to zero in totals). The endpoint is /expenses for all three; kind distinguishes them.

Transfers are one row, not two. A TRANSFER is a single record: businessId is the source and toBusinessId is the destination. There is no mirrored second row. In a per-business balance it counts as money out of the source and in to the destination, so across the whole workspace it nets to zero.
GET/api/orgs/YOUR_HANDLE/expenses

List every record in the workspace, newest first.

curl https://YOUR_HANDLE.rooche.biz/sumly/api/orgs/YOUR_HANDLE/expenses \
  -H "Authorization: Bearer spk_your_key_here"
POST/api/orgs/YOUR_HANDLE/expenses

Create a record.

FieldTypeNotes
kindstringEXPENSE, INCOME, or TRANSFER. Defaults to EXPENSE.
amountCentsintegerAmount in centavos (₱). ₱12.50 → 1250. Must be > 0.
businessIdstringThe business this record belongs to. For a transfer, the source.
toBusinessIdstringtransfer only The destination business (must differ from businessId).
categoryIdstringRequired for an income or expense, and must be an existing category of the same kind — a missing or unresolvable id answers 400 { "code": "category_required" } (no fallback category is ever assigned). Omit for a transfer (a transfer carries no category).
subcategorystring?Optional free text.
notestring?Optional, ≤ 280 chars.
spentAtstringISO-8601 timestamp; not in the future.

Example

curl -X POST https://YOUR_HANDLE.rooche.biz/sumly/api/orgs/YOUR_HANDLE/expenses \
  -H "Authorization: Bearer spk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "kind": "EXPENSE",
    "amountCents": 45000,
    "businessId": "biz_...",
    "categoryId": "cat_...",
    "note": "Supplies"
  }'

Response 201

{
  "expenses": [
    {
      "id": "exp_...",
      "kind": "EXPENSE",
      "amountCents": 45000,
      "categoryId": "cat_...",
      "businessId": "biz_...",
      "toBusinessId": null,
      "subcategory": null,
      "note": "Supplies",
      "spentAt": "2026-07-24T00:00:00.000Z",
      "recordedBy": "apikey_...",
      "recordedByName": "Accounting sync",
      "createdAt": "2026-07-24T01:02:03.456Z",
      "approvedBy": null,
      "approvedAt": null
    }
  ]
}
Approval-required members. A workspace can mark a member as requires approval. For such a member's signed-in session, create/update/delete answer 202 and the change is staged — it appears in /approvals (below) and touches nothing until an admin or manager approves it. An API key is a workspace admin, so key requests are never staged; this only concerns integrations replaying a member's session.
POST/api/orgs/YOUR_HANDLE/expenses/import

Bulk-create records from a parsed file or paste (web Import feature, charter §5). Human-only and elevated: a signed-in admin, a manager (into their own businesses), or a consumer (their own tenant) — a plain member answers 403, and an API key answers 403 (this is a signed-in desk task, not an integration surface — use the per-record POST /expenses for automation). Partial by design: good rows are created, bad rows are reported.

FieldTypeNotes
businessIdstringRequired. The single destination for the whole batch; must be a business you may record against (a manager’s must be in their assigned set) — else 403.
rowsarrayEach: { kind: "INCOME"|"EXPENSE", amountCents (positive integer minor units), categoryName, spentAt (ISO), note?, subcategory? }. TRANSFER is not importable. An unknown categoryName is auto-created (kind-scoped, once per name) and reused; a case-insensitive match reuses the existing category. A missing/invalid spentAt falls back to today; a backdated row lands in its real month. Capped at 500 rows — the rest are dropped and truncated is true.
skipDuplicatesbooleanWhen true, a row matching (businessId, day-of-spentAt, amountCents, kind) — against the existing ledger OR an earlier row in the same batch — is skipped.
idempotencyKeystringOptional (≤ 200 chars). A retried import with the same key replays the first result verbatim — no double-posting.

Response 200

{
  "imported": 47,
  "skipped": 2,
  "createdCategories": [ { "name": "Load", "kind": "EXPENSE" } ],
  "errors": [ { "index": 14, "reason": "Amount must be a positive whole number of minor units" } ],
  "truncated": false
}
Each surviving row is created through the same validated write path a single POST /expenses uses, so record webhooks and threshold alerts fire per row and recordedBy is the importer.
PUT/api/orgs/YOUR_HANDLE/expenses/:id

Update a record. Same body as create (its kind is fixed).

DELETE/api/orgs/YOUR_HANDLE/expenses/:id

Delete a record. This is a soft delete: the record stops appearing in normal reads, but the versioned API can still surface it as a tombstone so you can reconcile the removal (see below).

GET/api/orgs/YOUR_HANDLE/expenses/:id/history

A record’s edit history, newest first: every mutation that reached the ledger — a create/edit/delete, plus an approver’s approve/decline of a staged change and an attachment being added/removed — as { "record": …, "events": [ { "action", "actorName", "at", "changes": [ { "field", "from", "to" } ], "note", "viaApproval" } ] }. Scoped to who may see the record (a member their own; an admin/manager per their scope).

Attachments power-up

With the Attachments power-up switched on for your workspace (an admin's Settings → Power-ups toggle — see the Power-ups section below), a record can carry media — receipts (image/PDF), voice notes, and video. While the power-up is off, every endpoint in this section answers 403 with { "code": "power_up_off" }. Permissions follow the record's own: whoever may edit a record may add or remove its attachments; whoever may see it may fetch the bytes. When the power-up is on, records in list responses carry an attachments array (the same metadata shape as below), and the GET /expenses list additionally carries attachmentsEnabled and a storage object ({ "usedBytes", "quotaBytes" } — the workspace-wide 2 GB media budget).

What the server accepts is decided by the file's bytes, never its name: JPEG/PNG/WebP images, MP4/AAC/WebM audio and video, and PDF documents. Anything else answers 415. An upload that would push the workspace past its storage quota answers 413. Attachment events are not emitted over webhooks.
POST/api/orgs/YOUR_HANDLE/expenses/:id/attachments

Attach a file to a record. Multipart (not JSON): a single file part, a kind field (voice | video | image | file — must agree with the file’s actual content), and an optional durationMs for audio/video.

curl -X POST https://YOUR_HANDLE.rooche.biz/sumly/api/orgs/YOUR_HANDLE/expenses/exp_…/attachments \
  -H "Authorization: Bearer spk_your_key_here" \
  -F kind=image \
  -F file=@receipt.jpg

Response 201

{
  "attachment": {
    "id": "att_…", "expenseId": "exp_…", "kind": "image",
    "mimeType": "image/jpeg", "filename": "receipt.jpg",
    "sizeBytes": 184230, "durationMs": null, "createdAt": "2026-08-25T01:02:03.456Z",
    "hasPoster": false
  },
  "storage": { "usedBytes": 184230, "quotaBytes": 2147483648 }
}
GET/api/orgs/YOUR_HANDLE/attachments/:id

Fetch an attachment’s bytes (the only way to them — there is no public URL). Served with the sniffed Content-Type, Range support for audio/video seeking, and images/audio/video inline vs. everything else as a download.

GET/api/orgs/YOUR_HANDLE/attachments/:id/poster

A video attachment’s poster frame — a JPEG of a representative frame, for previews. Video attachments advertise it with "hasPoster": true in their metadata. Same permission as the bytes (whoever may see the record). Answers 404 for a non-video attachment or when a poster could not be generated.

DELETE/api/orgs/YOUR_HANDLE/attachments/:id

Remove an attachment (frees its bytes from the storage quota immediately). Returns the caller’s refreshed { "expenses", "storage" }.

Versioned API /api/v1

For robust, incremental sync, use the versioned read surface at https://YOUR_HANDLE.rooche.biz/sumly/api/v1/orgs/YOUR_HANDLE. It authenticates the same way (your API key) and is a stable contract — the unversioned routes above are what the Sumly apps use and may change shape; build integrations against /api/v1. A machine-readable spec is at https://YOUR_HANDLE.rooche.biz/sumly/openapi.json (OpenAPI 3.1). Every response uses a consistent envelope — a list is { data, page }, and a single record (read or write) is { data }:

{
  "data": [ …records… ],
  "page": { "limit": 100, "hasMore": true, "nextCursor": "eyJtIjoi…" }
}
GET/api/v1/orgs/YOUR_HANDLE/expenses

List records with cursor pagination, delta sync, and deletion tombstones.

QueryTypeNotes
limitintegerPage size. Default 100, max 500.
cursorstringThe nextCursor from the previous page. Keyset-based (stable under inserts) — page until hasMore is false.
updatedSincestringISO-8601. Returns only records whose updatedAt is strictly after this, oldest-change first — a delta feed. In this mode tombstones are always included (a delete is a change). Store the newest updatedAt you saw and pass it back next time.
includeDeletedbooleanWhen not doing a delta, set true to include soft-deleted records (each has a non-null deletedAt).
businessIdstringOnly records for this business.
kindstringFilter to EXPENSE, INCOME, or TRANSFER.
spentFromstringISO-8601. Only records with spentAt ≥ this (inclusive).
spentTostringISO-8601. Only records with spentAt ≤ this (inclusive). Pass a full timestamp for precise day boundaries.
expandstringexpand=category inlines the category object on each record (see below) so you don't join against /categories.
Reconciling deletes. Webhooks emit *.deleted, but if you miss one, a delta pull still repairs it: a removed record comes back with a non-null deletedAt in the updatedSince feed. Apply it as a delete on your side.
Scope follows the caller. A personal API key (the kind issued in Settings → API keys) acts as the person who created it — it reads exactly what that person's session reads. A signed-in session (or personal key) reads exactly what the app shows that person: an admin the whole workspace, a manager every entry in their assigned businesses, a member only their own entries; a consumer their own tenant. (A legacy workspace key, issued before per-user keys, still reads the whole workspace.) The single-record GET below answers with the same scope (out of scope reads as 404).

Record fields

Each record carries the same fields as the write API plus:

currencystringISO 4217 code for amountCents (e.g. PHP). The record is self-describing — no implicit currency.
updatedAtstringISO-8601. Touched on every create, update, and delete — the value you page a delta feed by.
deletedAtstring?null for a live record; an ISO-8601 timestamp for a tombstone.
approvedBystring?The admin/manager who approved this record's staged change, or null for a direct write. This API only ever sees approved truth — a staged change is invisible here until it lands.
approvedAtstring?ISO-8601 when that approval happened; null for a direct write.
categoryobject?Present only with ?expand=category: { id, kind, label, emoji, color }, or null for a transfer or a deleted category.

Example — walk a delta feed

# first sync: no watermark yet, page through everything
curl "https://YOUR_HANDLE.rooche.biz/sumly/api/v1/orgs/YOUR_HANDLE/expenses?limit=200" \
  -H "Authorization: Bearer spk_your_key_here"
# → { "data": [...], "page": { "hasMore": true, "nextCursor": "…" } }
# follow nextCursor until hasMore is false, remembering the max updatedAt you saw.

# later: fetch only what changed (incl. deletes) since that watermark
curl "https://YOUR_HANDLE.rooche.biz/sumly/api/v1/orgs/YOUR_HANDLE/expenses?updatedSince=2026-08-03T09:15:04.000Z" \
  -H "Authorization: Bearer spk_your_key_here"
GET/api/v1/orgs/YOUR_HANDLE/expenses/:id

Fetch one record: { "data": record }. A soft-deleted record answers 404 unless ?includeDeleted=true. Supports ?expand=category.

Writes

Create, update, and delete under /api/v1 return the single affected record in the same envelope — { "data": record } — instead of the whole collection. The body and rules match the unversioned write API above (a transfer is admin-only; kind is fixed after creation).

Approval-required members stage here too. For an approval-required member's signed-in session, create/update/delete answer 202 with { "data": null, "approval": { "id", "status": "AWAITING", "action", "expenseId", "submittedAt" } } — the change is staged (it appears in /approvals) and no record is written, so no webhook fires and no total moves until an admin or manager approves it. A personal key acts as its owner, so a key owned by an approval-required member stages exactly as that member's session would; a legacy workspace key is a workspace admin and is never staged.
POST/api/v1/orgs/YOUR_HANDLE/expenses

Create a record. Returns 201 with { "data": record } — or 202 staged, for an approval-required member’s session (see above).

Idempotency

Send an Idempotency-Key header (any unique string, ≤ 200 chars) so a retried create after a dropped response never makes a duplicate — the first response is stored per workspace and replayed verbatim for the same key.

curl -X POST https://YOUR_HANDLE.rooche.biz/sumly/api/v1/orgs/YOUR_HANDLE/expenses \
  -H "Authorization: Bearer spk_your_key_here" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 3f9c1a7e-order-4821" \
  -d '{ "kind": "EXPENSE", "amountCents": 45000, "businessId": "biz_…", "categoryId": "cat_…" }'
PUT/api/v1/orgs/YOUR_HANDLE/expenses/:id

Update a record (its kind is fixed). Returns { "data": record } — or 202 staged, for an approval-required member’s session.

DELETE/api/v1/orgs/YOUR_HANDLE/expenses/:id

Soft-delete a record. Returns { "data": record } with the record as a tombstone (deletedAt set) — or 202 staged, for an approval-required member’s session (the record keeps counting until the removal is approved).

Balances

GET/api/v1/orgs/YOUR_HANDLE/businesses/:id/balance

The current balance of a business, computed server-side (admin only).

Every consumer gets the same number instead of re-implementing the opening-balance + transfer math:

{
  "data": {
    "businessId": "biz_…",
    "currency": "PHP",
    "openingBalanceCents": 239177326,
    "incomeCents": 19919958,
    "expenseCents": 3300058,
    "transferInCents": 0,
    "transferOutCents": 0,
    "balanceCents": 255797226
  }
}

balanceCents = openingBalanceCents + incomeCents − expenseCents + transferInCents − transferOutCents, over live records only (tombstones don't move money).

Categories

Income and expense have separate category sets (a transfer has none). Each category is an emoji + colour scoped to a kind.

GET/api/orgs/YOUR_HANDLE/categories

List categories and sub-category suggestions.

{
  "categories": [
    { "id": "cat_...", "kind": "EXPENSE", "label": "Food", "emoji": "🍜", "color": "#FF7043", "position": 0 }
  ],
  "subcategories": [
    { "id": "sub_...", "categoryId": "cat_...", "label": "Lunch", "position": 0 }
  ]
}
POST/api/orgs/YOUR_HANDLE/categories

Create a category.

FieldTypeNotes
kindstringEXPENSE or INCOME.
labelstringRequired, ≤ 40 chars.
emojistringA single emoji.
colorstringHex, e.g. #FF7043.
PUT/api/orgs/YOUR_HANDLE/categories/:id

Update a category (label, emoji, color).

DELETE/api/orgs/YOUR_HANDLE/categories/:id

Delete a category. The last category of a kind can't be deleted (400); records of a deleted category fall back to a grey “Other”.

Businesses

Every record belongs to a business (a name + emoji).

GET/api/orgs/YOUR_HANDLE/businesses

List businesses.

{ "businesses": [ { "id": "biz_...", "name": "Acme Cafe", "emoji": "🏢", "position": 0, "openingBalanceCents": 5200000, "openingBalanceNote": "Cash on hand" } ] }

openingBalanceCents / openingBalanceNote are admin-only figures: they are absent from a plain member's payload.

POST/api/orgs/YOUR_HANDLE/businesses

Create a business. Body: { "name": "Acme Cafe", "emoji": "🏢" }.

PUT/api/orgs/YOUR_HANDLE/businesses/:id

Rename / re-emoji a business (admins; a consumer-face user, their own business).

PUT/api/orgs/YOUR_HANDLE/businesses/:id/opening-balance

Set the business’s opening (starting) balance — the floor the running balance is computed from. Admins any business; a manager, one assigned to them; a consumer-face user, their own. No date is stored (the balance is simply “as of now”).

FieldTypeNotes
amountCentsintegerThe opening balance in minor units (centavos). May be negative.
notestringOptional, ≤ 280 chars (e.g. “Cash on hand”). Omitted = unchanged; ""/null clears it.

Returns your (role-scoped) { "businesses": [...] } list, updated.

DELETE/api/orgs/YOUR_HANDLE/businesses/:id

Delete a business. Refused (400) while it still has records.

PUT/api/orgs/YOUR_HANDLE/business-label

Rename what the workspace calls a “business” (e.g. Store/Stores). Body: { "singular": "Store", "plural": "Stores" } — both blank restores the default; a missing plural is derived; over 40 characters answers 400. Admins (workspace-wide; a consumer-face user sets their own). Returns { "businessLabel": { "singular": "Store", "plural": "Stores" } } (null when default). The current label is on the org info (GET /api/orgs/YOUR_HANDLE) as businessLabel.

Members

People who have signed into the app. Each can be scoped to one or more businesses.

GET/api/orgs/YOUR_HANDLE/members

List members and the businesses each is assigned to.

{ "members": [ { "id": "...", "username": "carlos", "displayName": "Carlos", "businessIds": ["biz_..."], "approvalRequired": false } ] }
PUT/api/orgs/YOUR_HANDLE/members/:userId/businesses

Set a member’s businesses. Body: { "businessIds": ["biz_..."] }.

PUT/api/orgs/YOUR_HANDLE/members/:userId/approval-required

Set whether a member’s ledger writes need approval. Body: { "approvalRequired": true }. Members only (400 for an admin/manager target); signed-in admins — or a manager, for members within their own businesses — an API key answers 403.

Approvals

When a member is marked requires approval, their income/expense create, update, and delete are staged: the ledger, every balance and total, the /api/v1 feed, and webhooks see nothing until an admin (whole workspace) or a manager (their businesses) approves the change — approving applies it through the normal write path and stamps the record's approvedBy/approvedAt; webhook events fire at that moment. Declining leaves the ledger untouched: the item becomes a notice only its submitter sees ("recently declined", with the reason) until they dismiss it or submit afresh.

GET/api/orgs/YOUR_HANDLE/approvals

The caller-scoped list of staged changes. An admin (or API key) sees every item AWAITING approval; a manager, those in their businesses; a member, only their own submissions — awaiting and declined.

{
  "approvals": [
    {
      "id": "apr_...",
      "action": "EDIT",                    // CREATE | EDIT | DELETE
      "expenseId": "exp_...",              // null for a CREATE
      "kind": "EXPENSE",
      "amountCents": 45000,                // proposed values (null for a DELETE)
      "categoryId": "cat_...",
      "businessId": "biz_...",             // the record's current business (a CREATE: the proposed one)
      "proposedBusinessId": null,          // set when an EDIT moves the record
      "subcategory": null,
      "note": "corrected amount",
      "spentAt": "2026-08-10T00:00:00.000Z",
      "status": "AWAITING",                // AWAITING | DECLINED
      "declineReason": "", "declinedBy": null, "declinedByName": "", "declinedAt": null,
      "submittedBy": "...", "submittedByName": "Ana",
      "submittedAt": "2026-08-10T09:00:00.000Z", "updatedAt": "2026-08-10T09:00:00.000Z",
      "current": { "…the live record as it stands (null for a CREATE)…": "…" }
    }
  ]
}
PUT/api/orgs/YOUR_HANDLE/approvals/:id

The submitter edits their own still-awaiting item in place (same body as a record write; its kind is fixed). Only the submitter — approvers approve or decline, never rewrite.

POST/api/orgs/YOUR_HANDLE/approvals/:id/approve

Approve a staged change: it is applied to the ledger (webhooks fire now) with the approver’s approvedBy/approvedAt stamp, and leaves the queue. Signed-in admins, or a manager whose businesses cover the change (both ends of a move) — an API key answers 403. If the record was deleted underneath a staged edit, answers 409 and discards the item. Approve with changes: send a record body (same shape as a create) to amend the proposal before approving — the amended values are what land, and the record’s history notes the amendment.

DELETE/api/orgs/YOUR_HANDLE/approvals/:id

Discard a staged item. The submitter withdraws their awaiting item (or dismisses a declined notice); an approver declines an awaiting item — optional body { "reason": "…" } (≤ 280 chars) — leaving the ledger exactly as it was. Signed-in people only.

Currency

The workspace has a single display currency (charter §2). It is relabel only — changing it never converts amounts you already recorded; money is stored as integer minor units and only the symbol + decimal places of the label change. Supported: PHP, USD, EUR, GBP, JPY, AUD, SGD, KWD (JPY has 0 decimals, KWD 3, the rest 2). Each versioned record carries the workspace currency so a payload is self-describing.

GET/api/orgs/YOUR_HANDLE/currencies

List the supported currencies and the workspace’s current one.

{ "currency": "PHP", "currencies": [ { "code": "PHP", "symbol": "₱", "name": "Philippine Peso", "decimals": 2 }, … ] }
PUT/api/orgs/YOUR_HANDLE/currency

Set the currency. Body: { "currency": "USD" }. Admins and managers set the workspace currency; on a consumer instance each user sets their own (it never affects anyone else). Signed-in only (an API key answers 403); an unsupported code answers 400. Returns { "currency": "USD" }.

Threshold alerts

Fire a signed threshold.crossed webhook when money crosses a limit. Managed by an admin in Settings → Alerts (web only, signed-in — an API key answers 403). A rule has a type, an integer thresholdCents (minor units), an optional businessId (null = any business) and, for record_over, an optional kind filter (EXPENSE/INCOME/TRANSFER):

typefires when
record_overa single record’s amount reaches the threshold (once per record; re-fires only after it drops back under then over)
monthly_overa business’s month-to-date spend crosses up to the threshold (once per month, re-arms next month)
balance_undera business’s balance crosses below the threshold (re-arms when it recovers)
GET/api/orgs/YOUR_HANDLE/threshold-rules

List alert rules.

{ "rules": [ { "id": "thr_…", "enabled": true, "type": "record_over", "businessId": null, "kind": "EXPENSE", "thresholdCents": 5000000, "createdAt": "…", "updatedAt": "…" } ] }
POST/api/orgs/YOUR_HANDLE/threshold-rules

Create a rule. Body: { "type": "record_over", "thresholdCents": 5000000, "businessId": null, "kind": "EXPENSE", "enabled": true }. Returns the refreshed list.

PUT/api/orgs/YOUR_HANDLE/threshold-rules/:id

Update a rule (same body; omitted fields keep their value). Returns the refreshed list.

DELETE/api/orgs/YOUR_HANDLE/threshold-rules/:id

Delete a rule. Returns the refreshed list.

Webhooks

Instead of polling, subscribe a URL to be notified the moment a record changes. An admin adds endpoints in Settings → Webhooks (web only); on a consumer instance each user manages their own endpoints, which fire only for their own records (never another account's). Sumly then sends a signed POST to your URL for every event you subscribed to.

Events

For each record kind — expense, income, transfer — you can subscribe to .created, .updated and .deleted (nine in total, e.g. expense.created, transfer.deleted), plus threshold.crossed (a threshold alert — see below).

threshold.crossed

Fired when a threshold rule crosses. The body identifies the rule, the metric that crossed, the record that triggered it, and the business:

{
  "event": "threshold.crossed",
  "rule": { "id": "thr_…", "type": "monthly_over", "thresholdCents": 50000000, "businessId": "biz_…", "kind": null },
  "metric": { "value": 51200000, "period": "2026-08" },   // record_over: { value }; monthly_over: { value, period }; balance_under: { value }
  "record": { "id": "exp_…", "kind": "EXPENSE", "amountCents": 120000, "businessId": "biz_…", "…": "…" },
  "business": { "id": "biz_…", "name": "Acme Cafe", "emoji": "🏢" },
  "at": "2026-08-16T09:15:04.000Z"
}

Payload

The body is JSON. data is the record (same shape the API returns; on a delete it is the record as it last existed).

{
  "id": "evt_9f2c…",            // unique event id — use it to dedupe
  "type": "expense.created",
  "createdAt": "2026-08-03T09:15:04.000Z",
  "data": {
    "id": "exp_…", "kind": "EXPENSE", "amountCents": 5000,
    "categoryId": "cat_…", "businessId": "biz_…", "toBusinessId": null,
    "subcategory": null, "note": "lunch", "spentAt": "2026-08-03",
    "recordedBy": "usr_…", "recordedByName": "Carlos", "createdAt": "2026-08-03T09:15:04.000Z"
  }
}

Verifying the signature

Every delivery carries these headers:

X-Sumly-Signaturet=<unix>,v1=<hex>
X-Sumly-Eventthe event type
X-Sumly-Deliverythis delivery's id (for your logs)

Recompute v1 as HMAC-SHA256(secret, "<t>." + rawBody) using the signing secret shown when you created the endpoint, and compare (constant-time). Reject the request if it doesn't match, or if t is old, to defeat replays.

// Node
const [ , t, v1 ] = header.match(/t=(\d+),v1=([0-9a-f]+)/)
const expected = crypto.createHmac('sha256', secret).update(t + '.' + rawBody).digest('hex')
const ok = crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected))

Delivery

Upgrade request consumer face

On the personal (consumer) face at base.getsumly.app, a signed-in person can ask to upgrade their account into an organization workspace. Request-only: it opens a conversation with the Rooche team — nothing on the account changes until they've been in touch. Signed-in consumer sessions only: every organization-workspace role — and an API key — answers 403 (this endpoint exists so the app's Settings → Upgrade flow is documented; it is not an integration surface).

POST/api/orgs/YOUR_HANDLE/upgrade-request

Submit the upgrade request. Body: { "orgName": "Santos Family Stores", "teamSize": "2-5", "notes": "…" } — orgName required (≤ 80 chars, becomes the workspace name), teamSize one of 1 | 2-5 | 6-15 | 16+, notes optional (≤ 2000). Returns 201 { "upgradeRequestedAt": "…" } and the same timestamp thereafter on GET /me. One open request at a time: a second submit answers 409 with the existing upgradeRequestedAt (also while an approved request is being set up); after a request was not approved (upgradeDecision on GET /me reads denied) a fresh submit is accepted and resets the decision. The request is forwarded to the Rooche support inbox (the same channel as Get help), so follow-ups happen there.

Power-ups

Every workspace has the product's power-ups (currently just Attachments); what varies is whether each one is switched on. Power-ups are off by default — until one is enabled, its feature endpoints answer 403 { "code": "power_up_off" }. Who flips the switch depends on the workspace: on an organization workspace an admin enables a power-up for everyone (Settings → Power-ups; managers and members see the state but can't change it), while on the personal (consumer) face each person switches their own. The current state rides GET /me on both faces as "powerUps": [ { "key": "attachments", "name": "Attachments", "enabled": false } ] — one entry per product power-up, enabled reflecting the workspace's state (org) or the caller's own setting (consumer).

PUT/api/orgs/YOUR_HANDLE/power-ups

Turn a power-up on or off for the WHOLE workspace (organization workspaces). Body: { "key": "attachments", "enabled": true } — key must be a product power-up (400 otherwise), enabled a boolean. Admins only, signed-in humans only: managers and members answer 403, and so does an API key — enabling a workspace surface is an owning-role decision. Returns the refreshed { "powerUps": [ … ] } array (the same shape GET /me carries).

POST/api/orgs/YOUR_HANDLE/me/power-ups

Turn one of YOUR power-ups on or off (personal/consumer face only — every organization-workspace role answers 403; an organization's switch is the admin PUT /power-ups above). Body: { "key": "attachments", "enabled": true } — key must be a product power-up (400 otherwise), enabled a boolean. Returns the refreshed { "powerUps": [ … ] } array. Every power-up starts off; until you switch one on, its feature endpoints answer 403 { "code": "power_up_off" } for you.

Welcome walkthrough

On an organization workspace, an admin's first sign-in opens a one-time welcome walkthrough before the first-run setup. The seen-marker rides the sign-in payload and GET /me as "walkthroughSeenAt" (ISO timestamp, or null until the walkthrough was finished or skipped). Signed-in humans only — an API key answers 403 (a key has no first-run to walk through; this endpoint exists so the app's own flow is documented, not as an integration surface).

POST/api/orgs/YOUR_HANDLE/me/walkthrough-seen

Mark the caller's own walkthrough as seen (both "Get started" and "Skip" call it). Idempotent — the first call stamps the current time, later calls keep the original. Returns { "ok": true, "walkthroughSeenAt": "…" }; the same timestamp rides GET /me thereafter. There is no way to clear it — the walkthrough shows once.

Reset data

A destructive reset (Settings → Reset data, web only) whose meaning depends on the face. On an organization workspace it clears one business's ledger — the named business's records (income, expense, and any transfer touching it), their attachments (bytes freed from storage), staged/declined approvals, record history — and zeroes that business's opening balance; the business itself (name + emoji), every other business, the workspace-shared categories, users, and settings are untouched. On the personal (consumer) face it is a start-over: the caller's business is deleted outright together with all its records and their own categories, returning them to fresh onboarding (they are asked to name their business again); the account and sign-in are kept. It is not account deletion and there is no undo.

POST/api/orgs/YOUR_HANDLE/reset-data

Reset data. Signed-in humans only: an organization ADMIN/SUPERADMIN clears ONE business — body { "businessId": "…", "confirm": "…" }, where businessId names a business of the workspace (404 otherwise); on the personal (consumer) face a signed-in person starts over — body { "confirm": "…" }, their whole tenant including the business itself. A MANAGER, a plain member, and an API key answer 403. confirm must equal the target business's name (RESET when it has none); a mismatch answers 400 and changes nothing. Returns { "mode": "business" | "start_over", "recordsDeleted", "businessesReset", "businessesDeleted" }.

Errors

Errors are JSON: { "error": "message" }, with a matching status. Some carry a stable machine-readable code alongside the message — e.g. category_required (an income/expense write without a valid category of its kind) or power_up_off — so clients can branch without string-matching:

StatusMeaning
400Bad request — a validation rule failed (the message says which).
401Missing or invalid key.
403Not allowed for a key (key management, support).
404No such workspace or record.
429Rate limit exceeded — slow down and retry after Retry-After seconds.

Limits

← Back to Sumly