Webhook Handler Examples
Complete, production-ready webhook handlers with signature verification and all event types.
Full handler
- Node.js (Express)
- PHP
- Python (Flask)
- WordPress
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;
webhook.php
<?php
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_COINSNAP_SIG'] ?? '';
$secret = getenv('COINSNAP_WEBHOOK_SECRET');
// 1. Verify signature
if (!hash_equals('sha256=' . hash_hmac('sha256', $payload, $secret), $signature)) {
http_response_code(401);
exit('Unauthorized');
}
// 2. Parse event
$event = json_decode($payload, true);
$order_id = $event['metadata']['orderId'] ?? null;
// 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
$db->prepare('UPDATE orders SET status = ? WHERE id = ?')
->execute(['processing', $order_id]);
break;
case 'Settled':
// Idempotency guard
$order = $db->fetchOne('SELECT status FROM orders WHERE id = ?', [$order_id]);
if (!$order || $order['status'] === 'paid') break;
$db->prepare('UPDATE orders SET status = ?, paid_at = NOW() WHERE id = ?')
->execute(['paid', $order_id]);
if ($event['additionalStatus'] === 'Overpaid') {
// Customer paid more than the invoice amount — decide on refund policy
notifyMerchantOfOverpayment($order_id, $event);
}
if ($event['additionalStatus'] === 'PaidAfterExpiration') {
// Payment received after the invoice expired — review manually
flagForManualReview($order_id);
break;
}
triggerFulfillment($order_id);
break;
case 'Expired':
if ($event['additionalStatus'] === 'Underpaid') {
// Invoice expired with partial payment — contact customer
$db->prepare('UPDATE orders SET status = ? WHERE id = ?')
->execute(['underpaid', $order_id]);
notifyCustomerOfUnderpayment($order_id);
} else {
$db->prepare('UPDATE orders SET status = ? WHERE id = ?')
->execute(['expired', $order_id]);
releaseStockReservation($order_id);
}
break;
case 'Invalid':
// Invoice manually marked invalid or otherwise rejected
$db->prepare('UPDATE orders SET status = ? WHERE id = ?')
->execute(['cancelled', $order_id]);
break;
}
http_response_code(200);
echo 'OK';
app.py
from flask import Flask, request, abort
import hashlib
import hmac
import json
import os
app = Flask(__name__)
def verify_signature(raw_body: bytes, signature: str, secret: str) -> bool:
expected = 'sha256=' + hmac.new(
secret.encode('utf-8'),
raw_body,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature or '')
@app.post('/webhooks/coinsnap')
def coinsnap_webhook():
raw_body = request.get_data()
signature = request.headers.get('X-Coinsnap-Sig', '')
secret = os.environ['COINSNAP_WEBHOOK_SECRET']
if not verify_signature(raw_body, signature, secret):
abort(401)
event = json.loads(raw_body)
order_id = event.get('metadata', {}).get('orderId')
match event.get('type'):
case 'New':
# Payment attempt started — no action needed
pass
case 'Processing':
# Full amount received on-chain, waiting for block confirmation
db.orders.update({'id': order_id}, {'status': 'processing'})
case 'Settled':
order = db.orders.find_by_id(order_id)
if not order or order.get('status') == 'paid':
return 'OK', 200
db.orders.update({'id': order_id}, {'status': 'paid', 'paid_at': now()})
if event.get('additionalStatus') == 'Overpaid':
notify_merchant_of_overpayment(order_id, event)
if event.get('additionalStatus') == 'PaidAfterExpiration':
flag_for_manual_review(order_id)
return 'OK', 200
trigger_fulfillment(order_id)
case 'Expired':
if event.get('additionalStatus') == 'Underpaid':
db.orders.update({'id': order_id}, {'status': 'underpaid'})
notify_customer_of_underpayment(order_id)
else:
db.orders.update({'id': order_id}, {'status': 'expired'})
release_stock_reservation(order_id)
case 'Invalid':
db.orders.update({'id': order_id}, {'status': 'cancelled'})
return 'OK', 200
functions.php or plugin file
<?php
add_action('rest_api_init', function () {
register_rest_route('coinsnap/v1', '/webhook', [
'methods' => 'POST',
'callback' => 'coinsnap_handle_webhook',
'permission_callback' => '__return_true',
]);
});
function coinsnap_handle_webhook(WP_REST_Request $request): WP_REST_Response {
$payload = $request->get_body();
$signature = $request->get_header('x-coinsnap-sig');
$secret = get_option('coinsnap_webhook_secret');
$expected = 'sha256=' . hash_hmac('sha256', $payload, $secret);
if (!hash_equals($expected, $signature ?? '')) {
return new WP_REST_Response('Unauthorized', 401);
}
$event = json_decode($payload, true);
$order_id = $event['metadata']['orderId'] ?? null;
switch ($event['type']) {
case 'New':
break;
case 'Processing':
update_post_meta($order_id, '_coinsnap_status', 'processing');
break;
case 'Settled':
if (get_post_meta($order_id, '_coinsnap_status', true) === 'paid') {
break;
}
update_post_meta($order_id, '_coinsnap_status', 'paid');
update_post_meta($order_id, '_coinsnap_paid_at', current_time('mysql'));
if (($event['additionalStatus'] ?? '') === 'Overpaid') {
do_action('coinsnap_overpaid_order', $order_id, $event);
}
if (($event['additionalStatus'] ?? '') === 'PaidAfterExpiration') {
do_action('coinsnap_manual_review_required', $order_id, $event);
break;
}
do_action('coinsnap_order_paid', $order_id, $event);
break;
case 'Expired':
if (($event['additionalStatus'] ?? '') === 'Underpaid') {
update_post_meta($order_id, '_coinsnap_status', 'underpaid');
do_action('coinsnap_underpaid_order', $order_id, $event);
} else {
update_post_meta($order_id, '_coinsnap_status', 'expired');
}
break;
case 'Invalid':
update_post_meta($order_id, '_coinsnap_status', 'cancelled');
break;
}
return new WP_REST_Response('OK', 200);
}
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
type | additionalStatus | Meaning |
|---|---|---|
Settled | None | Exact payment received |
Settled | Overpaid | Customer paid more than the invoice amount |
Settled | PaidAfterExpiration | Payment received after invoice expired |
Expired | None | Invoice expired without payment |
Expired | Underpaid | Invoice expired with partial payment |
Processing | None | On-chain payment detected, awaiting confirmation |
See Webhook Status Reference for the compact decision table and Webhooks → Overview for payload examples and signature verification.