Settlement notifications
When a settlement completes, Sulpayments sends a POST request to the webhook
endpoint configured for your account. Every notification is signed; verify the
signature before trusting the payload.
Registering your endpoint
Section titled “Registering your endpoint”Configure the endpoint and its signing secret yourself with your API key (see authentication):
curl -X PUT https://api.sulpayments.ch/v1/webhook \ -H "Authorization: Bearer sk_your_api_key" \ -H "Content-Type: application/json" \ -d '{"webhook_url": "https://your-app.example/webhooks/sulpayments", "webhook_secret": "a-secret-you-choose"}'- The URL must be a public
httpsaddress; internal or loopback hosts are rejected. - The secret is yours to choose and is required the first time you set a
URL. Use it to verify the signature on every notification (below). It is
stored but never returned —
GET /v1/webhookonly tells you whether one is set. - To rotate the secret,
PUTagain with a newwebhook_secret; to change only the URL, omit the secret to keep the current one; send an emptywebhook_urlto disable notifications.
Request headers
Section titled “Request headers”| Header | Content |
|---|---|
sulpayments-signature |
sha256=<hex> — HMAC-SHA256 of "{timestamp}.{body}" with your webhook secret |
sulpayments-timestamp |
Unix timestamp (seconds) of the delivery attempt |
sulpayments-event |
Event type, currently settlement.completed |
sulpayments-delivery-id |
Unique id of this delivery — use it for idempotency |
Verifying the signature
Section titled “Verifying the signature”Compute HMAC-SHA256 over the timestamp, a dot, and the raw request body, then compare against the header value using a constant-time comparison. Reject notifications whose timestamp is older than a few minutes to prevent replays.
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(secret, headers, rawBody) { const timestamp = headers["sulpayments-timestamp"]; if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) { return false; } const expected = `sha256=${createHmac("sha256", secret) .update(`${timestamp}.`) .update(rawBody) .digest("hex")}`; const received = headers["sulpayments-signature"] ?? ""; return ( expected.length === received.length && timingSafeEqual(Buffer.from(expected), Buffer.from(received)) );}Payload
Section titled “Payload”{ "event": "settlement.completed", "settlement_id": "0197d2f0-5f9b-7c81-a9a3-2e2b8f6f3c1d", "fixed_side": "source", "occurred_at": "2026-07-18T12:03:33.481922+00:00", "completed_at": "2026-07-18T12:03:34.107553+00:00", "payin_reference": "evt-2026-0718-042", "incoming_currency": "EUR", "incoming_amount": "108", "outgoing_currency": "USDC", "outgoing_amount": "94.692307", "conversion_rate": "1.1232", "fee_percentage": "0.01", "fee_percentage_amount": "0.961538", "fee_fixed": "0.5", "destination_reference": "0x4bc8...", "payout_reference": "pyt_7f3a91c4"}Amounts are decimal strings. conversion_rate is the rate quoted for your
settlement, expressed as incoming units per outgoing unit: dividing
incoming_amount by it gives the amount before fees. fee_percentage,
fee_percentage_amount and fee_fixed are the fees agreed for your corridor.
outgoing_amount carries only the decimals the destination asset can actually
transfer (six for USDC), never rounded up.
Delivery and retries
Section titled “Delivery and retries”- Answer with any
2xxstatus within 10 seconds; do heavy work asynchronously. Requests that do not complete in time count as a failure. - Failed deliveries are retried with exponential backoff — one minute doubling up to one hour between attempts, for up to 8 attempts.
- Delivery is at-least-once: deduplicate by
sulpayments-delivery-idif your handler is not idempotent. - After the final failed attempt the delivery is marked dead and our operations team is alerted.