Webhooks
There are two webhook flows in the system. Don’t conflate them.
Inbound: PSP → gateway
Section titled “Inbound: PSP → gateway”The gateway exposes one endpoint per registered PSP:
POST https://payment.uncle-z.com/webhooks/nicepayPOST https://payment.uncle-z.com/webhooks/polarPOST https://payment.uncle-z.com/webhooks/paypal
The PSP fires events here when payment state changes. The gateway:
- Archives the raw body to
inbound_events(forensics, even on rejection). - Verifies the signature against the registered driver’s scheme + secret.
- Maps the PSP-specific event type to a canonical state (
succeeded/failed/canceled). - Finalizes the matching payment row (atomic — only one event per payment can transition
initiated → terminal). - Enqueues an outbound delivery to the owning app’s webhook URL.
The PSP-side configuration of these URLs is the operator’s job, not yours. Your only concern is the outbound side.
Outbound: gateway → your product
Section titled “Outbound: gateway → your product”When state changes, the gateway POSTs JSON to your app’s webhook_url:
POST https://yourproduct.uncle-z.com/api/payment-webhookContent-Type: application/jsonX-PAY-Timestamp: 1730000000X-PAY-Signature: <hex hmac-sha256 of "<ts>.<rawBody>" under your webhook_secret>
{ "event": "payment.succeeded", "payment_id": "abc123…", "status": "succeeded", "amount": 2900, "currency": "USD", "method": "polar", "provider": "polar", "provider_ref": "polar_chk_…", "timestamp": "2026-05-09T12:34:56Z", "metadata": { /* see below */ }}Webhook signing scheme (different from request signing)
Section titled “Webhook signing scheme (different from request signing)”For inbound API requests, the canonical string is <ts>.<METHOD>.<path>.<sha256(body)>.
For outbound webhooks, the canonical string is just <ts>.<rawBody>. Simpler. Use the webhook_secret from your app, not the request secret.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyWebhook(secret, ts, rawBody, signature) { const expected = createHmac('sha256', secret) .update(`${ts}.${rawBody}`) .digest('hex'); return timingSafeEqual(Buffer.from(expected), Buffer.from(signature));}See Verifying webhooks for the full per-language reference.
Event types
Section titled “Event types”event | When |
|---|---|
payment.succeeded | Buyer paid; PSP confirmed. |
payment.canceled | Buyer abandoned, PSP rejected, or admin canceled. |
payment.failed | PSP-side failure (insufficient funds, fraud rule). |
subscription.canceled | Subscription canceled by buyer or admin. |
subscription.revoked | Subscription delinquent → access revoked (Polar dunning). |
refund.succeeded | Refund was issued and PSP confirmed. |
refund.failed | Refund failed (rare). |
Metadata enrichment
Section titled “Metadata enrichment”The metadata envelope contains:
- Whatever you sent on
POST /v1/payments— verbatim. This is your context (your internal user id, plan code, etc.). - PSP-specific enrichment added by the gateway from the inbound event. For Polar specifically:
polar_subscription_id— Polar’s subscription UUID, stable across renewalscurrent_period_end— RFC3339 timestamp of the next renewalpolar_event_type— original Polar event name (subscription.created,order.created, etc.) for debugging
So your handler can match a subscription.active renewal event against the row you created at first-charge time by metadata.polar_subscription_id.
Retry behavior
Section titled “Retry behavior”- 2xx response — done. River drops the job.
- 4xx response — your handler is broken; the gateway dead-letters and stops retrying. Operator gets alerted.
- 5xx or timeout — River retries with exponential backoff, up to ~16 attempts over a few hours.
Make your handler idempotent. The gateway can deliver the same event twice (timeout-then-retry, or duplicate inbound from the PSP that survives the dedup). Dedupe on payment_id + event semantically — not on a delivery id, since retries reuse the original.
What you should respond with
Section titled “What you should respond with”- 200 OK — you accepted the event and processed it (or recognized it as a replay and moved on).
- 204 No Content — same; gateway doesn’t read your body.
- 4xx — only if the event is structurally wrong (your code’s invariants broken). Triggers dead-lettering; operator investigates.
- 5xx — only when your DB is genuinely unavailable. Triggers retries.
Don’t return 5xx to “tell the gateway to retry later as a feature” — use 200 + your own queue if you want to defer processing.
Reliability contract
Section titled “Reliability contract”- Every successful inbound event from a PSP results in at least one outbound delivery attempt to the owning product. At-least-once delivery.
- The gateway will not invent events that didn’t come from the PSP.
- Out-of-order delivery is possible (PSP fires events in tight succession; River may schedule them in different orders). Order events by
timestampif your state machine cares.