Skip to content

Webhooks

There are two webhook flows in the system. Don’t conflate them.

The gateway exposes one endpoint per registered PSP:

  • POST https://payment.uncle-z.com/webhooks/nicepay
  • POST https://payment.uncle-z.com/webhooks/polar
  • POST https://payment.uncle-z.com/webhooks/paypal

The PSP fires events here when payment state changes. The gateway:

  1. Archives the raw body to inbound_events (forensics, even on rejection).
  2. Verifies the signature against the registered driver’s scheme + secret.
  3. Maps the PSP-specific event type to a canonical state (succeeded / failed / canceled).
  4. Finalizes the matching payment row (atomic — only one event per payment can transition initiated → terminal).
  5. 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.

When state changes, the gateway POSTs JSON to your app’s webhook_url:

POST https://yourproduct.uncle-z.com/api/payment-webhook
Content-Type: application/json
X-PAY-Timestamp: 1730000000
X-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.

eventWhen
payment.succeededBuyer paid; PSP confirmed.
payment.canceledBuyer abandoned, PSP rejected, or admin canceled.
payment.failedPSP-side failure (insufficient funds, fraud rule).
subscription.canceledSubscription canceled by buyer or admin.
subscription.revokedSubscription delinquent → access revoked (Polar dunning).
refund.succeededRefund was issued and PSP confirmed.
refund.failedRefund failed (rare).

The metadata envelope contains:

  1. Whatever you sent on POST /v1/payments — verbatim. This is your context (your internal user id, plan code, etc.).
  2. PSP-specific enrichment added by the gateway from the inbound event. For Polar specifically:
    • polar_subscription_id — Polar’s subscription UUID, stable across renewals
    • current_period_end — RFC3339 timestamp of the next renewal
    • polar_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.

  • 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.

  • 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.

  • 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 timestamp if your state machine cares.