Webhooks push events to your server the moment something happens — a payment settles, KYB is decided, an agent trips AML review — so you never have to poll. This guide covers registering an endpoint, verifying signatures correctly, and building a handler that survives retries.

Register an endpoint

Register a publicly reachable HTTPS URL and the events you want:
Response
The secret is returned once, at creation. Store it in a secrets manager immediately — it’s what you’ll use to verify every delivery. If you lose it, rotate the webhook to get a new one.

Event catalog

Events cover payments (payment.created, payment.settled, payment.failed), settlements (settlement.initiated/completed/failed, merchant.settlement.completed), agents (agent.created, agent.key_rotated, agent.aml_review, agent.aml_declined), and KYB (merchant.kyb.approved, merchant.kyb.rejected). Full payload schemas for every event are in the webhook events reference. Every delivery has the same envelope: an event_id (evt_ prefix), type, created_at, and a data object containing the resource.

Verify signatures

Every delivery carries two headers:
  • Sohopay-Signature — hex HMAC-SHA256 of ${timestamp}.${body} using your webhook secret
  • Sohopay-Timestamp — Unix seconds when the delivery was signed
Verify by recomputing the HMAC over the raw request body (not the parsed JSON), comparing in constant time, and rejecting anything older than 5 minutes to block replays.
The two mistakes that break most integrations: verifying against a re-serialized body (key order changes ⇒ different bytes) and using == instead of a constant-time compare. Both handlers above avoid them.

Delivery, retries, and idempotency

A delivery is considered successful only on a 2xx response within 10 seconds. On anything else, SohoPay retries at 5s, 30s, 2m, 10m, 1h, then hourly up to 24 hours, after which the delivery is dropped (the event remains queryable via the API). Design your handler accordingly:
  • Respond 2xx fast, process async. Verify the signature, enqueue, return. Slow handlers time out and cause duplicate deliveries.
  • Dedupe by event_id. Retries reuse the same event_id. Keep a processed-IDs table (24h retention is enough) and skip repeats.
  • Don’t assume ordering. payment.settled can arrive before payment.created under retry. Treat each event as a snapshot, not a diff.

Test locally

1

Point a webhook at webhook.site

Create a URL at webhook.site and register it as a sandbox webhook. You’ll see raw deliveries — headers, body, and signature — without writing any code.
2

Send a test event

Trigger a synthetic delivery of any event type to your endpoint:
3

Verify against your real handler

Replay the captured body and headers against your local handler, then re-register the webhook with your real (tunneled or deployed) URL and settle a sandbox payment end to end.

Test it

  • Registered a sandbox webhook and stored the whsec_ secret
  • POST /webhooks/{id}/test delivery passes your signature verification
  • A tampered body or wrong secret is rejected with 401
  • A delivery with a 10-minute-old timestamp is rejected with 400
  • Replaying the same delivery twice processes the event only once (dedupe by event_id)

Next steps

Webhook Events Reference

Full payload JSON for every event type.

Settlements & Payouts

The settlement events you’ll consume most.

Testing Guide

The full sandbox-to-mainnet readiness checklist.