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 id  can come back twice. De-duplicate before sending, or you will pay for the same recipient twice against your allowance.
  • Read recipients.skipped  on 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) , not id . 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_name  for 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_count  at 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

  1. 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.
  2. Check Recent deliveries underneath, which keeps 30 days of attempts with response codes and errors.
  3. 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.