Webhooks
CowriePay sends an HTTP POST to your registered endpoints whenever a tracked event occurs. Every delivery is signed with HMAC-SHA256, and verifying that signature is the only thing that tells you the request came from us.
The list of events you can subscribe to lives with the API reference, next to the events field that
accepts it: see the Webhooks reference.
The envelope, and the headers
Section titled “The envelope, and the headers”The body is always the same three keys:
{ "event": "DEPOSIT_CONFIRMED", "created_at": "2024-05-27T10:10:00.000Z", "data": { "...": "event-specific fields" }}Four headers come with it:
| Header | Value |
|---|---|
X-CowriePay-Event |
The event name, the same value as event in the body |
X-CowriePay-Timestamp |
Unix timestamp in seconds, the moment the delivery was signed |
X-CowriePay-Signature |
sha256=<hex>, HMAC-SHA256 over {timestamp}.{raw_body} |
X-CowriePay-Delivery |
The delivery id, your deduplication key (see below) |
Verify the signature
Section titled “Verify the signature”Strip the sha256= prefix, recompute
HMAC_SHA256(endpoint_secret, X-CowriePay-Timestamp + "." + raw_request_body), and compare
constant-time. Reject a delivery whose timestamp is more than a few minutes old, which is what stops a
captured request being replayed at you later.
Sign the raw body bytes, and parse the JSON only after the signature checks out. Re-serialising the parsed object first is the usual reason a correct integration reports an invalid signature: a whitespace or key-order difference changes the bytes and therefore the hash.
const crypto = require('crypto');
function verifyWebhook(rawBody, signatureHeader, timestamp, secret) { const provided = (signatureHeader || '').replace(/^sha256=/, ''); const expected = crypto .createHmac('sha256', secret) .update(`${timestamp}.${rawBody}`) .digest('hex'); const a = Buffer.from(provided), b = Buffer.from(expected); return a.length === b.length && crypto.timingSafeEqual(a, b);}
// Express: mount express.raw so req.body is the raw bytes, not a parsed object.app.post('/webhooks/cowriepay', express.raw({ type: 'application/json' }), (req, res) => { const sig = req.headers['x-cowriepay-signature']; const ts = req.headers['x-cowriepay-timestamp']; if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.status(400).send('Stale'); if (!verifyWebhook(req.body, sig, ts, process.env.WEBHOOK_SECRET)) { return res.status(401).send('Invalid signature'); } const payload = JSON.parse(req.body); // handle payload.event ... res.status(200).send('OK');});import hashlib, hmac, time
def verify_webhook(raw_body: bytes, signature_header: str, timestamp: str, secret: str) -> bool: if abs(time.time() - int(timestamp)) > 300: return False # stale, possible replay provided = (signature_header or '').removeprefix('sha256=') signed = f'{timestamp}.'.encode() + raw_body expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, provided)Use a timing-safe comparison (timingSafeEqual, compare_digest). Ordinary string equality leaks how
much of the signature was correct, one character at a time.
The delivery id is stable across retries
Section titled “The delivery id is stable across retries”X-CowriePay-Delivery identifies the delivery, not the attempt. All five attempts at the same
delivery carry the same id, which is precisely what makes it usable as a deduplication key: store it,
and ignore a delivery you have already processed.
Two deliveries of the same underlying event get different ids, and that is deliberate. It happens
when you have several endpoints registered (one delivery each), and when our reconciliation job notices
a settlement event that never reached you and enqueues a fresh one. So deduplicate on the delivery id
when you want at-most-once processing of an attempt, and on the resource id in the payload
(deposit_id, withdrawal_id) when you want your own side effect to happen exactly once.
Retries
Section titled “Retries”Your endpoint has 10 seconds to return a 2xx. Anything else, a timeout, a connection error, or any non-2xx status, counts as a failure and is retried. There are 5 attempts in total, with an exponential backoff of 1, 2, 4 and 8 minutes between them:
| Attempt | When |
|---|---|
| 1 | Immediately |
| 2 | 1 minute after attempt 1 |
| 3 | 2 minutes after attempt 2 |
| 4 | 4 minutes after attempt 3 |
| 5 | 8 minutes after attempt 4 |
After the fifth attempt the delivery is marked FAILED and is not retried again.
So the whole retry window is short: about 15 minutes end to end. Size your endpoint’s availability accordingly: a deploy that takes your receiver down for half an hour will lose deliveries, and the recovery path is to read them back from the delivery log rather than to wait for a retry that is not coming. Attempts are dispatched by a worker that ticks every 10 seconds, so treat the times above as the earliest a retry can happen, not as a precise schedule.
If an endpoint accumulates 5 permanently failed deliveries within 7 days, we email the workspace contact that it looks dead.
The delivery log
Section titled “The delivery log”GET /v2/webhooks/{id}/deliveries returns what actually happened: the event, the status, the HTTP
status we got back, the attempt count, the last error, and the timestamps. This is where you look when
a delivery never arrived, and it is how you recover the ones that failed while your receiver was down.
Delivered and failed records are kept for 90 days, then purged. Pending ones are never purged.
Rotating the signing secret
Section titled “Rotating the signing secret”POST /v2/webhooks/{id}/rotate-secret issues a new signing secret, returned once in the response
and never retrievable afterwards.
To avoid dropping deliveries while you update your stored copy, the previous secret stays valid for a 24-hour overlap window. During that window every delivery carries two signature headers:
| Header | Signed with |
|---|---|
X-CowriePay-Signature |
the NEW secret |
X-CowriePay-Signature-Previous |
the OLD secret, only present during the overlap |
Accept the request if either header matches the secret you currently hold. The roll-over is:
rotate, store the new secret, keep accepting both headers until your deployment is live, and let the
window expire. The response echoes previous_secret_expires_at so you know when the old one stops
being accepted.
Fee breakdown on settlement events
Section titled “Fee breakdown on settlement events”DEPOSIT_SWEPT and WITHDRAWAL_CONFIRMED carry a fee object, so you can reconcile the net credited
or sent without a second call. gross_amount is the on-chain amount, fee_amount is the CowriePay
fee, net_amount is what landed in (deposit) or left (withdrawal) the balance. All amounts are
human-readable decimal strings.
{ "event": "DEPOSIT_SWEPT", "created_at": "2024-05-27T10:15:00.000Z", "data": { "deposit_id": "c3d4e5f6-0000-0000-0000-000000000003", "workspace_id": "11111111-0000-0000-0000-000000000000", "wallet_id": "a1b2c3d4-0000-0000-0000-000000000001", "chain": "TRON_MAINNET", "asset": "USDT_TRON", "amount": "50.000000", "txid": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab", "status": "SWEPT", "confirmations": 19, "required_confirmations": 19, "detected_at": "2024-05-27T10:05:00.000Z", "confirmed_at": "2024-05-27T10:10:00.000Z", "fee": { "gross_amount": "50.000000", "fee_amount": "0.450000", "net_amount": "49.550000", "applied_rate": "0.009", "applied_min_fee": "0.300000" } }}The fee object is additive: it is absent on the earlier lifecycle events (DEPOSIT_DETECTED,
DEPOSIT_CONFIRMED, WITHDRAWAL_PROCESSING). Treat its absence leniently, and read
Backwards compatibility for what else may appear in a payload over time.
Where deliveries come from
Section titled “Where deliveries come from”Deliveries leave from a single public source address today. The value, why it can change, and why an IP allowlist is a firewall convenience rather than a substitute for signature verification, are on the Webhook source IPs page.