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.

An endpoint must be a public HTTPS URL. A private or reserved IP address, a single-label host name, and the .internal, .local, .localhost and .home.arpa suffixes are refused, at creation, on update, and again at every delivery attempt.

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.

Redirects are never followed. A 3xx answer is a failure like any other non-2xx, so register the final URL of your receiver, not one that forwards. Only the first 1,000 characters of your response body are recorded: a short acknowledgement is all you need to return.

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.

The last error is a code:

last_error Meaning
CONNECTION_FAILED The connection to your endpoint could not be opened or was cut
TIMEOUT Your endpoint did not answer in time
TLS_ERROR The TLS handshake failed, usually a certificate problem
DNS_FAILED The host name did not resolve
REDIRECT_NOT_FOLLOWED Your endpoint answered 3xx; redirects are never followed
DESTINATION_NOT_ALLOWED The URL, or an address it resolves to, is not public
PLATFORM_ERROR A failure on our side before your endpoint was contacted; the delivery is retried
HTTP_<status> Any other non-2xx answer, for example HTTP_500

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.