Outbound webhooks
Signed HTTPS POSTs when work is signed off, a change order moves, or an invoice is paid — plus how to verify one and how to survive a duplicate.
Stria POSTs a signed JSON body to endpoints you register in
Settings → Integrations. Requires the api_access capability, available on every
paid tier.
Event catalogue
The authoritative list is the webhook_event_types table. The TypeScript union and
the settings UI are generated from it (npm run webhook:write), and
npm run webhook:check fails if they drift — the list used to be maintained in three
places and drifted twice, once silently reverting a paid-feature gate.
| Event | Category | Fires when |
|---|---|---|
feedback.created | delivery | A client submits feedback on a deliverable |
signoff.recorded | delivery | A deliverable sign-off completes |
deliverable.sent | delivery | A deliverable is sent for review |
change_order.sent | scope | A change order is sent for signing |
change_order.signed | scope | A change order is signed |
change_order.declined | scope | A change order is declined |
change_decision.sent | change | A change decision or milestone gate is sent |
invoice.sent | money | An invoice moves from draft to open |
invoice.paid | money | An invoice is paid in full or in part |
invoice.overdue | money | An invoice passes its due date unsettled |
deposit.cleared | money | A deposit clears and the kickoff gate releases |
The money events fire from any source — Stripe, a manual mark-paid, recurring
billing, a proposal deposit — because they are emitted by database triggers on the
state transition itself rather than by one caller.
Sample-client (demo) data never produces a delivery.
Payload shape
Infrastructure metadata at the top level, business data nested under data, so you
can route and deduplicate without parsing every event schema.
{
"event": "invoice.paid",
"created_at": "2026-08-09T11:04:22.481Z",
"data": {
"invoice_id": "…",
"number": "INV-0042",
"status": "paid",
"currency": "EUR",
"total": 4200.00,
"amount_paid": 4200.00,
"amount_due": 0,
"amount_received": 4200.00,
"previous_status": "open",
"client_name": "Northwind",
"project_name": "Platform retainer",
"collected_via": "stripe"
}
}
Amounts are decimals in the invoice's own currency, not integer minor units. That
is a deliberate choice: emitting minor units would require a zero-decimal currency
list in SQL, one already exists in the Stripe path, and a second copy that disagrees
would misstate an amount by 100×. Read currency alongside every amount.
On invoice.paid, amount_received is what arrived — on a partial payment that
differs from total, and the difference is the point.
Never included: Stripe or connected-account identifiers, payment instructions, bank
details, client email addresses. collected_via tells you stripe or offline
without exposing the underlying object.
Verifying a delivery
Every request carries both a current and a legacy signature. Verify one; prefer
the first.
webhook-id: 2f8c1e5a-… # the delivery id, stable across retries
webhook-timestamp: 1786636862 # unix seconds
webhook-signature: v1,<base64 HMAC-SHA256>
X-Stria-Delivery-Id: 2f8c1e5a-… # alias of webhook-id
X-Stria-Event: invoice.paid
X-Stria-Attempt: 1
X-Stria-Signature: sha256=<hex HMAC-SHA256> # legacy
Current (Standard Webhooks). HMAC-SHA256 over
` ${webhook-id}.${webhook-timestamp}.${rawBody} , base64, prefixed v1,`.
Signing the id and timestamp — not just the body — is what makes a captured payload
un-replayable and binds a signature to one delivery. Reject a delivery whose
timestamp is more than ~5 minutes old.
Legacy. HMAC-SHA256 over the raw body, hex, prefixed sha256=. Still sent for
receivers built before the current scheme. It will be retired with a Sunset header
and 90 days' notice.
Always verify against the raw body, before any JSON parse or re-serialisation.
Compare in constant time.
Secret rotation
Rotating produces a new secret and keeps the previous one valid for an overlap window
(24 hours by default), so rotation is not an outage.
During the overlap you receive two signatures in one delivery, and the delivery is
authentic if either verifies:
webhook-signature: v1,<HMAC under NEW secret> v1,<HMAC under PREVIOUS secret>
X-Stria-Signature: sha256=<hex under NEW>,sha256=<hex under PREVIOUS>
webhook-signature is space-separated, which is the Standard Webhooks list form, so
an off-the-shelf verifier already handles this with no change on your side. The legacy
header has no list syntax in its published format, so its pair is comma-separated —
split on , and accept either value.
The new secret is always listed first. A verifier that stops at the first match stops
on the one that will still be valid after the window closes.
Outside a rotation window exactly one signature is sent in each header, as before.
Until migration
20261115000200this section described the intended behaviourrather than the shipped one: the previous secret was written to the endpoint row by
rotate_webhook_secretand never read by the signer, so only the new secret wasever used and rotating was an outage until the receiver redeployed. If you built
a receiver against the old single-signature behaviour it still works unchanged —
this only adds a second value to accept.
Delivery, retries and duplicates
Delivery is at-least-once. No HTTP-based system can offer exactly-once, because a
sender cannot distinguish a lost request from a lost response. Duplicates are the
contract.
- First attempt is immediate, inline with the event.
- Retries on failure: 10s, 30s, 2m, 10m, 1h, 6h, 24h — 8 attempts total, a ~31h
horizon. Each delay carries full jitter, so a shared outage does not produce a
synchronised retry burst the moment it clears. The retry worker runs once a minute,
so the first retry lands within about a minute rather than exactly at 10s.
- Retryable:
5xx,408,429(we honourRetry-After), and network or timeout
failures.
- Not retryable: any other
4xx. A400or422means you will never accept
this payload, so it is marked dead after one attempt and surfaced immediately rather
than retried for 31 hours behind a green-looking "still retrying".
- Timeout is 5 seconds per attempt. Return
2xximmediately and process
asynchronously.
- Dead letters appear in Settings → Integrations and can be replayed by hand.
- Circuit breaker: after 20 consecutive dead deliveries the endpoint is disabled
automatically and the workspace owner is notified. Re-enable it in Settings once the
receiver is fixed.
Deduplicate on webhook-id
It is stable across every retry and across an admin-triggered replay — a replay
reuses the original delivery row rather than minting a new id, precisely so a correct
receiver ignores it. Keep a 24-hour window of processed ids.
Do not assume ordering
There is none. Use created_at and the resource state in the payload to discard stale
events: an invoice.sent arriving after an invoice.paid for the same invoice should
be dropped, not applied.
Recovering from an outage
Do not wait for a replay. Poll the matching v1 list endpoint with updated_since:
GET /api-v1/invoices?updated_since=2026-08-09T00:00:00Z
That is the supported recovery path and it is correct regardless of what the queue
did.
Requirements for your endpoint
- HTTPS only. Plain HTTP is rejected at registration.
- Public address. Private, loopback and link-local addresses are blocked, and the
check runs on every attempt rather than only at registration — a hostname can be
re-pointed between a first attempt and a retry six hours later.
- Respond within 5 seconds with any
2xx.
Health
Settings → Integrations shows, per endpoint: delivered and failed counts over 24
hours, pending backlog, dead-letter count, consecutive failures, and **oldest pending
age**. The last one is the leading indicator — success rate tells you something broke
after it broke, while a backlog whose oldest item keeps ageing is the only signal that
catches a retry worker that has silently stopped.
More reference
- Authentication — How tokens are minted, stored, presented and revoked.
- Endpoints — Every request and response shape, with worked examples.
- Rate limits — Per organization, per minute, and published rather than discovered as a random failure.
- Errors — What each status means, what the body carries, and which ones to retry.
- Compatibility and versioning — What may change without notice, what may not, and how you are told.
- API changelog — Every change, dated, including the ones that were corrections.
OpenAPI specification · API access is on every paid plan · Start your 14-day trial