Handle CoinPay Webhooks
You are implementing a webhook receiver for CoinPay events. This is required for any payment, escrow, or subscription integration.
Goal
Receive signed webhook deliveries from CoinPay, verify them, and update local state idempotently.
Environment variables
COINPAY_WEBHOOK_SECRET=whsec_...
Where to find it:
https://coinpayportal.com/businesses/<your-business-id>→ Webhooks tab (or?mode=webhooks) → create an endpoint pointing at yourhttps://example-business.com/api/coinpay/webhookURL → copy the Signing Secret.- Each endpoint has its own secret. If you rotate it in the portal, update
.envand redeploy.
Events
Treat BOTH payment.confirmed AND payment.forwarded as completion — your handler should fulfill the order on whichever arrives first. They mean different things internally and which one fires depends on the payment rail:
| Event | Card rail | Crypto rail | Action | | --- | --- | --- | --- | | payment.confirmed | Fires after Stripe Checkout completes — funds in your CoinPay-connected Stripe account | Fires when the chain has enough confirmations — funds NOT yet in your merchant wallet | Fulfill if rail is card; safe to fulfill if rail is crypto (CoinPay forwards next) | | payment.forwarded | Not fired for card | Fires when crypto funds are forwarded to your merchant wallet — includes the on-chain payout txid | Fulfill (this is the canonical "merchant has the money" event for crypto) |
A handler that only switches on payment.confirmed will silently miss every crypto payment — payment.forwarded is the only signal that fires for the crypto path on some chain/wallet configs. Make both events terminal "complete" states and dedupe by payment.id so it doesn't matter which lands first.
Other events:
payment.expired— payment window passed without fundingpayment.failed— payment was attempted but failed (typically card decline)escrow.funded/escrow.released/escrow.refunded/escrow.disputedseries.cycle.created/series.cycle.funded/series.cycle.missed/series.canceled
Headers
Each delivery includes:
x-coinpay-signature: t=<timestamp>,v1=<hex_hmac>x-coinpay-event: <event_name>x-coinpay-delivery: <unique_id>— use for dedupe
Verification (Node)
import crypto from 'crypto';
function verifyWebhookSignature(rawBody, signatureHeader, secret) {
const parts = signatureHeader.split(',');
const signatureParts = {};
for (const part of parts) {
const [key, value] = part.split('=');
signatureParts[key] = value;
}
const timestamp = signatureParts.t;
const expectedSignature = signatureParts.v1;
const timestampAge = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
if (timestampAge > 300) return false; // reject anything older than 5 minutes
const signedPayload = `${timestamp}.${rawBody}`;
const computedSignature = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(computedSignature),
Buffer.from(expectedSignature)
);
}
Handler shape
export async function POST(req) {
const rawBody = await req.text(); // MUST be the raw body, not parsed JSON
const signature = req.headers.get('x-coinpay-signature');
const secret = process.env.COINPAY_WEBHOOK_SECRET;
if (!verifyWebhookSignature(rawBody, signature, secret)) {
return new Response('Invalid signature', { status: 401 });
}
const event = JSON.parse(rawBody);
// Idempotency: skip if we've seen this delivery before
if (await alreadyProcessed(event.id)) return new Response('ok');
// Use an allowlist Set, not a switch — adding a new completion event
// should be one change in one place, and dedupe by payment.id covers
// the case where both `payment.confirmed` and `payment.forwarded` fire
// for the same crypto payment.
const COMPLETE = new Set(['payment.confirmed', 'payment.forwarded']);
const FAIL = new Set(['payment.expired', 'payment.failed']);
if (COMPLETE.has(event.type)) {
// Mark order paid + store payout txid if present (event.data.tx_hash
// is set on `payment.forwarded` for crypto).
} else if (FAIL.has(event.type)) {
// Release reservation.
}
await markProcessed(event.id);
return new Response('ok');
}
Rules
- The signature is computed over the raw request body. Do not re-stringify parsed JSON — many frameworks (Next.js, Express with
express.json()) lose the exact bytes. Capture the raw body. - Reject deliveries older than 5 minutes (replay protection).
- Always idempotent: dedupe by
x-coinpay-deliveryorevent.id. - Return 2xx quickly — do not
awaitslow IO inside the handler. CoinPay's outbound delivery uses 3 retries with a 30s timeout each (up to 93s). If your handler awaits PDF rendering, email sending, or any third-party API, you can blow CoinPay's retry budget — which in turn ripples back to Stripe (whose webhook to CoinPay also has a 30s budget for card payments) and silently breaks the whole chain. Grant the credit / mark the order paid synchronously, thenvoidthe slow work:
``js await markOrderPaid(event.data.payment_id); // fast: DB update void sendReceiptEmail(...).catch(console.error); // slow: defer return new Response('ok'); // 200 within milliseconds ``
- Treat
payment.confirmedANDpayment.forwardedas completion (see Events table above) — handlers that only listen forpayment.confirmedsilently miss crypto payments. - Use
example-business.comfor the placeholder webhook URL.
Deliverable
- One webhook endpoint, signature-verified, idempotent, with tests for: valid signature, bad signature, expired timestamp, duplicate delivery.