Skip to content
BaxPayDevelopers

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.

POST/webhooksregister (secret returned once)
GET/webhookslist this mode's endpoints
GET/webhooks/{id}retrieve
PUT/webhooks/{id}edit URL, events, description; pause or resume
POST/webhooks/{id}/roll-secretnew signing secret, returned once
POST/webhooks/{id}/testsend a signed webhook.test now
DEL/webhooks/{id}delete
curl -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"]
  }'
{
  "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:

HeaderValue
X-BaxPay-Signature-V2t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>"> — verify this one
X-BaxPay-Event-IdThis delivery to this endpoint; the same on its retries
X-BaxPay-TimestampThe same t, on its own
X-BaxPay-SignatureLegacy: 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);
}

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"
}
EventWhendata
payment_link.paidA payment link was paidThe link: paymentLinkId, amount, currency, reference, method, transactionRef, …
payment_session.preauthorizedA fuel pre-authorisation was approved: funds held, capture nextThe session view
payment_session.capturedA session captured: money movedThe session view
payment_session.declinedA session was declinedThe session view
payment_session.voidedA session was voided, or a fuel pre-auth releasedThe session view
payment_session.expiredA session was not completed within 3 minutesThe session view
einvoice.paidAn e-invoice was paid through its linkinvoiceId, invoiceNumber, amount, currency, …
einvoice.status_changedAn e-invoice moved in its legal lifecycleinvoiceId, the new status and its source
webhook.testYou asked for a test deliveryA 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.

GET/eventsthis mode's events, newest first; filter by type, from, to
GET/events/{id}one event, with its deliveries
GET/deliveriesdeliveries, newest first; filter by webhookId
POST/deliveries/{id}/resendsend a delivery again

List 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
      }
    ]
  }
}
statusMeaning
succeededYour endpoint answered 2xx
pendingIt failed; a retry is scheduled at nextRetryAt
failedIt 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 FAILED and 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.

HeaderValue
X-BaxPay-Webhook-IdWhich of your endpoints this delivery is for
X-BaxPay-Event-IdThis 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"