Webhooks

Instead of your system asking us "has anything happened yet?", we post a small JSON message to your endpoint the moment it does.

Setting one up

  1. Go to Account → API → Webhooks.
  2. Choose Add webhook.
  3. Enter your endpoint URL and tick the events you want.

Your endpoint must be HTTPS and reachable from the public internet. We refuse addresses that resolve to private or internal networks, and we re-check that at delivery time — so an endpoint that starts resolving somewhere private later will be switched off rather than called.

Use Send test to fire a webhook.test  event at your endpoint before you rely on it.

The events

Event Fires when
organisation.data_rebuilt A refresh of your Xero data has finished
statement_run.sent A batch of statements has gone out
statement.delivery_updated One statement was delivered, opened, bounced or marked as spam

organisation.data_rebuilt

{
  "id": "0b8f2c31-5d6e-4a7b-9c8d-1e2f3a4b5c6d",
  "event": "organisation.data_rebuilt",
  "created_at": "2026-09-17T04:12:33Z",
  "data": {
    "outcome": "completed",
    "started_at": "2026-09-17T03:58:02Z",
    "ended_at": "2026-09-17T04:12:31Z",
    "contacts_total": 912,
    "contacts_rebuilt": 909
  }
}

This fires on every finished refresh, not only successful ones. Check outcome  — it is completed , failed , or occasionally unknown .

That is deliberate. A single contact failing to rebuild flips the whole run out of "completed", and a success-only webhook would leave you waiting for a message that never arrives. The counts let you decide for yourself: 909 of 912 is almost certainly fine to act on; 4 of 912 is not.

statement_run.sent

{
  "id": "1c9e3d42-6e7f-4b8c-0d9e-2f3a4b5c6d7e",
  "event": "statement_run.sent",
  "created_at": "2026-09-01T06:00:12Z",
  "data": {
    "campaign_id": "7a6b5c4d-3e2f-4109-8a7b-6c5d4e3f2a1b",
    "scheduled": true,
    "start_date": "2026-08-01",
    "end_date": "2026-08-31"
  }
}

scheduled  tells you whether this was an automated run or a manual or API send. Pass campaign_id  to GET /api/v1/campaigns/{id}  for the recipient list.

This fires when the batch is queued, not when every email has landed. For per-recipient outcomes, use statement.delivery_updated .

statement.delivery_updated

{
  "id": "2d0f4e53-7f80-4c9d-1e0f-3a4b5c6d7e8f",
  "event": "statement.delivery_updated",
  "created_at": "2026-09-01T06:04:55Z",
  "data": {
    "campaign_id": "7a6b5c4d-3e2f-4109-8a7b-6c5d4e3f2a1b",
    "contact_id": "9f8e7d6c-5b4a-4392-8172-6a5b4c3d2e1f",
    "sent_to": "accounts@acme.example",
    "status": "bounce"
  }
}

status  is SendGrid's own event name, passed through unchanged rather than translated. Expect processed , delivered , open , click , bounce , dropped , deferred  and spamreport . Treat an unrecognised value as informational rather than failing on it — SendGrid can add events.

The ones worth acting on are bounce , dropped  and spamreport .

This is the event most worth wiring up: it lets you route bounces into your own collections workflow rather than finding them in our UI a week later. You get one event per status change per recipient, so a single statement usually produces several — processed , then delivered , then perhaps open .

Events from a test key

A test key fires your webhooks, so you can build a receiver without emailing anyone.

statement_run.sent  arrives as normal with "sandbox": true  in the payload. statement.delivery_updated  is simulated — one delivered  event per recipient, also marked "sandbox": true  — because a test run never reaches our mail provider and so no real delivery event will ever come back.

If your integration writes to a ledger or raises tasks, filter out events carrying "sandbox": true , or a rehearsal will show up in your real records. See Test keys.

Verifying a request came from us

Every request carries these headers:

Header Contents
Statey-Event The event name
Statey-Delivery The delivery id — same as id in the body
Statey-Timestamp Unix timestamp, in seconds
Statey-Signature HMAC-SHA256, hex encoded
User-Agent Statey-Webhooks/1

Check the signature before you trust the body. Anyone can POST to your endpoint; the signature is what proves we sent it.

The signed string is the timestamp, a full stop, and the raw request body:

<timestamp>.<body>

signed with your endpoint's signing secret, which you can reveal under Account → API → Webhooks.

# Ruby
expected = OpenSSL::HMAC.hexdigest('SHA256', secret, "#{timestamp}.#{raw_body}")
Rack::Utils.secure_compare(expected, request.headers['Statey-Signature'])
# Python
import hashlib, hmac
expected = hmac.new(secret.encode(), f"{timestamp}.{raw_body}".encode(), hashlib.sha256).hexdigest()
hmac.compare_digest(expected, request.headers["Statey-Signature"])
// Node
const expected = crypto.createHmac("sha256", secret)
  .update(`${timestamp}.${rawBody}`)
  .digest("hex");
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(req.get("Statey-Signature")));

Three things to get right:

  • Sign the raw body, exactly as received. Parsing the JSON and re-serialising it will change the bytes and the signature will not match. Most frameworks need to be told to keep the raw body.
  • Compare in constant timesecure_compare , compare_digest , timingSafeEqual . A plain ==  leaks how much of the signature was correct, one character at a time.
  • Reject old timestamps. The timestamp is inside the signed string so it cannot be altered, but you must still check it is recent — five minutes is a reasonable window. Without that, someone who captured a valid request could replay it forever.

Rotating the secret

Rotation takes effect immediately — the old secret stops working the moment you rotate. Your endpoint will reject our requests until you deploy the new value, and those deliveries count as failures.

If downtime matters, accept either secret for a short window: deploy code that tries the new secret and falls back to the old, rotate, then remove the fallback.

Retries and duplicates

We deliver at least once. Your endpoint must tolerate duplicates.

That is not a hedge. A response we never see is indistinguishable from one that never arrived, so after a timeout we retry — and if your endpoint did the work before the connection dropped, it gets the same event twice.

Use the envelope id  as a de-duplication key. Record the ones you have processed and ignore repeats.

How delivery works:

Success Any 2xx response
Timeout 5 seconds
Attempts Up to 5, with increasing gaps between them
Redirects Not followed — a redirect could point somewhere we never security-checked

Respond 2xx quickly and do your real work afterwards, on a queue. If you process inline and take longer than 5 seconds, we time out and retry, and you get duplicates you created yourself.

When an endpoint keeps failing

After 10 consecutive failures we switch the endpoint off and stop sending to it. A successful delivery resets that count to zero, so an occasional blip never accumulates.

A disabled endpoint shows under Account → API → Webhooks with the reason. Fix your endpoint, then choose Re-enable.

Re-enabling re-checks the URL, so if it now resolves somewhere private it stays off and tells you why.

Recent deliveries — status, response code and error — are listed on the same page, and kept for 30 days. That is usually enough to answer "why didn't my webhook fire?" without contacting us.

A checklist

  • [ ] Respond 2xx as fast as possible; queue the real work
  • [ ] Verify the signature against the raw body, in constant time
  • [ ] Reject timestamps older than a few minutes
  • [ ] De-duplicate on the envelope id
  • [ ] Handle outcome  on organisation.data_rebuilt  rather than assuming success
  • [ ] Alert yourself if you stop receiving events — a disabled endpoint is silent