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>
X-Coinsnap-Signature: t=<unix-seconds>,v1=<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. Only the selected events are delivered, so an endpoint with nothing selected receives nothing.
- Copy the Signing Secret — store it as
COINSNAP_WEBHOOK_SECRET
URL validation on registration
Before the webhook is saved, Coinsnap sends a one-off POST to the URL to confirm it is reachable:
{
"app": "Coinsnap",
"message": "This is a test for webhook reachability.",
"purpose": "webhook_url_validation",
"timestamp": "2024-01-01T00:00:00.000Z"
}
This request is unsigned. The webhook does not exist yet, so there is no signing secret to use. Check for purpose: "webhook_url_validation" if you need to tell it apart from a real event, and do not treat it as a payment.
The timeout is five seconds. Any success status counts as reachable, and so do 401 and 403, so an endpoint that authenticates before processing still registers. Anything else, including 404, a 5xx, a timeout or a DNS failure, refuses the registration.
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, or call Redeliver a webhook payload to automate it:
POST /api/v1/stores/{storeId}/webhooks/{webhookId}/paylods/{payloadId}/redeliver
The paylods segment is spelled that way in the live route. Use it exactly as shown.
A redelivered payload is not identical to the original. It carries only type and invoiceId, without metadata or additionalStatus, and it is signed at the moment of redelivery, so the timestamp in X-Coinsnap-Signature is the redelivery time rather than the original event time. Handle the two extra fields as optional and re-read the invoice if you need them.
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: New, Processing, Settled, or Expired |
invoiceId | The Coinsnap invoice ID |
metadata.orderId | Your order ID, as passed when creating the invoice |
additionalStatus | None, Underpaid, Overpaid, or PaidAfterExpiration |
Invalid is an invoice status, not an event. It is readable on the invoice, but no Invalid webhook is sent, which is why it does not appear in the event selection screen. Use Expired as the terminal signal.
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. For an on-chain payment it means the payment is waiting for block confirmation, and Settled follows later.
A Lightning payment settles immediately, but still produces a Processing event ahead of its Settled event. The two are delivered in that order, back to back, for the same invoice. Do not treat Processing as "not yet paid": treat it as informational and act on Settled. If your endpoint subscribes to Processing but not Settled, you will see only the first of the pair.
{
"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 having received less than the invoice amount, by more than the store's underpayment tolerance (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.
:::note Shortfalls within tolerance settle normally
Each store has an underpayment tolerance. A payment short by less than that settles as Settled with additionalStatus: None, not as Underpaid. So fulfilling on Settled plus None means "paid within tolerance" rather than "paid to the satoshi".
The exact figures are on the invoice: amount is what was asked for and totalPaid is what arrived, both in satoshis. Compare them if your business flow needs the difference.
The tolerance is a percentage of the invoice amount on a 0 to 100 scale, not a fraction and not a satoshi count, so a stored 0.25 means a quarter of one percent rather than 25 percent. See Underpayment tolerance for the formula and a worked example. Read your store's setting as underpaymentTolerance on GET /api/v1/stores/{storeId}. It is not settable through the public API, so contact Coinsnap to change it.
:::
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.
Signature verification
Every webhook Coinsnap sends carries two signature headers, both computed with your webhook's signing secret.
| Header | Format | Signs |
|---|---|---|
X-Coinsnap-Signature | t=<unix-seconds>,v1=<hex> | "<t>.<body>" |
X-Coinsnap-Sig | sha256=<hex> | the body only |
Always verify a signature before processing a webhook. An unverified endpoint can be abused to fake payment confirmations.
For a new integration, verify X-Coinsnap-Signature. The timestamp is part of the signed input, so a captured request cannot be replayed later with a fresh t without breaking v1. Verify v1, reject a t older than about five minutes, and compare in constant time. The scheme version lives inside the value, so a future scheme adds v2= rather than a new header.
X-Coinsnap-Sig signs the body alone and therefore offers no replay protection by itself. It is sent on every delivery and stays byte-for-byte compatible, so existing receivers keep working. The examples below use it.
Verifying X-Coinsnap-Signature (Node.js)
import crypto from 'crypto';
const MAX_AGE_SECONDS = 300;
function verifyTimestampedSignature(rawBody, header, secret) {
const parts = Object.fromEntries(
String(header ?? '')
.split(',')
.map((part) => part.split('=', 2)),
);
if (!parts.t || !parts.v1) return false;
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(parts.t));
if (!Number.isFinite(age) || age > MAX_AGE_SECONDS) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`)
.digest('hex');
const received = Buffer.from(parts.v1, 'hex');
const computed = Buffer.from(expected, 'hex');
return (
received.length === computed.length && crypto.timingSafeEqual(received, computed)
);
}
- 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 |
Verifying only X-Coinsnap-Sig | Works, but accepts a replayed request |
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