Webhooks
Webhooks are how Coinsnap tells your server that something happened — most importantly, that a payment was received.
Why webhooks matter
When a customer pays, you have two signals:
- The redirect — The customer's browser redirects to your
redirectUrl - The webhook — Coinsnap sends a POST request to your server
Only the webhook is reliable. The redirect can be:
- Blocked by the browser
- Arrived before the payment is fully confirmed
- Spoofed by a malicious user (e.g. someone who knows your
redirectUrl)
Always confirm payment via the webhook before fulfilling an order.
How it works
Customer pays
→ Coinsnap detects payment
→ Coinsnap sends POST to your webhook URL
Headers:
Content-Type: application/json
X-Coinsnap-Sig: sha256=<hmac-sha256>
Body:
{ "type": "Settled", "invoiceId": "...", "metadata": { ... }, "additionalStatus": "None" }
→ Your server verifies signature
→ Your server processes the event
→ Your server responds 200 OK
Registering a webhook
- Open the Coinsnap dashboard → Webhooks
- Click Add Webhook
- Enter your endpoint URL (must be HTTPS in production)
- Select the events to receive
- Copy the Signing Secret — store it as
COINSNAP_WEBHOOK_SECRET
Delivery
Webhooks are delivered once. If your server is unreachable or returns a non-2xx response, the delivery is marked as failed — there are no automatic retries. Use the Redeliver button in the dashboard to manually resend a failed delivery.
Idempotency
Always make your handlers idempotent — protect against the same event being processed twice (e.g. after a manual redeliver):
case 'Settled': {
const order = await db.orders.findByInvoiceId(event.invoiceId);
if (order.status === 'paid') break;
await db.orders.markAsPaid(order.id);
break;
}
Events
All events share the same structure:
{
"type": "Settled",
"invoiceId": "inv_4Kz9mXpQ2rNvBtYwLs8cDf",
"metadata": {
"orderId": "order-123"
},
"additionalStatus": "None"
}
| Field | Description |
|---|---|
type | Event type: Settled, Expired, Processing, New, or Invalid |
invoiceId | The Coinsnap invoice ID |
metadata.orderId | Your order ID, as passed when creating the invoice |
additionalStatus | None, Underpaid, Overpaid, or PaidAfterExpiration |
Settled
Fires when a Bitcoin payment is fully confirmed. Use this to mark an order as paid.
{
"type": "Settled",
"invoiceId": "inv_4Kz9mXpQ2rNvBtYwLs8cDf",
"metadata": { "orderId": "order-123" },
"additionalStatus": "None"
}
Processing
Fires when a payment is detected but not yet fully confirmed. Applies to on-chain payments waiting for block confirmation — Lightning payments skip this state.
{
"type": "Processing",
"invoiceId": "inv_4Kz9mXpQ2rNvBtYwLs8cDf",
"metadata": { "orderId": "order-123" },
"additionalStatus": "None"
}
Wait for Settled before fulfilling.
Expired
Fires when an invoice expires before any payment is received.
{
"type": "Expired",
"invoiceId": "inv_4Kz9mXpQ2rNvBtYwLs8cDf",
"metadata": { "orderId": "order-123" },
"additionalStatus": "None"
}
Mark the order as expired and release any reserved stock.
Overpayment
Fires when payment is confirmed but the customer paid more than the invoice amount (additionalStatus: Overpaid).
{
"type": "Settled",
"invoiceId": "inv_4Kz9mXpQ2rNvBtYwLs8cDf",
"metadata": { "orderId": "order-123" },
"additionalStatus": "Overpaid"
}
The order is paid — fulfill it. Whether to refund the difference is up to your business logic.
Underpayment
Fires when an invoice expires but a partial payment was received (additionalStatus: Underpaid).
{
"type": "Expired",
"invoiceId": "inv_4Kz9mXpQ2rNvBtYwLs8cDf",
"metadata": { "orderId": "order-123" },
"additionalStatus": "Underpaid"
}
Do not fulfill. Contact the customer — they need to pay the remaining amount or receive a refund.
PaidLate
Fires when a payment is received after the invoice has already expired (additionalStatus: PaidAfterExpiration).
{
"type": "Settled",
"invoiceId": "inv_4Kz9mXpQ2rNvBtYwLs8cDf",
"metadata": { "orderId": "order-123" },
"additionalStatus": "PaidAfterExpiration"
}
Do not auto-fulfill — review manually and decide whether to honor the order.
Invalid
Fires when an invoice is invalidated — for example, when a payment could not be reconciled. The additionalStatus is always None.
{
"type": "Invalid",
"invoiceId": "inv_4Kz9mXpQ2rNvBtYwLs8cDf",
"metadata": { "orderId": "order-123" },
"additionalStatus": "None"
}
Do not fulfill. Log the event and investigate the invoice in the dashboard.
Signature verification
Every webhook Coinsnap sends includes a signature in the X-Coinsnap-Sig header with the format sha256=<hex>. Verifying it ensures the request genuinely came from Coinsnap.
Always verify the signature before processing a webhook. An unverified endpoint can be abused to fake payment confirmations.
- Node.js
- PHP
- Python
import crypto from 'crypto';
function verifyWebhookSignature(rawBody, signature, secret) {
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
const sigBuf = Buffer.from(signature.padEnd(expected.length));
const expBuf = Buffer.from(expected);
return sigBuf.length === expBuf.length && crypto.timingSafeEqual(sigBuf, expBuf);
}
// Express — must use raw body parser
app.post('/webhooks/coinsnap', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-coinsnap-sig'] ?? '';
if (!verifyWebhookSignature(req.body, signature, process.env.COINSNAP_WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body);
// handle event...
res.status(200).send('OK');
});
Use express.raw(), not express.json() — parsing the body changes the bytes and breaks signature verification.
<?php
function verifyWebhookSignature(string $payload, string $signature, string $secret): bool {
$expected = 'sha256=' . hash_hmac('sha256', $payload, $secret);
return hash_equals($expected, $signature);
}
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_COINSNAP_SIG'] ?? '';
$secret = getenv('COINSNAP_WEBHOOK_SECRET');
if (!verifyWebhookSignature($payload, $signature, $secret)) {
http_response_code(401);
exit('Invalid signature');
}
$event = json_decode($payload, true);
// handle event...
http_response_code(200);
echo 'OK';
import hashlib
import hmac
import os
from flask import Flask, request
app = Flask(__name__)
def verify_webhook_signature(raw_body: bytes, signature: str, secret: str) -> bool:
expected = 'sha256=' + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
@app.route('/webhooks/coinsnap', methods=['POST'])
def webhook():
signature = request.headers.get('X-Coinsnap-Sig', '')
secret = os.environ['COINSNAP_WEBHOOK_SECRET']
if not verify_webhook_signature(request.get_data(), signature, secret):
return 'Invalid signature', 401
event = request.get_json()
# handle event...
return 'OK', 200
Common mistakes:
| Mistake | Result |
|---|---|
| Parsing JSON body before verifying | Signature always fails |
Missing the sha256= prefix in comparison | Signature always fails |
Using === instead of constant-time comparison | Vulnerable to timing attacks |
| Using the API key instead of the webhook secret | These are different credentials |
Reading X-Webhook-Signature instead of X-Coinsnap-Sig | Header not found |
Local testing
Coinsnap needs a public HTTPS URL to deliver webhooks. During development, use a tunneling tool to expose your local server.
ngrok
Requires a free account at ngrok.com.
ngrok config add-authtoken YOUR_NGROK_TOKEN
ngrok http 3000
# → https://abc123.ngrok-free.app
cloudflared (free, no account needed)
npx cloudflared tunnel --url http://localhost:3000
# → https://abc123.trycloudflare.com
Register the generated HTTPS URL as your webhook URL in Coinsnap → Webhooks. The URL changes every time you restart the tunnel.
Simulate a webhook manually
SECRET="your_webhook_secret"
PAYLOAD='{"type":"Settled","invoiceId":"inv_test","metadata":{"orderId":"order-123"},"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"
Redeliver from dashboard
- Go to Webhooks → [your webhook] → Deliveries
- Click Redeliver