Webhooks
Every event Strimz fires and how to verify signatures.
Signature
Every webhook is signed with HMAC-SHA256 and sent as the Strimz-Signature header:
t is the Unix timestamp at signing time. Receivers should:
- Re-compute
hmac_sha256(secret, "${t}.${rawBody}") - Compare timing-safe to
v1 - Reject if
|now - t| > 300s(replay protection window)
The reference implementation lives in @strimz/sdk → verifyWebhookSignature.
Headers
| Header | Description |
|---|---|
Strimz-Signature | t=<unix>,v1=<hex> |
Strimz-Event-Id | evt_…, globally unique |
Strimz-Event-Type | dotted name, e.g. payment.completed |
X-Strimz-Delivery-Id | whdl_…, the same on every retry of one delivery |
Events
| Event | Fired when | data |
|---|---|---|
payment.created | A payment session is created, through the API or a storefront | the session |
payment.completed | An on-chain payment is confirmed for a session | { session, transaction } |
payment.failed | A session expires unpaid | { session, reason } |
subscription.created | The indexer sees a new subscription on chain | the subscription |
subscription.charged | A periodic charge succeeded | { subscription, charge, transaction } |
subscription.charge_failed | A charge failed for a payment reason (insufficient funds, allowance, etc.) | { subscription, charge } |
subscription.recovery_attempt | The recovery agent emailed the customer about a failed charge | { subscription, attemptNumber } |
subscription.cancelled | You cancelled through the API or dashboard | the subscription |
subscription.lapsed | The 48-hour grace window ran out without a successful charge | the subscription |
subscription.reactivated | Strimz restored a subscription its operator key cancelled by mistake | the subscription |
refund.created | A refund is created | the refund |
refund.completed | The refund transaction confirmed on chain | { refund, transaction } |
refund.failed | The refund failed; failureReason says why | the refund |
invoice.created | An invoice is created | the invoice |
invoice.paid | An invoice's payment confirmed | { invoice, transaction } |
invoice.overdue | An hourly cron finds the invoice past its due date | the invoice |
A cancel the payer makes from their own wallet updates the subscription but does not fire subscription.cancelled.
The agent.job_* and compliance.* event types belong to features that are coming. They are not sent today.
Retry policy
A delivery that times out (10 seconds) or gets a non-2xx response is retried. There are 5 attempts in total, with waits of 1 minute, 5 minutes, 30 minutes and 2 hours between them, about 2 hours 36 minutes from first to last. After the last attempt:
- The delivery is marked
permanently_failed - The merchant is emailed
- 5 permanent failures within 24 hours on the same endpoint auto-disable it
Replaying a delivery from the dashboard or API resets it to attempt 1 and starts the schedule again.
Durability behind the scenes
A few details about how the delivery pipeline is built. You don't need to know any of this to use webhooks, but it's worth understanding if you're evaluating Strimz for production traffic.
Signing secrets are stored as AES-256-GCM ciphertext in Postgres. A database dump leaks ciphertext, not the key used to sign your deliveries. The plaintext lives in Redis as a hot cache for the delivery worker; that way we don't pay the decrypt cost on every send. If Redis is flushed, the scheduler decrypts the column back into the cache on its next boot and webhook delivery resumes with no action on your side.
Every attempt is persisted to the WebhookDelivery table. You get
the response code, the response body (truncated to 4,000 characters), the attempt
number, the next-attempt time, and the last error, all visible in
your dashboard. Every attempt carries an
X-Strimz-Delivery-Id header that stays the same across retries of one
delivery, so your endpoint can deduplicate on it.
If your endpoint goes dark for a short while (a few minutes, an hour)
the retry schedule covers it. If you're down for the whole window of
about two and a half hours, the delivery lands as
permanently_failed, and you can replay it from the dashboard once
you're back up.

