Skip to main content

Handle Payment Result

Pay links fire the same webhook events as direct invoice integrations. The orderId you set when creating the pay link is included in every event — use it to match the payment back to your order.


Order status flow

Map Coinsnap webhook events to your order statuses:

Your order statusWhen to set it
pending_paymentOrder created, pay link generated
awaiting_bitcoin_paymentCustomer opened the pay link (optional — requires polling)
paidSettled webhook received
completedOrder fulfilled

For most integrations, you only need two states: pending_payment and paid.


Webhook events

EventadditionalStatusMeaningAction
SettledNoneExact amount paidMark as paid, fulfill
SettledOverpaidCustomer paid more than the amountMark as paid, fulfill; decide on the overpayment
SettledPaidAfterExpirationPayment received after invoice expiredReview manually
ExpiredNoneInvoice expired, no paymentNo action — pay link is still open
ExpiredUnderpaidInvoice expired with partial paymentContact customer

Handling events

POST /webhooks/coinsnap
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
switch (event.type) {
case 'Settled': {
// Idempotency guard
const order = await db.orders.findById(orderId);
if (!order || order.status === 'paid') break;

await db.orders.update({ id: orderId }, { status: 'paid', paidAt: new Date() });
await triggerFulfillment(orderId);
break;
}

case 'Expired':
// Pay link remains open — no action needed unless underpaid
if (event.additionalStatus === 'Underpaid') {
await notifyCustomerOfUnderpayment(orderId);
}
break;
}

res.status(200).send('OK');
});

export default router;

Signature verification

Always verify the X-Coinsnap-Sig header before processing. See Webhooks → Verification for full examples in Node.js, PHP, and Python, including common mistakes.


Reconciliation

The orderId field in the webhook payload matches the orderId you set when creating the pay link. Use this to look up the order in your system:

{
"type": "Settled",
"invoiceId": "inv_4Kz9mXpQ2rNvBtYwLs8cDf",
"metadata": {
"orderId": "ORD-2026-1045"
},
"additionalStatus": "None"
}

For accounting and ERP integrations, orderId is the field that links a Coinsnap payment back to the original invoice number in your external system.


Polling as a fallback (development only)

const res = await fetch(
`https://app.coinsnap.io/api/v1/stores/${storeId}/payment-requests/${payLinkId}`,
{ headers: { 'x-api-key': apiKey } }
);
const link = await res.json();
// Check link.status for payment state

Do not poll in production. Webhooks are the reliable confirmation mechanism.