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
sendscope, 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
200and 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_todayand 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.