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
payment.confirmed— buyer paid; safe to fulfill the orderpayment.forwarded— funds forwarded to merchant wallet (settles the merchant payout, includes on-chain txid)payment.expired— payment window passed without fundingescrow.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');
switch (event.type) {
case 'payment.confirmed': /* mark order paid */ break;
case 'payment.forwarded': /* store payout txid */ break;
case 'payment.expired': /* release reservation */ break;
// ...
}
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 heavy work in a background queue.
- 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.