Skip to content

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 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)

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');
});

Use a timing-safe comparison (timingSafeEqual, compare_digest). Ordinary string equality leaks how much of the signature was correct, one character at a time.

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.

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.

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.

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.

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.

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.