Recipes
Worked examples that string the endpoints together. Each one assumes you have a key with the permissions it names.
Chase everyone more than 30 days overdue
Needs: read , send
There is no server-side "30 days overdue" filter. Fetch the overdue contacts and filter them yourself on most_days_overdue , which the listing already returns — so this costs no extra requests.
This is not an omission: Statey's own scheduled runs offer the same set of filters, and the API deliberately matches what the app can do rather than inventing a second vocabulary.
1. GET /api/v1/sync_status
→ if state is not "idle", stop. Do not send from half-rebuilt data.
2. GET /api/v1/contacts?filter=overdue&sort=most_days_overdue&direction=desc&page_size=200
→ keep the ones where most_days_overdue > 30
→ page with page=2, 3 … until meta.total_pages is reached
3. POST /api/v1/statement_sends/preview
{ "contact_ids": [...] }
→ 200: check recipients.will_receive and quota.sufficient
4. POST /api/v1/statement_sends
Idempotency-Key: <one uuid for this run>
{ "contact_ids": [...] }
→ 202 Accepted, with a recipients block naming anyone skipped
Notes:
- Step 3 is cheap and worth it on a big run. It reserves no allowance and tells you how many of your list can actually be emailed, so a job that would run out of allowance half-way finds out before it starts rather than after 2,000 emails. See Asynchronous work.
- Do this with a test key first. Same calls, same responses, nothing delivered — then check the recipient list on the campaign before you run it for real. See Test keys.
- Step 1 matters more than it looks. See Data freshness.
- A contact appears once per currency, so the same
idcan come back twice. De-duplicate before sending, or you will pay for the same recipient twice against your allowance. - Read
recipients.skippedon the response. Contacts with no email address are skipped and do not consume allowance, but they are named there — a monthly run that quietly loses sixty archived contacts looks identical to one that worked. - Batch at 1,000 contacts per request, each with its own idempotency key.
React to a bounce
Needs: read , write
Rather than polling for delivery outcomes, subscribe to statement.delivery_updated and act when a bounce arrives.
1. Account → API → Webhooks → add an endpoint, tick statement.delivery_updated
2. On each delivery:
- verify the signature (see Webhooks)
- respond 2xx immediately, queue the rest
- ignore ids you have already processed
3. If data.status is "bounce", "dropped" or "spamreport":
POST /api/v1/contacts/{data.contact_id}/notes
{ "text": "Statement bounced: ..." }
and, if you want to stop chasing them automatically:
PATCH /api/v1/contacts/{data.contact_id}
{ "excluded_from_schedule": true }
The note is attributed to the API key, so it shows in Statey as your key's label followed by (API) — "Warehouse system (API)" — rather than appearing to be written by a person. A test key reads (API test) instead, so rehearsal notes are obvious in the history.
excluded_from_schedule stops the contact receiving automated statement runs. It does not stop a POST /statement_sends that names them explicitly — that is a deliberate instruction and is honoured.
Download a statement for one customer
Needs: read
1. POST /api/v1/contacts/{id}/statement_export
{ "format_type": "pdf", "start_date": "2026-08-01", "end_date": "2026-08-31" }
→ 202, with an export id
2. GET /api/v1/statement_exports/{export_id}
→ poll, a second or two apart, until status is "completed"
3. GET the download_url
→ no Authorization header needed; the signature is in the URL
The URL expires after 24 hours. Fetch the file when it is ready rather than storing the link.
See Asynchronous work.
Mirror your debtor book into your own system
Needs: read
1. GET /api/v1/sync_status → if a rebuild is running, wait for it 2. GET /api/v1/contacts?sort=xero_name&page_size=200 → note meta.total_pages and meta.total_count 3. Repeat for page=2 … page=N → if meta.data_freshness.rebuild_in_progress turns true, start again 4. Compare meta.total_count with how many you collected 5. Store meta.data_freshness.last_completed_at with the batch
Four things to get right:
- Key your records on
(id, currency), notid. A contact trading in two currencies is two rows. - Money is a string. Parse it as a decimal — a float loses cents.
- Sort by
xero_namefor a full export. Balances change as Statey recalculates in the background, which can move rows between pages while you read them. Names do not change, so name order is stable. See Searching, sorting and paging. - Check
total_countat the end. If it does not match what you collected, the list shifted while you were reading it and the export is incomplete.
To refresh rather than poll, subscribe to organisation.data_rebuilt and re-sync when it fires.
Check a key works before relying on it
Needs: any key
GET /api/v1/organisation
Returns the organisation, its base currency and timezone, and the permissions the key holds. Useful as a startup check, and as the first thing to try when something is not working.
If it returns 401 invalid_token the key is wrong or revoked. 402 plan_too_low means the organisation is not on the Pro plan.
Find out why a webhook did not arrive
Needs: nothing — this one is in the app
- Account → API → Webhooks. If the endpoint shows Disabled, it failed ten times in a row and we stopped sending. The reason is shown with it.
- Check Recent deliveries underneath, which keeps 30 days of attempts with response codes and errors.
- Fix your endpoint, choose Send test, then Re-enable.
Re-enabling re-checks the URL, so an endpoint that now resolves to a private address stays off and tells you why.