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>
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

  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. Only the selected events are delivered, so an endpoint with nothing selected receives nothing.
  5. 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"
}
FieldDescription
typeEvent type: New, Processing, Settled, or Expired
invoiceIdThe Coinsnap invoice ID
metadata.orderIdYour order ID, as passed when creating the invoice
additionalStatusNone, 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.

HeaderFormatSigns
X-Coinsnap-Signaturet=<unix-seconds>,v1=<hex>"<t>.<body>"
X-Coinsnap-Sigsha256=<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)
);
}
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
Verifying only X-Coinsnap-SigWorks, 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

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