Essentials
Webhooks
Lint tells your server when money arrives and when bills settle. Every webhook is signed; verify it, answer quickly, and handle each event once.
Money moves asynchronously: a payer sends a transfer whenever they like, and a biller can take minutes to settle. Rather than polling, give Lint a URL and it will POST an event there the moment something happens.
Events
| Event | Sent when | Sent to |
|---|---|---|
checkout.session.completed | The payer has paid a checkout session in full. | Your registered endpoints |
checkout.session.expired | A checkout session reached expires_at unpaid. | Your registered endpoints |
virtual_account.payment_received | A payment lands in a one-time virtual account. | That virtual account's callback_url |
business.customer.wallet.deposit | Money arrives in one of your customers' wallets. | Your registered endpoints |
bill_order.completed | A bill order is delivered, however long it took. | Your registered endpoints |
bill_order.failed | A bill order has failed for good (after Lint's own retries). | Your registered endpoints |
bill_order.payment_refunded | The payment for a failed bill order is back in your wallet. | Your registered endpoints |
Each checkout event is sent at most once per session, and each bill order event at most once per order. A session can get checkout.session.completed after checkout.session.expired: a transfer the payer started just before expiry can land just after it, and the money is yours either way. A failed bill order is always followed by bill_order.payment_refunded once the money is returned.
The dashboard also lists virtual_account.created, virtual_account.expired, bill_order.created and bill_order.updated. Lint does not send these yet; don't build on them.
Register an endpoint
In the dashboard, open Integrations → Webhooks → Create new webhook, enter an HTTPS URL and pick the events it should receive. You can register several endpoints, each with its own events. The same page lists every delivery with its response, and lets you resend one.
virtual_account.payment_received is the exception: it goes to the callback_url you set when creating that virtual account, so each payment can be routed wherever you like.
The request
Every webhook is an HTTP POST with a JSON body in the same envelope:
POST /lint/webhooks HTTP/1.1
Content-Type: application/json
Lint-Signature: t=1762077851,v1=6a0b2e3f8c1d…
Lint-Event: bill_order.completed
Lint-Delivery-Id: 4812
{"event":"bill_order.completed","data":{…}}| Header | Meaning |
|---|---|
Lint-Signature | Proof the request came from Lint. Verify it. |
Lint-Event | The event name, also in the body as event. |
Lint-Delivery-Id | Unique to this event. Stays the same when Lint retries it, so use it to ignore duplicates. |
Verify signatures
Anyone can send a request to your URL. Before you act on a webhook, check that Lint signed it.
Lint-Signature has two parts: t, the Unix time Lint signed at, and v1, a hex HMAC-SHA256 of the string <t>.<raw request body> made with your account's signing secret. Find the secret under Integrations → Webhooks → Signing secret. It starts with whsec_, and sandbox and live each have their own.
To verify:
- Read the raw request body exactly as received, before any JSON parsing. Re-serialised JSON will not match.
- Compute HMAC-SHA256 of
t + "." + rawBodywith your signing secret. - Compare it with
v1using a constant-time comparison. - Reject the request if
tis more than five minutes from your clock, so a captured request can't be replayed later.
import crypto from 'node:crypto'
import express from 'express'
const app = express()
const TOLERANCE_SECONDS = 300
function verifyLintSignature(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(',').map((part) => part.split('=')))
const timestamp = Number(parts.t)
if (!parts.v1 || !Number.isInteger(timestamp)) return false
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false
const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex')
const given = Buffer.from(parts.v1, 'hex')
return given.length === expected.length / 2 && crypto.timingSafeEqual(Buffer.from(expected, 'hex'), given)
}
// express.raw keeps the body as bytes, exactly as Lint signed it.
app.post('/lint/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body.toString('utf8')
if (!verifyLintSignature(rawBody, req.get('Lint-Signature') ?? '', process.env.LINT_WEBHOOK_SECRET)) {
return res.status(400).send('invalid signature')
}
const { event, data } = JSON.parse(rawBody)
queue.add(event, { deliveryId: req.get('Lint-Delivery-Id'), data }) // process later
res.sendStatus(200)
})Rolling the secret
If the secret leaks, choose Roll secret on the Webhooks page. Every delivery from then on, retries included, is signed with the new secret, so update your server straight away.
Respond quickly, process later
Return any 2xx status within a few seconds to acknowledge a webhook. Do the real work — updating orders, emailing customers — in a background job after you respond. Lint waits up to 30 seconds; a slow or failing response counts as a failed delivery.
Retries
If your endpoint does not answer with a 2xx, Lint tries again: six deliveries in total, after waiting 1 minute, 5 minutes, 30 minutes, 2 hours and 8 hours. Every retry carries the same Lint-Delivery-Id and a fresh signature timestamp. After the last attempt Lint stops; you can still resend the delivery from the dashboard, and the API remains the source of truth.
Handle each event once
Retries, and your own redeploys, mean the same event can arrive more than once. Record each Lint-Delivery-Id you have processed and skip ones you have seen. Events can also arrive out of order, so treat a webhook as a prompt and confirm the current state with the API when order matters, e.g. GET /vas/billers/orders/{id}.
Payloads
checkout.session.completed and checkout.session.expired
data is the checkout session, exactly as GET /checkout/sessions/{id} returns it:
{
"event": "checkout.session.completed",
"data": {
"id": "9d3c1f0a-5b2e-4c7a-9f10-2b3c4d5e6f70",
"status": "paid",
"amount": 1250000,
"amount_paid": 1250000,
"currency": "NGN",
"reference": "order_8812",
"description": "2 x Golden Morn 12x500g",
"customer": { "name": "Ada Obi", "email": "ada@example.com", "phone": "2348000000000" },
"metadata": { "cart_id": "c_19" },
"url": "https://lint.finance/checkout/Xk3n9QpL2vT8rW1y…",
"success_url": "https://shop.example.com/orders/8812/thanks",
"cancel_url": "https://shop.example.com/cart",
"expires_at": "2026-11-04T10:00:00+00:00",
"paid_at": "2026-11-03T10:12:40+00:00",
"payment": {
"transaction_reference": "b7d7eaf3db594caeb95b4ef9b46e38341785231752",
"amount": 1250000,
"fees": 5000,
"settled_amount": 1245000
},
"created_at": "2026-11-03T10:00:00+00:00"
}
}checkout.session.expired carries the same shape with status: "expired" and payment: null.
virtual_account.payment_received
{
"event": "virtual_account.payment_received",
"data": {
"transaction_status": "SUCCESSFUL",
"amount": 500000,
"currency": "NGN",
"fees": 0,
"transaction_reference": "b7d7eaf3db594caeb95b4ef9b46e38341785231752",
"external_reference": "order_8812",
"virtual_account": {
"account_number": "6020012029",
"account_name": "Your Business Checkout",
"bank_code": "999240",
"bank_name": "SAFE HAVEN SANDBOX BANK"
},
"created_at": "2026-11-02T10:04:11+00:00"
}
}external_reference is the reference you gave when creating the virtual account.
business.customer.wallet.deposit
{
"event": "business.customer.wallet.deposit",
"data": {
"transaction_status": "SUCCESSFUL",
"amount": 2500000,
"fees": 0,
"total": 2500000,
"currency": "NGN",
"transaction_reference": "5c0e9e2d4b1f4b8f9a0c3d2e1f6a7b8c",
"customer": {
"id": "1898107f-ab12-4b27-a483-a863b7f6982e",
"name": "Ada Obi",
"external_reference": "user_4410"
},
"account": {
"id": "209bc13c-70ce-450d-8f2e-7a870362912a",
"name": "Ada Obi",
"account_number": "6010172152",
"bank_code": "999240",
"bank_name": "SAFE HAVEN SANDBOX BANK",
"external_reference": null
},
"created_at": "2026-11-02T11:20:45+00:00"
}
}customer.id is the customer's id; account.id is the wallet's id.
bill_order.completed, bill_order.failed, bill_order.payment_refunded
All three share one payload; order.status is SUCCESSFUL or FAILED, and order.processor_message carries the biller's reason on a failure.
{
"event": "bill_order.completed",
"data": {
"order": {
"id": 204,
"amount": 7500,
"fees": 0,
"total": 7500,
"commission": 0,
"external_reference": null,
"currency": "NGN",
"status": "SUCCESSFUL",
"processor_message": "",
"created_at": "2026-11-02T07:42:54+00:00",
"updated_at": "2026-11-02T07:54:26+00:00"
},
"biller": { "id": 3, "name": "MTN", "code": "mtn", "description": "MTN Nigeria", "logo_url": "https://…/mtn.png" },
"product": { "id": 44, "name": "75MB Daily Plan", "code": "mtn-75mb-daily", "description": "75MB valid for 1 day" }
}
}biller and product can be null if the product has since been removed from the catalogue; the order is still yours to reconcile by order.id.