# 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 /webhooks` — register (secret returned once)
- `GET /webhooks` — list this mode's endpoints
- `GET /webhooks/{id}` — retrieve
- `PUT /webhooks/{id}` — edit URL, events, description; pause or resume
- `POST /webhooks/{id}/roll-secret` — new signing secret, returned once
- `POST /webhooks/{id}/test` — send a signed webhook.test now
- `DELETE /webhooks/{id}` — delete

```bash
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"]
  }'
```

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

```json
{
  "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](#events); anything else is refused with `400`.

> **The signing secret is shown once**
> Store `secret` now: it is never returned again. If you lose it, delete the endpoint and register a
> new one.

Send yourself a signed `webhook.test` event to check your endpoint end to end:

```bash
curl -X POST https://sandbox-api.baxpay.com/v1/webhooks/5b0e7c1d-…/test \
  -H "Authorization: Bearer $BAXPAY_API_KEY"
```

```json
{ "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.

```bash
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" }'
```

```json
{ "success": true, "data": { "id": "5b0e7c1d-…", "status": "INACTIVE" } }
```

```bash
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:

```bash
curl -X POST https://sandbox-api.baxpay.com/v1/webhooks/5b0e7c1d-…/roll-secret \
  -H "Authorization: Bearer $BAXPAY_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
```

```json
{ "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.

> **Verify before you trust — and compare in constant time**
> Your webhook URL is public: anyone can POST to it. The signature is the only thing separating a
> real BaxPay event from a forged one that says a €10,000 payment succeeded. Verify it on every
> request, and use a constant-time comparison — a plain `===` leaks the correct signature one byte
> at a time through timing.

```js
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);
}
```

```python
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:

```js
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.

```json
{
  "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](https://developers.baxpay.com/payment-links) 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](https://developers.baxpay.com/terminal-api) |
| `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](https://developers.baxpay.com/einvoicing-paylinks) 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**.

- `GET /events` — this mode's events, newest first; filter by type, from, to
- `GET /events/{id}` — one event, with its deliveries
- `GET /deliveries` — deliveries, newest first; filter by webhookId
- `POST /deliveries/{id}/resend` — send 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:

```bash
curl "https://sandbox-api.baxpay.com/v1/events?type=payment_link.paid&limit=5" \
  -H "Authorization: Bearer $BAXPAY_API_KEY"
```

```json
{
  "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.

```bash
curl https://sandbox-api.baxpay.com/v1/events/f017738f-… \
  -H "Authorization: Bearer $BAXPAY_API_KEY"
```

```json
{
  "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:

```bash
curl "https://sandbox-api.baxpay.com/v1/deliveries?webhookId=a3f2e8d1-…&limit=10" \
  -H "Authorization: Bearer $BAXPAY_API_KEY"
```

```json
{
  "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.

```bash
curl -X POST https://sandbox-api.baxpay.com/v1/deliveries/9d2c6b0a-…/resend \
  -H "Authorization: Bearer $BAXPAY_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
```

```json
{
  "success": true,
  "data": { "id": "c41e07b9-…", "eventId": "f017738f-…", "status": "succeeded", "attempts": 1, "responseStatus": 200 }
}
```

## Delivery and retries

> **Acknowledge fast, work later**
> Answer `2xx` as soon as you've verified the signature and queued the event, then do the real work
> asynchronously. BaxPay waits at most **10 seconds** for your answer.

- 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](#pause-resume-and-roll-the-secret). A successful delivery resets the failure count.
- Every attempt is in the [event log](#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:

```bash
curl -X DELETE https://sandbox-api.baxpay.com/v1/webhooks/5b0e7c1d-… \
  -H "Authorization: Bearer $BAXPAY_API_KEY"
```
