Skip to main content

Webhook Handler Examples

Complete, production-ready webhook handlers with signature verification and all event types.


Full handler

routes/webhook.js
import express from 'express';
import crypto from 'crypto';

const router = express.Router();

router.post(
'/coinsnap',
express.raw({ type: 'application/json' }),
async (req, res) => {
// 1. Verify signature
const sig = req.headers['x-coinsnap-sig'] ?? '';
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.COINSNAP_WEBHOOK_SECRET)
.update(req.body)
.digest('hex');

const sigBuf = Buffer.from(sig.padEnd(expected.length));
const expBuf = Buffer.from(expected);

if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) {
return res.status(401).send('Unauthorized');
}

// 2. Parse event
const event = JSON.parse(req.body);
const orderId = event.metadata?.orderId;

// 3. Handle by type
switch (event.type) {
case 'New':
// Payment attempt started — no action needed
break;

case 'Processing':
// Full amount received on-chain, waiting for block confirmation
await db.orders.update({ id: orderId }, { status: 'processing' });
break;

case 'Settled': {
const order = await db.orders.findById(orderId);
if (!order || order.status === 'paid') break; // idempotency guard

await db.orders.update({ id: orderId }, { status: 'paid', paidAt: new Date() });

if (event.additionalStatus === 'Overpaid') {
// Customer paid more than the invoice amount — decide on refund policy
await notifyMerchantOfOverpayment(orderId, event);
}
if (event.additionalStatus === 'PaidAfterExpiration') {
// Payment received after the invoice expired — review manually
await flagForManualReview(orderId);
break;
}

await triggerFulfillment(orderId);
break;
}

case 'Expired':
if (event.additionalStatus === 'Underpaid') {
// Invoice expired with partial payment — contact customer
await db.orders.update({ id: orderId }, { status: 'underpaid' });
await notifyCustomerOfUnderpayment(orderId);
} else {
await db.orders.update({ id: orderId }, { status: 'expired' });
await releaseStockReservation(orderId);
}
break;

case 'Invalid':
// Invoice manually marked invalid or otherwise rejected
await db.orders.update({ id: orderId }, { status: 'cancelled' });
break;
}

res.status(200).send('OK');
}
);

export default router;

Simulate a webhook locally

SECRET="your_webhook_secret"
PAYLOAD='{"type":"Settled","invoiceId":"inv_test123","metadata":{"orderId":"order-42"},"additionalStatus":"None"}'
SIGNATURE="sha256=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')"

curl -X POST http://localhost:3000/webhooks/coinsnap \
-H "Content-Type: application/json" \
-H "X-Coinsnap-Sig: $SIGNATURE" \
-d "$PAYLOAD"

Event reference

typeadditionalStatusMeaning
SettledNoneExact payment received
SettledOverpaidCustomer paid more than the invoice amount
SettledPaidAfterExpirationPayment received after invoice expired
ExpiredNoneInvoice expired without payment
ExpiredUnderpaidInvoice expired with partial payment
ProcessingNoneOn-chain payment detected, awaiting confirmation

See Webhook Status Reference for the compact decision table and Webhooks → Overview for payload examples and signature verification.