Skip to main content

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:

  1. The redirect — The customer's browser redirects to your redirectUrl
  2. 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

  1. Open the Coinsnap dashboard → Webhooks
  2. Click Add Webhook
  3. Enter your endpoint URL (must be HTTPS in production)
  4. Select the events to receive
  5. 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"
}
FieldDescription
typeEvent type: Settled, Expired, Processing, New, or Invalid
invoiceIdThe Coinsnap invoice ID
metadata.orderIdYour order ID, as passed when creating the invoice
additionalStatusNone, 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.

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.

Common mistakes:

MistakeResult
Parsing JSON body before verifyingSignature always fails
Missing the sha256= prefix in comparisonSignature always fails
Using === instead of constant-time comparisonVulnerable to timing attacks
Using the API key instead of the webhook secretThese are different credentials
Reading X-Webhook-Signature instead of X-Coinsnap-SigHeader 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

  1. Go to Webhooks → [your webhook] → Deliveries
  2. Click Redeliver