Payments
Webhooks
Learn about payments, settlements, and disputes as they happen — signed, so you can trust what you receive.
Register an endpoint
Register endpoints with your API key, or on the Developers page of the dashboard. An endpoint
belongs to the key's mode: one registered with a bx_test_ key only ever receives test-mode events.
/webhooksregister (secret returned once)/webhookslist this mode's endpoints/webhooks/{id}retrieve/webhooks/{id}edit URL, events, description; pause or resume/webhooks/{id}/roll-secretnew signing secret, returned once/webhooks/{id}/testsend a signed webhook.test now/webhooks/{id}deletecurl -X POST https://sandbox-api.baxpay.com/v1/webhooks \
-H "Authorization: Bearer $BAXPAY_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"url": "https://yourapp.com/hooks/baxpay",
"description": "Order events",
"events": ["payment_link.paid", "payment_session.captured"]
}'const res = await fetch('https://sandbox-api.baxpay.com/v1/webhooks', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.BAXPAY_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': crypto.randomUUID(),
},
body: JSON.stringify({
url: 'https://yourapp.com/hooks/baxpay',
description: 'Order events',
events: ['payment_link.paid', 'payment_session.captured'],
}),
});
const body = await res.json();
if (!res.ok) throw new Error(JSON.stringify(body));
console.log(JSON.stringify(body, null, 2)); // store body.data.secret now{
"success": true,
"data": {
"id": "5b0e7c1d-…",
"url": "https://yourapp.com/hooks/baxpay",
"description": "Order events",
"events": ["payment_link.paid", "payment_session.captured"],
"livemode": false,
"status": "ACTIVE",
"secret": "whsec_4a91c…",
"createdAt": "2026-07-16T14:00:00.000Z"
}
}events must list events from the catalogue below; anything else is refused with 400.
Send yourself a signed webhook.test event to check your endpoint end to end:
curl -X POST https://sandbox-api.baxpay.com/v1/webhooks/5b0e7c1d-…/test \
-H "Authorization: Bearer $BAXPAY_API_KEY"{ "success": true, "data": { "success": true, "statusCode": 200 } }data.statusCode is what your endpoint answered.
Pause, resume and roll the secret
Pause an endpoint while you deploy, then resume it. Resuming also revives an endpoint that was switched
to FAILED after too many failed deliveries.
curl -X PUT https://sandbox-api.baxpay.com/v1/webhooks/5b0e7c1d-… \
-H "Authorization: Bearer $BAXPAY_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{ "status": "INACTIVE" }'{ "success": true, "data": { "id": "5b0e7c1d-…", "status": "INACTIVE" } }curl -X PUT https://sandbox-api.baxpay.com/v1/webhooks/5b0e7c1d-… \
-H "Authorization: Bearer $BAXPAY_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{ "status": "ACTIVE" }'If a secret may have leaked, roll it. The old secret stops working immediately; store the new one, which is shown only in this response:
curl -X POST https://sandbox-api.baxpay.com/v1/webhooks/5b0e7c1d-…/roll-secret \
-H "Authorization: Bearer $BAXPAY_API_KEY" \
-H "Idempotency-Key: $(uuidgen)"{ "success": true, "data": { "id": "5b0e7c1d-…", "secret": "whsec_4a91c…" } }Verifying signatures
Every delivery carries an HMAC-SHA256 signature keyed with your webhook secret:
| Header | Value |
|---|---|
X-BaxPay-Signature-V2 | t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>"> — verify this one |
X-BaxPay-Event-Id | This delivery to this endpoint; the same on its retries |
X-BaxPay-Timestamp | The same t, on its own |
X-BaxPay-Signature | Legacy: hex HMAC-SHA256 of the raw body only (no replay protection) |
Reject a delivery whose t is more than 5 minutes old: an attacker who captured a real request
can't replay it later, because changing t breaks the signature.
import crypto from 'crypto';
// header = req.headers['x-baxpay-signature-v2'], e.g. "t=1767225600,v1=5f2b…"
function verify(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex');
const a = Buffer.from(parts.v1 ?? '', 'hex');
const b = Buffer.from(expected, 'hex');
// timingSafeEqual throws if the lengths differ, so check that first.
return a.length === b.length && crypto.timingSafeEqual(a, b);
}import hmac, hashlib, time
def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t = int(parts.get("t", "0"))
if not t or abs(time.time() - t) > tolerance:
return False
signed = f"{t}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(parts.get("v1", ""), expected)Sign the raw bytes, not a re-serialised object. If your framework parses JSON before you see it, re-encoding it will reorder keys or change whitespace, and the signature will never match. In Express, take the raw body for this one route:
import express from 'express';
const app = express();
app.post('/hooks/baxpay', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.get('X-BaxPay-Signature-V2') ?? '';
if (!verify(req.body, header, process.env.BAXPAY_WEBHOOK_SECRET)) return res.sendStatus(400);
const event = JSON.parse(req.body.toString('utf8'));
if (event.livemode !== (process.env.NODE_ENV === 'production')) return res.sendStatus(200);
queue.add(event); // dedupe on event.id, do the work later
res.sendStatus(200);
});Events
Every delivery has the same envelope; data depends on the event.
{
"id": "f017738f-…",
"type": "payment_link.paid",
"livemode": false,
"data": { "paymentLinkId": "1c9f4a2e-…", "amount": 47.3, "currency": "EUR" },
"createdAt": "2026-07-16T14:03:21.530Z"
}| Event | When | data |
|---|---|---|
payment_link.paid | A payment link was paid | The link: paymentLinkId, amount, currency, reference, method, transactionRef, … |
payment_session.preauthorized | A fuel pre-authorisation was approved: funds held, capture next | The session view |
payment_session.captured | A session captured: money moved | The session view |
payment_session.declined | A session was declined | The session view |
payment_session.voided | A session was voided, or a fuel pre-auth released | The session view |
payment_session.expired | A session was not completed within 3 minutes | The session view |
einvoice.paid | An e-invoice was paid through its link | invoiceId, invoiceNumber, amount, currency, … |
einvoice.status_changed | An e-invoice moved in its legal lifecycle | invoiceId, the new status and its source |
webhook.test | You asked for a test delivery | A fixed message |
Always check livemode before acting: a test event handled by production code is a free product.
Event log
Every event BaxPay sends is recorded for 30 days, in its mode, even when no endpoint was subscribed to it. Use the log to catch up after downtime, to see what your endpoint answered, and to send a delivery again. The dashboard shows the same log under Developers → Events.
/eventsthis mode's events, newest first; filter by type, from, to/events/{id}one event, with its deliveries/deliveriesdeliveries, newest first; filter by webhookId/deliveries/{id}/resendsend a delivery againList events of one type. from and to take ISO 8601 dates, page and limit (up to 100) page
through the results:
curl "https://sandbox-api.baxpay.com/v1/events?type=payment_link.paid&limit=5" \
-H "Authorization: Bearer $BAXPAY_API_KEY"{
"success": true,
"data": {
"data": [
{
"id": "f017738f-…",
"type": "payment_link.paid",
"livemode": false,
"data": { "paymentLinkId": "1c9f4a2e-…", "amount": 47.3, "currency": "EUR" },
"deliveries": { "total": 1, "succeeded": 1 },
"createdAt": "2026-07-16T14:03:21.530Z"
}
],
"total": 1,
"page": 1,
"limit": 5
}
}An event's id is the id of the payload your endpoint received. Retrieve it to see each delivery:
which endpoint, what it answered (the first 2 KB), how long it took, and whether a retry is pending.
curl https://sandbox-api.baxpay.com/v1/events/f017738f-… \
-H "Authorization: Bearer $BAXPAY_API_KEY"{
"success": true,
"data": {
"id": "f017738f-…",
"type": "payment_link.paid",
"livemode": false,
"data": { "paymentLinkId": "1c9f4a2e-…" },
"deliveries": [
{
"id": "9d2c6b0a-…",
"eventId": "f017738f-…",
"status": "succeeded",
"endpoint": { "id": "a3f2e8d1-…", "url": "https://yourapp.com/hooks/baxpay" },
"attempts": 1,
"responseStatus": 200,
"responseExcerpt": "{\"received\":true}",
"durationMs": 84,
"deliveredAt": "2026-07-16T14:03:21.702Z",
"nextRetryAt": null
}
]
}
}status | Meaning |
|---|---|
succeeded | Your endpoint answered 2xx |
pending | It failed; a retry is scheduled at nextRetryAt |
failed | It failed and will not be retried; error says why |
List the deliveries to one endpoint:
curl "https://sandbox-api.baxpay.com/v1/deliveries?webhookId=a3f2e8d1-…&limit=10" \
-H "Authorization: Bearer $BAXPAY_API_KEY"{
"success": true,
"data": {
"data": [{ "id": "9d2c6b0a-…", "eventId": "f017738f-…", "type": "…", "status": "succeeded" }],
"total": 1,
"page": 1,
"limit": 10
}
}Fixed a bug in your handler? Send the delivery again. The same payload goes to the same endpoint,
signed now, as a new delivery of the same event, so your deduplication on the payload id still works.
A resend is not retried automatically.
curl -X POST https://sandbox-api.baxpay.com/v1/deliveries/9d2c6b0a-…/resend \
-H "Authorization: Bearer $BAXPAY_API_KEY" \
-H "Idempotency-Key: $(uuidgen)"{
"success": true,
"data": { "id": "c41e07b9-…", "eventId": "f017738f-…", "status": "succeeded", "attempts": 1, "responseStatus": 200 }
}Delivery and retries
- Any answer other than
2xx(or a timeout) is a failure. Redirects are not followed. - A failed delivery is retried 5 times, after 1, 2, 4, 8 and 16 minutes.
- After the last failed retry the endpoint is switched to
FAILEDand receives nothing more until you resume it. A successful delivery resets the failure count. - Every attempt is in the event log, with what your endpoint answered.
Deliveries can arrive more than once: a retry after your handler already succeeded looks exactly
like a first attempt. Deduplicate on the payload's id (the same on every retry and on every
endpoint), and make your handler idempotent.
| Header | Value |
|---|---|
X-BaxPay-Webhook-Id | Which of your endpoints this delivery is for |
X-BaxPay-Event-Id | This delivery to this endpoint; stays the same across its retries |
When you're done with an endpoint, delete it:
curl -X DELETE https://sandbox-api.baxpay.com/v1/webhooks/5b0e7c1d-… \
-H "Authorization: Bearer $BAXPAY_API_KEY"