#Webhooks
cryptoinfra pushes events to HTTPS endpoints you register. Delivery is signed, retried, and at-least-once — which shapes everything below.
#Registering an endpoint
POST /v1/developer/webhooks with { "url": "https://…" }. HTTPS only, public hostnames only, at most 5 endpoints per merchant. Every event goes to every enabled endpoint — there is no per-event subscription.
The response contains endpoint.secret. It is returned once, there, and never again. Store it before you close the connection. POST /v1/developer/webhooks/{id}/regenerate issues a new one — also shown once — and the old secret stops verifying immediately, so rotate during a window you can watch.
POST /v1/developer/webhooks/{id}/test sends a signed test event through the real dispatcher, so it exercises your actual verification path rather than a simulation. It accepts an event_type so you can rehearse a specific shape.
The full Developer reference covers listing, enabling, disabling and deleting endpoints.
#The complete event list
These 9 events are the complete set. Nothing else is ever emitted.
| Event | Fires when |
|---|---|
deposit.credited | A deposit cleared confirmations and was credited |
withdrawal.confirmed | A payout confirmed on-chain |
withdrawal.failed | A payout failed and the hold was refunded |
card.kyc | A card holder's KYC status changed |
card.status | A card's status changed (also emitted when an issue order settles) |
card.topup | A card top-up order reached a terminal state (confirmed or failed) |
card.withdraw | A card→wallet withdrawal order reached a terminal state (confirmed or failed) |
card.transaction | A card transaction was created |
card.wallet_otp | A live 3DS wallet OTP is available (notification only — the code is NOT in the payload) |
Full payload schemas and examples for each are on the generated Webhook events page.
#What emits nothing
Poll the order read instead, while status is non-terminal:
| Flow | Poll this |
|---|---|
| Swaps and exchanges | GET /v1/swap/orders/{id} |
It is the authoritative read. It has no event, and will not get one.
#Delivery mechanics
Each delivery is a POST with two headers:
POST /your/endpoint HTTP/1.1
Content-Type: application/json
x-cryptoinfra-event: deposit.credited
x-cryptoinfra-signature: 9f2b… (hex hmac-sha256 of the raw body)- Timeout: 10 seconds per attempt.
- Success: any
2xx. Anything else — including a redirect — is a failure. - Redirects are not followed. Register the final URL.
- Retries: exponential backoff,
min(2^attempts × 30s, 1h)— roughly 60s, 2m, 4m, 8m, 16m, 32m, then hourly — abandoned as failed after 8 attempts.
Acknowledge with a 2xx immediately and do the work asynchronously. If your handler takes longer than 10 seconds, the delivery is recorded as failed and retried, and you will process the same event again.
#Verifying the signature
x-cryptoinfra-signature is the hex HMAC-SHA256 of the exact raw request bytes, keyed with that endpoint's secret.
import { createHmac, timingSafeEqual } from 'node:crypto';
import express from 'express';
const app = express();
// give the handler the raw bytes, not a parsed object
app.post('/webhooks/cryptoinfra', express.raw({ type: 'application/json' }), (req, res) => {
const expected = createHmac('sha256', process.env.CRYPTOINFRA_WEBHOOK_SECRET)
.update(req.body) // Buffer of the exact bytes received
.digest('hex');
const given = req.get('x-cryptoinfra-signature') ?? '';
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(given, 'utf8');
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return res.sendStatus(401);
}
const event = req.get('x-cryptoinfra-event');
const payload = JSON.parse(req.body.toString('utf8'));
enqueue(event, payload); // do the work off the request path
res.sendStatus(200); // acknowledge fast
});import hmac, hashlib
from flask import Flask, request, abort
app = Flask(__name__)
@app.post("/webhooks/cryptoinfra")
def cryptoinfra_webhook():
raw = request.get_data() # exact bytes, unparsed
expected = hmac.new(SECRET.encode(), raw, hashlib.sha256).hexdigest()
given = request.headers.get("x-cryptoinfra-signature", "")
if not hmac.compare_digest(expected, given):
abort(401)
event = request.headers.get("x-cryptoinfra-event")
enqueue(event, request.get_json())
return "", 200Always compare in constant time. Reject anything that fails, and reject anything with a missing signature header — never fall through to processing it.
#Replay and idempotency on your side
Delivery is at-least-once. You will receive the same event twice: after a timeout that actually succeeded, after a 5xx on your side, after a network blip.
There is no delivery-id header. Deduplicate on the pair (x-cryptoinfra-event, entity id) — the entity id being depositId, withdrawalId, card_id, order_id or the equivalent field on that payload.
create table webhook_seen (
event_type text not null,
entity_id text not null,
seen_at timestamptz not null default now(),
primary key (event_type, entity_id)
);Insert with on conflict do nothing; if no row was inserted, you have already handled this one — acknowledge 200 and stop. Make the handler itself idempotent too: crediting a user should be safe to attempt twice even if the dedupe table is lost.
Note that a status-transition event can legitimately arrive more than once with different content — card.status fires on each change. Key those on (event, entity id, new status) rather than on the entity alone.
#Webhooks are hints; the poll is truth
Even where a webhook exists, it is a notification that something may have changed — not the record of what changed. The read endpoints are authoritative:
GET /v1/withdrawals/{id}GET /v1/swap/orders/{id}GET /v1/cards/orders/{order_id}GET /v1/users/{externalId}/balances
Before you credit a customer, issue a refund, or tell anyone that money has moved, reconcile against one of those. A webhook that never arrives — because your endpoint was down for longer than the retry window — must not be able to strand money in your system. Run a reconciliation sweep over non-terminal records regardless of what did or did not arrive.
#A handler checklist
- Read the raw bytes before any body parser touches them.
- Verify the HMAC in constant time; reject on mismatch or a missing header.
- Return
2xxin well under 10 seconds; queue the work. - Deduplicate on (
event, entity id) — and make the handler idempotent anyway. - Never trust the payload as final: reconcile against the resource read.
- Store the secret like a credential; rotate it through
/regeneratewhen it leaks.