Connect this workspace to your own systems — read and write your records, categories, businesses, and members over HTTP.
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.
403.All endpoints live under your workspace:
https://YOUR_HANDLE.rooche.biz/sumly/api/orgs/YOUR_HANDLE
Content-Type: application/json on writes).amountCents. 1,234.50 is 123450. The currency is your workspace's (the /api/v1 records carry a currency code; default PHP).2026-07-24T00:00:00.000Z).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.
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./api/orgs/YOUR_HANDLE/expensesList 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"
/api/orgs/YOUR_HANDLE/expensesCreate a record.
| Field | Type | Notes |
|---|---|---|
kind | string | EXPENSE, INCOME, or TRANSFER. Defaults to EXPENSE. |
amountCents | integer | Amount in centavos (₱). ₱12.50 → 1250. Must be > 0. |
businessId | string | The business this record belongs to. For a transfer, the source. |
toBusinessId | string | transfer only The destination business (must differ from businessId). |
categoryId | string | Required 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). |
subcategory | string? | Optional free text. |
note | string? | Optional, ≤ 280 chars. |
spentAt | string | ISO-8601 timestamp; not in the future. |
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"
}'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
}
]
}
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./api/orgs/YOUR_HANDLE/expenses/importBulk-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.
| Field | Type | Notes |
|---|---|---|
businessId | string | Required. 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. |
rows | array | Each: { 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. |
skipDuplicates | boolean | When true, a row matching (businessId, day-of-spentAt, amountCents, kind) — against the existing ledger OR an earlier row in the same batch — is skipped. |
idempotencyKey | string | Optional (≤ 200 chars). A retried import with the same key replays the first result verbatim — no double-posting. |
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
}
POST /expenses uses, so record webhooks and threshold alerts fire per row and
recordedBy is the importer./api/orgs/YOUR_HANDLE/expenses/:idUpdate a record. Same body as create (its kind is fixed).
/api/orgs/YOUR_HANDLE/expenses/:idDelete 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).
/api/orgs/YOUR_HANDLE/expenses/:id/historyA 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).
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).
415. An upload that would push the workspace past its storage quota answers
413. Attachment events are not emitted over webhooks./api/orgs/YOUR_HANDLE/expenses/:id/attachmentsAttach 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.jpg201{
"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 }
}
/api/orgs/YOUR_HANDLE/attachments/:idFetch 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.
/api/orgs/YOUR_HANDLE/attachments/:id/posterA 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.
/api/orgs/YOUR_HANDLE/attachments/:idRemove an attachment (frees its bytes from the storage quota immediately). Returns the caller’s refreshed { "expenses", "storage" }.
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…" }
}
/api/v1/orgs/YOUR_HANDLE/expensesList records with cursor pagination, delta sync, and deletion tombstones.
| Query | Type | Notes |
|---|---|---|
limit | integer | Page size. Default 100, max 500. |
cursor | string | The nextCursor from the previous page. Keyset-based (stable under inserts) — page until hasMore is false. |
updatedSince | string | ISO-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. |
includeDeleted | boolean | When not doing a delta, set true to include soft-deleted records (each has a non-null deletedAt). |
businessId | string | Only records for this business. |
kind | string | Filter to EXPENSE, INCOME, or TRANSFER. |
spentFrom | string | ISO-8601. Only records with spentAt ≥ this (inclusive). |
spentTo | string | ISO-8601. Only records with spentAt ≤ this (inclusive). Pass a full timestamp for precise day boundaries. |
expand | string | expand=category inlines the category object on each record (see below) so you don't join against /categories. |
*.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.GET below answers with the same scope (out of scope reads as 404).Each record carries the same fields as the write API plus:
currency | string | ISO 4217 code for amountCents (e.g. PHP). The record is self-describing — no implicit currency. |
updatedAt | string | ISO-8601. Touched on every create, update, and delete — the value you page a delta feed by. |
deletedAt | string? | null for a live record; an ISO-8601 timestamp for a tombstone. |
approvedBy | string? | 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. |
approvedAt | string? | ISO-8601 when that approval happened; null for a direct write. |
category | object? | Present only with ?expand=category: { id, kind, label, emoji, color }, or null for a transfer or a deleted category. |
# 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"
/api/v1/orgs/YOUR_HANDLE/expenses/:idFetch one record: { "data": record }. A soft-deleted record answers 404 unless ?includeDeleted=true. Supports ?expand=category.
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).
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./api/v1/orgs/YOUR_HANDLE/expensesCreate a record. Returns 201 with { "data": record } — or 202 staged, for an approval-required member’s session (see above).
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_…" }'
/api/v1/orgs/YOUR_HANDLE/expenses/:idUpdate a record (its kind is fixed). Returns { "data": record } — or 202 staged, for an approval-required member’s session.
/api/v1/orgs/YOUR_HANDLE/expenses/:idSoft-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).
/api/v1/orgs/YOUR_HANDLE/businesses/:id/balanceThe 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).
Income and expense have separate category sets (a transfer has none). Each category is an emoji +
colour scoped to a kind.
/api/orgs/YOUR_HANDLE/categoriesList 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 }
]
}
/api/orgs/YOUR_HANDLE/categoriesCreate a category.
| Field | Type | Notes |
|---|---|---|
kind | string | EXPENSE or INCOME. |
label | string | Required, ≤ 40 chars. |
emoji | string | A single emoji. |
color | string | Hex, e.g. #FF7043. |
/api/orgs/YOUR_HANDLE/categories/:idUpdate a category (label, emoji, color).
/api/orgs/YOUR_HANDLE/categories/:idDelete a category. The last category of a kind can't be deleted (400); records of a deleted category fall back to a grey “Other”.
Every record belongs to a business (a name + emoji).
/api/orgs/YOUR_HANDLE/businessesList 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.
/api/orgs/YOUR_HANDLE/businessesCreate a business. Body: { "name": "Acme Cafe", "emoji": "🏢" }.
/api/orgs/YOUR_HANDLE/businesses/:idRename / re-emoji a business (admins; a consumer-face user, their own business).
/api/orgs/YOUR_HANDLE/businesses/:id/opening-balanceSet 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”).
| Field | Type | Notes |
|---|---|---|
amountCents | integer | The opening balance in minor units (centavos). May be negative. |
note | string | Optional, ≤ 280 chars (e.g. “Cash on hand”). Omitted = unchanged; ""/null clears it. |
Returns your (role-scoped) { "businesses": [...] } list, updated.
/api/orgs/YOUR_HANDLE/businesses/:idDelete a business. Refused (400) while it still has records.
/api/orgs/YOUR_HANDLE/business-labelRename 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.
People who have signed into the app. Each can be scoped to one or more businesses.
/api/orgs/YOUR_HANDLE/membersList members and the businesses each is assigned to.
{ "members": [ { "id": "...", "username": "carlos", "displayName": "Carlos", "businessIds": ["biz_..."], "approvalRequired": false } ] }
/api/orgs/YOUR_HANDLE/members/:userId/businessesSet a member’s businesses. Body: { "businessIds": ["biz_..."] }.
/api/orgs/YOUR_HANDLE/members/:userId/approval-requiredSet 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.
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.
/api/orgs/YOUR_HANDLE/approvalsThe 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)…": "…" }
}
]
}
/api/orgs/YOUR_HANDLE/approvals/:idThe 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.
/api/orgs/YOUR_HANDLE/approvals/:id/approveApprove 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.
/api/orgs/YOUR_HANDLE/approvals/:idDiscard 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.
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.
/api/orgs/YOUR_HANDLE/currenciesList the supported currencies and the workspace’s current one.
{ "currency": "PHP", "currencies": [ { "code": "PHP", "symbol": "₱", "name": "Philippine Peso", "decimals": 2 }, … ] }
/api/orgs/YOUR_HANDLE/currencySet 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" }.
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):
| type | fires when |
|---|---|
record_over | a single record’s amount reaches the threshold (once per record; re-fires only after it drops back under then over) |
monthly_over | a business’s month-to-date spend crosses up to the threshold (once per month, re-arms next month) |
balance_under | a business’s balance crosses below the threshold (re-arms when it recovers) |
/api/orgs/YOUR_HANDLE/threshold-rulesList alert rules.
{ "rules": [ { "id": "thr_…", "enabled": true, "type": "record_over", "businessId": null, "kind": "EXPENSE", "thresholdCents": 5000000, "createdAt": "…", "updatedAt": "…" } ] }
/api/orgs/YOUR_HANDLE/threshold-rulesCreate a rule. Body: { "type": "record_over", "thresholdCents": 5000000, "businessId": null, "kind": "EXPENSE", "enabled": true }. Returns the refreshed list.
/api/orgs/YOUR_HANDLE/threshold-rules/:idUpdate a rule (same body; omitted fields keep their value). Returns the refreshed list.
/api/orgs/YOUR_HANDLE/threshold-rules/:idDelete a rule. Returns the refreshed list.
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.
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.crossedFired 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"
}
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"
}
}
Every delivery carries these headers:
X-Sumly-Signature | t=<unix>,v1=<hex> |
X-Sumly-Event | the event type |
X-Sumly-Delivery | this 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))
id.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).
/api/orgs/YOUR_HANDLE/upgrade-requestSubmit 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.
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).
/api/orgs/YOUR_HANDLE/power-upsTurn 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).
/api/orgs/YOUR_HANDLE/me/power-upsTurn 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.
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).
/api/orgs/YOUR_HANDLE/me/walkthrough-seenMark 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.
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.
/api/orgs/YOUR_HANDLE/reset-dataReset 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 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:
| Status | Meaning |
|---|---|
400 | Bad request — a validation rule failed (the message says which). |
401 | Missing or invalid key. |
403 | Not allowed for a key (key management, support). |
404 | No such workspace or record. |
429 | Rate limit exceeded — slow down and retry after Retry-After seconds. |
RateLimit-Limit,
RateLimit-Remaining, and RateLimit-Reset (seconds until the window
resets) so you can pace yourself. Over the limit answers 429 with a
Retry-After header — back off and retry. The exact numbers depend on your plan;
contact us to raise them.