Coinflow webhook setup, signature verification, event handling, deduplication, and payload structures for all checkout, subscription, payout, and KYC events.
You are an expert at integrating Coinflow webhooks. You know every event type, payload structure, and common pitfall. When the developer asks you to handle webhooks, follow these instructions exactly.
app.post('/coinflow-webhook', (req, res) => {
const authHeader = req.get('Authorization');
if (authHeader !== process.env.COINFLOW_VALIDATION_KEY) {
return res.status(401).send('Unauthorized');
}
const {data, eventType, created} = req.body;
handleEvent(eventType, data);
res.sendStatus(200);
});
The Coinflow-Signature header format:
Coinflow-Signature: t=1717012345,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
t = Unix timestamp (seconds) when signature was generatedv1 = HMAC-SHA256 hex digest of {timestamp}.{raw JSON body}Node.js/TypeScript:
import crypto from 'node:crypto';
function verifyWebhookSignature({
signatureHeader,
payload,
secret,
}: {
signatureHeader: string;
payload: string;
secret: string;
}): boolean {
const parts = signatureHeader.split(',');
let timestamp: string | undefined;
let signature: string | undefined;
for (const part of parts) {
const [key, value] = part.split('=', 2);
if (key === 't') timestamp = value;
else if (key === 'v1') signature = value;
}
if (!timestamp || !signature) {
throw new Error('Invalid Coinflow-Signature header');
}
const signedPayload = `${timestamp}.${payload}`;
const expected = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected),
);
}
Express middleware:
// CRITICAL: Must use raw body, not parsed JSON
app.post('/coinflow-webhook',
express.raw({type: 'application/json'}),
(req, res) => {
const signatureHeader = req.headers['coinflow-signature'] as string;
const rawBody = req.body.toString(); // Must be raw string
const isValid = verifyWebhookSignature({
signatureHeader,
payload: rawBody,
secret: process.env.COINFLOW_VALIDATION_KEY!,
});
if (!isValid) return res.status(401).send('Invalid signature');
const event = JSON.parse(rawBody);
handleEvent(event);
res.sendStatus(200);
}
);
CRITICAL: Verify against the raw request body string, NOT parsed-and-re-serialized JSON. Re-serializing changes whitespace or key order, causing verification failure.
Python:
import hmac
import hashlib
def verify_webhook_signature(signature_header: str, payload: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in signature_header.split(","))
timestamp = parts.get("t")
signature = parts.get("v1")
if not timestamp or not signature:
raise ValueError("Invalid Coinflow-Signature header")
signed_payload = f"{timestamp}.{payload}"
expected = hmac.new(
secret.encode(), signed_payload.encode(), hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)
Coinflow may send the same webhook multiple times. Deduplicate by payment ID using a persistent store:
// Use a database or Redis to track processed events — never use in-memory state,
// which resets on every deploy/restart and doesn't work across multiple instances.
app.post('/coinflow-webhook', async (req, res) => {
const {data, eventType} = req.body;
const deduplicationKey = `${eventType}:${data.id}`;
const alreadyProcessed = await db.collection('processedWebhooks').findOne({_id: deduplicationKey});
if (alreadyProcessed) {
return res.sendStatus(200); // Already processed
}
await db.collection('processedWebhooks').insertOne({
_id: deduplicationKey,
processedAt: new Date(),
});
handleEvent(eventType, data);
res.sendStatus(200);
});
Payment completed, funds sent to merchant settlement location.
{
"eventType": "Settled",
"category": "Purchase",
"created": "2025-04-28T20:11:07.608Z",
"data": {
"id": "78f9be3f-691f-4f8c-82f7-c70221b006e7",
"signature": "3zP3VcWM6rKF1izpnWn5rJ7XJucoEicwncQ9hvGR7Q7FuGvDPenfBrfLUVc4fjbYghnzauTDfY4c8Jc2Nb5y1AWm",
"webhookInfo": {"example": "{\"purchaseId\":\"123abc\"}"},
"subtotal": {"cents": 500, "currency": "USD"},
"fees": {"cents": 46, "currency": "USD"},
"gasFees": {"cents": 0, "currency": "USD"},
"chargebackProtectionFees": {"cents": 0, "currency": "USD"},
"total": {"cents": 546, "currency": "USD"},
"merchantId": "testtest",
"customerId": "customer1",
"rawCustomerId": "customer1"
}
}
Card issuer authorized the payment (funds not yet settled).
Same structure as Settled but eventType: "Card Payment Authorized".
Card issuer declined. Includes decline details:
{
"eventType": "Card Payment Declined",
"data": {
"declineCode": "59",
"declineDescription": "The transaction is suspected of fraud.",
...sameFieldsAsSettled
}
}
Chargeback protection provider rejected due to fraud suspicion.
Awaiting merchant review/approval (if pending review feature is enabled).
Direct USDC payment received in settlement location.
{
"eventType": "USDC Payment Received",
"data": {
"webhookInfo": {"item": "sword"},
"subtotal": {"cents": 200, "currency": "USD"},
"fees": {"cents": 0, "currency": "USD"},
...
}
}
{
"eventType": "Refund",
"data": {
"totals": {
"subtotal": {"currency": "USD", "cents": 500},
"fees": {"cents": 46, "currency": "USD"},
"total": {"cents": 546, "currency": "USD"}
},
"id": "78f9be3f-...",
"paymentId": "0197a7e7-...",
"wallet": "...",
"blockchain": "solana"
}
}
| Event | Description |
|---|---|
| ACH Initiated | ACH payment started |
| ACH Batched | ACH accepted by bank, processing |
| ACH Returned | Bank returned the ACH payment |
| ACH Failed | ACH denied by bank |
| Settled | ACH payment completed (same structure as card Settled) |
ACH lifecycle: Initiated → Batched → Settled (success) OR Returned/Failed (failure)
| Event | Description |
|---|---|
| Settled | PIX payment completed |
| PIX Failed | PIX payment failed during processing |
| PIX Expiration | Payment window expired before PIX completion |
| Event | Description |
|---|---|
| Settled | Payment completed |
| Payment Expiration | Payment window expired |
{
"eventType": "Card Payment Chargeback Opened",
"data": {
"id": "c10d7ac6-...",
"chargebackId": "11111111111111111111111",
"reasonCode": "4853",
"reasonDescription": "Merchandise/Services Not as Described",
"respondByDate": "2025-07-01T00:00:00.000Z",
"subtotal": {"cents": 2500, "currency": "USD"},
"fees": {"cents": 169, "currency": "USD"},
"chargebackProtectionFees": {"cents": 68, "currency": "USD"},
"total": {"cents": 2737, "currency": "USD"},
"customerId": "user123"
}
}
IMPORTANT: respondByDate is your deadline to submit evidence. Act on this immediately.
Same structure with eventType: "Card Payment Chargeback Won" or "Card Payment Chargeback Lost".
{
"eventType": "Crypto Overpayment",
"data": {
"paymentId": "78f9be3f-...",
"sessionId": "ses_abc123xyz",
"refundUrl": "https://...",
"expectedAmount": "10.00",
"actualAmount": "12.50",
"currencySymbol": "USDC",
"actualAmountUSD": "12.50"
}
}
Same structure but actualAmount < expectedAmount. Use refundUrl to process refund.
| Event | Description | Key Fields |
|---|---|---|
| Subscription Created | New subscription activated | planCode, planName, subscriptionId, amount, interval, fundingMethod |
| Subscription Canceled | Canceled by merchant/customer | + reason field |
| Subscription Expired | Plan no longer active | |
| Subscription Failure | Payment failed, not created/renewed | |
| Subscription Concluded | Duration completed |
{
"eventType": "Subscription Created",
"category": "Subscription",
"data": {
"planCode": "music_access",
"planName": "creator1234",
"subscriptionId": "680fe4d832ddccc1ee91885e",
"customerId": "78C3dn...",
"amount": {"cents": 500, "currency": "USD"},
"interval": "Monthly",
"fundingMethod": "Card",
"webhookInfo": {"item": "sword"}
}
}
Subscription renewals fire Settled events with a subscription object in the payload:
{
"eventType": "Settled",
"category": "Purchase",
"data": {
"id": "db746ccf-...",
"subscription": {
"_id": "680fe4d832ddccc1ee91885e",
"plan": {
"name": "creator1234",
"code": "music_access",
"interval": "Monthly",
"duration": 12,
"amount": {"cents": 500, "currency": "USD"}
},
"status": "Active",
"nextPaymentAt": "2025-05-28T20:28:08.164Z"
},
"subtotal": {"cents": 500, "currency": "USD"},
...
}
}
| Event | Description |
|---|---|
| KYC Created | KYC process started |
| KYC Success | KYC verification passed |
| KYC Failure | KYC verification failed |
{
"eventType": "KYC Success",
"category": "KYC",
"data": {
"wallet": "test-withdrawer-f",
"blockchain": "user",
"email": "[email protected]"
}
}
| Event | Description |
|---|---|
| Withdraw Pending | Withdrawal initiated |
| Withdraw Success | Withdrawal submitted to bank |
| Withdraw Failure | Withdrawal failed |
{
"eventType": "Withdraw Success",
"category": "Withdraw",
"data": {
"wallet": "user123",
"blockchain": "solana",
"signature": "5nQq5iVkoEc1...",
"userFees": {"cents": 200, "currency": "USD"},
"userGasFees": {"cents": 0, "currency": "USD"},
"merchantGasFees": {"cents": 2, "currency": "USD"},
"total": {"cents": 202, "currency": "USD"},
"currency": "USD",
"merchantId": "testtest",
"idempotencyKey": "merchant-withdrawal-12345"
}
}
{
"eventType": "CryptoDepositFundsReceived",
"category": "CryptoDeposit",
"data": {
"sessionId": "session-123",
"depositAddress": "deposit-address-here",
"blockchainChainId": "solana",
"tokenSymbol": "USDC",
"user": "user-456",
"amount": {"cents": 10000, "currency": "USD"},
"merchantId": "merchant-789",
"transactionHash": "tx-hash-here",
"receivedAt": "2024-01-01T00:00:00.000Z"
}
}
| Event Type | Category | When |
|---|---|---|
| Settled | Purchase | Payment completed and funds delivered |
| Card Payment Authorized | Purchase | Card issuer authorized |
| Card Payment Declined | Purchase | Card issuer declined |
| Card Payment Suspected Fraud | Purchase | Fraud detection rejected |
| Payment Pending Review | Purchase | Awaiting manual review |
| USDC Payment Received | Purchase | Direct USDC received |
| Card Payment Chargeback Opened | Purchase | Chargeback investigation started |
| Card Payment Chargeback Won | Purchase | Chargeback resolved for merchant |
| Card Payment Chargeback Lost | Purchase | Chargeback resolved for cardholder |
| ACH Initiated | Purchase | ACH started |
| ACH Batched | Purchase | ACH accepted by bank |
| ACH Returned | Purchase | ACH returned by bank |
| ACH Failed | Purchase | ACH denied by bank |
| PIX Failed | Purchase | PIX processing failed |
| PIX Expiration | Purchase | PIX window expired |
| Payment Expiration | Purchase | SEPA/PIX window expired |
| Refund | Purchase | Payment refunded |
| Crypto Overpayment | Purchase | More crypto sent than required |
| Crypto Underpayment | Purchase | Less crypto sent than required |
| Subscription Created | Subscription | Subscription activated |
| Subscription Canceled | Subscription | Customer/merchant canceled |
| Subscription Expired | Subscription | Plan no longer active |
| Subscription Failure | Subscription | Payment failed |
| Subscription Concluded | Subscription | Duration completed |
| KYC Created | KYC | Verification started |
| KYC Success | KYC | Verification passed |
| KYC Failure | KYC | Verification failed |
| Withdraw Pending | Withdraw | Withdrawal initiated |
| Withdraw Success | Withdraw | Withdrawal submitted |
| Withdraw Failure | Withdraw | Withdrawal failed |
| CryptoDepositFundsReceived | CryptoDeposit | Crypto deposit received |
settledCard Payment Chargeback OpenedChargeback Won or Chargeback LostPass custom data through the checkout flow to your webhook:
// In CoinflowPurchase or API request:
webhookInfo: {orderId: "abc123", userId: "user456"}
// Received in webhook payload:
data.webhookInfo // {"orderId": "abc123", "userId": "user456"}
Parsing body before verifying signature — Use express.raw() middleware to get the raw string body for signature verification. If you use express.json() first, re-serializing will break the signature.
Not returning 200 fast enough — Coinflow times out after 5 seconds. Queue heavy processing (database writes, API calls, notifications) for background execution.
Missing deduplication — Webhooks can fire multiple times. Always check if you've already processed a given eventType:paymentId combination.
Ignoring retry behavior — If your server is down, Coinflow retries for 36 hours. Your handler must be idempotent.
Not handling all payment method event flows — ACH has different lifecycle events than cards. PIX and SEPA have expiration events. Make sure your handler covers all relevant event types for the payment methods you support.
Confusing eventType strings — Event types contain spaces (e.g., "Card Payment Authorized", not "CardPaymentAuthorized"). Match exactly.