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.

EventCategoryFires when
feedback.createddeliveryA client submits feedback on a deliverable
signoff.recordeddeliveryA deliverable sign-off completes
deliverable.sentdeliveryA deliverable is sent for review
change_order.sentscopeA change order is sent for signing
change_order.signedscopeA change order is signed
change_order.declinedscopeA change order is declined
change_decision.sentchangeA change decision or milestone gate is sent
invoice.sentmoneyAn invoice moves from draft to open
invoice.paidmoneyAn invoice is paid in full or in part
invoice.overduemoneyAn invoice passes its due date unsettled
deposit.clearedmoneyA 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 20261115000200 this section described the intended behaviour

rather than the shipped one: the previous secret was written to the endpoint row by

rotate_webhook_secret and never read by the signer, so only the new secret was

ever 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 honour Retry-After), and network or timeout

failures.

  • Not retryable: any other 4xx. A 400 or 422 means 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 2xx immediately 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