Asynchronous work — statement documents

Anything that produces a document or sends email returns immediately, before the work is done. You get an id and a 202 Accepted , then either poll or wait for a webhook.

This is not us being awkward. Rendering a statement PDF shells out to an external program and takes seconds; if that happened while you waited, a script looping over a few thousand contacts would tie up the same web servers that serve the Statey app to every other customer.

Requesting a statement document

curl -X POST https://app.statey.app/api/v1/contacts/9f8e7d6c-.../statement_export \
  -H "Authorization: Bearer stky_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{"format_type": "pdf", "start_date": "2026-08-01", "end_date": "2026-08-31"}'
{
  "statement_export": {
    "id": "3e1f5a64-8091-4d0e-2f1a-4b5c6d7e8f90",
    "status": "queued",
    "file_format": "pdf",
    "contact_id": "9f8e7d6c-5b4a-4392-8172-6a5b4c3d2e1f",
    "start_date": "2026-08-01",
    "end_date": "2026-08-31",
    "expires_at": "2026-09-18T09:14:02Z",
    "error_message": null,
    "download_url": null
  },
  "meta": {
    "data_freshness": { "last_completed_at": "2026-09-17T04:12:33Z", "rebuild_in_progress": false }
  }
}

format_type  is pdf  or xlsx . Both dates are optional and default to the last 60 days.

Collecting it

Poll the id until status  is completed :

curl https://app.statey.app/api/v1/statement_exports/3e1f5a64-8091-4d0e-2f1a-4b5c6d7e8f90 \
  -H "Authorization: Bearer stky_your_key_here"
status Meaning
queued Waiting for a worker
processing Being rendered
completed Ready — download_url is populated
failed Gave up. error_message says why

Once complete you get a signed download_url . Fetch it directly; it needs no Authorization  header, because the signature is in the URL.

Poll politely. Every poll is a request against your per-minute rate limit. Wait a second or two between attempts and back off if it is taking a while — a tight loop will throttle you before the document is ready.

Documents expire after 24 hours

expires_at  is when the file is deleted. After that, download_url  comes back null  and you need to request a new export.

Download it when it is ready rather than storing the URL for later. The record outlives the file by a few days so support can still see what happened, but the file itself is gone.

Month-start is the busiest time for the queue, so an export requested then can sit longer before a worker picks it up. Build for that rather than assuming seconds.

Sending statements

Sending works the same way:

curl -X POST https://app.statey.app/api/v1/statement_sends \
  -H "Authorization: Bearer stky_your_key_here" \
  -H "Idempotency-Key: 6f1c2b7e-9d3a-4f51-8c27-1a4b5d6e7f80" \
  -H "Content-Type: application/json" \
  -d '{"contact_ids": ["9f8e7d6c-...", "3a2b1c0d-..."]}'

You get 202 , a campaign id, and an account of who will actually receive something:

{
  "campaign": { "id": "3e1f5a64-8091-4d0e-2f1a-4b5c6d7e8f90", "sent_at": "2026-09-18T09:14:22Z" },
  "recipients": {
    "requested": 3,
    "will_receive": 2,
    "skipped": [{ "contact_id": "3a2b1c0d-...", "reason": "no_email_address" }]
  }
}

Read skipped  every time. It is the only place a shortened list is reported: requested  is what you asked for, will_receive  is what will happen, and each entry says which contact and why. The two reasons are not_found  (no such contact, or not one of yours) and no_email_address  — for which see Who receives a statement, since a contact can have addresses and still be skipped.

The emails are queued, not sent, at that moment.

To rehearse this without emailing anyone, use a test key — same request, same response, no delivery. See Test keys.

Three things to know:

  • Always send an Idempotency-Key . A retry without one can email your customers twice. See Idempotency.
  • Contacts with no email address are skipped, and do not count against your allowance. If none of the contacts you named can receive email, the request is rejected outright rather than silently doing nothing.
  • Up to 1,000 contacts per request. Split larger runs.

To find out what actually happened, either poll GET /api/v1/campaigns/{id}  for per-recipient status, or subscribe to statement.delivery_updated  and let us tell you. The webhook is better: delivery outcomes arrive over minutes or hours as the mail is accepted, opened or bounced, and polling for that is wasteful.

Checking a list before you send it

POST /api/v1/statement_sends/preview  takes the same body and answers the same question — who would receive a statement — without queueing anything or touching your daily allowance.

curl -X POST https://app.statey.app/api/v1/statement_sends/preview \
  -H "Authorization: Bearer stky_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{"contact_ids": ["9f8e7d6c-...", "3a2b1c0d-..."]}'
{
  "recipients": {
    "requested": 3,
    "will_receive": 2,
    "skipped": [{ "contact_id": "3a2b1c0d-...", "reason": "no_email_address" }]
  },
  "quota": { "limit": 5000, "remaining": 4980, "sufficient": true }
}

quota.sufficient  tells you whether the run would fit in what is left of today's allowance, so a large monthly job can check before it starts rather than failing part-way.

Worth knowing:

  • It needs the send  scope, even though it sends nothing.
  • It never errors on an unreachable list. Where a real send rejects a list nobody can receive, the preview returns 200  and names every reason — that is what you asked it to find out.
  • It reserves nothing, so the allowance you see is still there when you send.

This is a different thing from a test key. The preview tells you who would be emailed; a test key runs the whole send for real and stops at the final step, so it tells you what would happen. Use the preview to check a list, and a test key to check your integration. See Test keys.

Running a schedule on demand

If you already have a schedule set up in Statey, you can fire it now instead of building the recipient list yourself:

curl -X POST https://app.statey.app/api/v1/schedules/{id}/runs \
  -H "Authorization: Bearer stky_your_key_here" \
  -H "Idempotency-Key: 6f1c2b7e-9d3a-4f51-8c27-1a4b5d6e7f80"

You get the same 202 , campaign id and recipients  block a send gives you. The one difference is what requested  counts: you named no contacts, so it is everyone the schedule's own filter matched.

{
  "campaign": { "id": "3e1f5a64-...", "sent_at": "2026-09-18T09:14:22Z" },
  "recipients": {
    "requested": 42,
    "will_receive": 40,
    "skipped": [{ "contact_id": "3a2b1c0d-...", "reason": "no_email_address" }]
  }
}
  • It does not use up the next occurrence. The schedule still runs on its own timetable as well.
  • It can only run once a day. If the schedule has already produced a campaign today — from the timetable or from an earlier call — you get 409 schedule_already_run_today  and nothing is sent. That guard is what stops a customer receiving the same statement twice.
  • It counts against your daily allowance, unlike the schedule's own automatic runs. Those are part of your plan; firing one through the API is API usage.
  • Send an Idempotency-Key . A retry with the same key replays the original answer rather than reporting a conflict.

The other two refusals are schedule_unrunnable  (the schedule is misconfigured) and schedule_has_no_recipients  (nobody matches it right now). See Errors.

Prefer webhooks to polling

For anything that takes real time, subscribe rather than poll:

Instead of polling Subscribe to
GET /campaigns/{id} for delivery outcomes statement.delivery_updated
GET /sync_status waiting for a refresh organisation.data_rebuilt

Statement exports have no webhook yet, so polling is the only option there — keep the interval sensible.

See Webhooks.