Skip to content

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.

Configure the endpoint and its signing secret yourself with your API key (see authentication):

Terminal window
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 https address; 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/webhook only tells you whether one is set.
  • To rotate the secret, PUT again with a new webhook_secret; to change only the URL, omit the secret to keep the current one; send an empty webhook_url to disable notifications.
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

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))
);
}
{
"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.

  • Answer with any 2xx status 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-id if your handler is not idempotent.
  • After the final failed attempt the delivery is marked dead and our operations team is alerted.