# Overview

Receive real-time notifications in your application when events happen in BeeL.

Webhooks let your application receive HTTP notifications the moment something happens in BeeL. — no polling required. When an event occurs (e.g. AEAT processes a VeriFactu submission), BeeL. sends an HTTP `POST` to the URL you registered with a signed JSON payload.

<Callout type="info">
  **API plan exclusive:** Webhooks are available only with the API plan. See [Pricing](/pricing) for details.
</Callout>

## Scopes

Every webhook endpoint is reachable with an API key — none of them needs a dashboard
session. Two scopes gate them, `webhooks:read` for looking and `webhooks:write` for
changing; each [webhook operation](/webhooks/listAccountWebhookSubscriptions) states the one it
requires, and you grant them to your key like any other [scope](/auth/scopes).

<Callout type="warn">
  **`webhooks:write` covers rotating the secret.** A key that can create subscriptions
  can also invalidate the secret every existing receiver verifies against. If you issue
  keys to third parties, that is the reason to keep webhook management on a separate
  key from the one that issues invoices.
</Callout>

## Quick start

<Steps>

<Step>

### Create a webhook subscription

Requires **`webhooks:write`**.

```bash
curl -X POST "https://app.beel.es/api/v1/accounts/{account_id}/webhooks" \
  -H "Authorization: Bearer $BEEL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://yourapp.com/webhooks/beel",
    "events": ["invoice.issued", "invoice.email.sent", "invoice.voided", "verifactu.status.updated"]
  }'
```

The response contains a `secret` field — **copy it now**. It will not be shown again.

```json
{
  "success": true,
  "data": {
    "id": "3f7a1b2c-4d5e-6f7a-8b9c-0d1e2f3a4b5c",
    "url": "https://yourapp.com/webhooks/beel",
    "events": ["invoice.issued", "invoice.email.sent", "invoice.voided", "verifactu.status.updated"],
    "active": true,
    "account_relationship": "own",
    "secret": "whsec_a3f5b2e1c9d8f7e6b5a4c3d2e1f0a9b8c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2",
    "consecutive_failures": 0,
    "created_at": "2026-03-08T10:00:00Z",
    "test_delivery": {
      "delivery_success": false,
      "http_status": null,
      "duration_ms": 3001,
      "error": "Connection timed out",
      "failure_cause": "timeout"
    }
  }
}
```

**`test_delivery`** is a short signed probe BeeL. sends to your URL while creating the
subscription, so the same call tells you whether your endpoint answers. It never blocks
the registration: the subscription is created and active whatever the probe says, and
`test_delivery` is `null` when the probe could not run at all. A failure usually just means
you registered the URL before deploying the endpoint. Branch on `failure_cause`, not on the
wording of `error` — the values are the same as in
[`last_error_cause`](/webhooks/retries#reading-the-health-fields). Once the endpoint is live,
run a full [test](#test-your-endpoint).

<Callout type="warn">
  **The URL must be reachable from the internet.** A URL whose host resolves to a private,
  loopback or otherwise internal address is rejected with
  `422` [`URL_TARGET_NOT_ALLOWED`](/errors/URL_TARGET_NOT_ALLOWED), on creation and whenever you
  change the URL. A URL that is not `https://` is rejected with
  [`URL_MUST_BE_HTTPS`](/errors/URL_MUST_BE_HTTPS). To receive events on your own machine
  while you develop, expose the local server through a public HTTPS tunnel and register
  the tunnel's URL; a `localhost` URL and private addresses are rejected like any other.
</Callout>

<Callout type="info">
  **Managing accounts on someone's behalf?** Add `"account_relationship": "managed"`
  (or `"all"`) to also receive events from the accounts you provisioned. It defaults to
  `own`. See [Events](/webhooks/events#envelope-structure) for how to route them on
  arrival.
</Callout>

<Callout type="warn">
  **Store the secret securely!** It's only shown once and required to verify webhook signatures.
</Callout>

</Step>

<Step>

### Handle incoming requests

Your endpoint must:

- Accept `POST` requests with `Content-Type: application/json`
- [Verify the signature](/webhooks/signatures) using the `BeeL-Signature` header
- Return `HTTP 2xx` within **10 seconds**

```js
// Express.js example
app.post('/webhooks/beel', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['beel-signature'];

  if (!verifySignature(req.body, signature, process.env.BEEL_WEBHOOK_SECRET)) {
    return res.status(401).send('Invalid signature');
  }

  const event = JSON.parse(req.body);

  switch (event.type) {
    case 'invoice.issued':
      handleNewInvoice(event.data);
      break;
    case 'invoice.email.sent':
      handleEmailSent(event.data);
      break;
    case 'invoice.voided':
      handleVoided(event.data);
      break;
    case 'verifactu.status.updated':
      handleVeriFactuUpdate(event.data);
      break;
  }

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

</Step>

<Step>

### Test your endpoint

`POST /v1/accounts/{account_id}/webhooks/{webhook_id}/test` — **`webhooks:write`**. Fires a synthetic,
fully signed payload to your endpoint immediately, apart from the normal deliveries.

```bash
curl -X POST "https://app.beel.es/api/v1/accounts/{account_id}/webhooks/3f7a1b2c-4d5e-6f7a-8b9c-0d1e2f3a4b5c/test" \
  -H "Authorization: Bearer $BEEL_API_KEY"
```

```json
{
  "success": true,
  "data": {
    "delivery_success": true,
    "http_status": 200,
    "duration_ms": 142,
    "error": null
  }
}
```

The call answers `200` whenever the test **ran** — branch on `delivery_success` for the
outcome, not on the HTTP status. A delivery your endpoint rejected is a successful test
run, not an error. Test events are not retried and do not appear in the delivery
history.

The same thing is available as the **Send test event** button in the BeeL. dashboard
(Developers → Webhooks → your webhook).

<Callout type="info">
  **Don't have an endpoint yet?** Use [webhook.site](https://webhook.site) to get a free temporary URL that captures and displays incoming requests. It's a great way to inspect webhook payloads and verify that events are being delivered correctly before building your handler.
</Callout>

---

</Step>

</Steps>

## Payload structure

Every webhook event shares the same envelope, with a `data` block that depends on the event type. The envelope, field by field, is in [Events](/webhooks/events#envelope-structure).

Every delivery carries the same envelope fields: `id`, `type`, `created_at`, `api_version`, `livemode`, `test`, `company_id`, `nif`, `account_id`, `account_external_ref`, `account_relationship`.

Two of them decide how you write your handler:

- **`id` is stable across every retry attempt** of the same logical event, so it is the
  key to deduplicate on — see [Deduplication](/webhooks/deduplication).
- **`company_id` is what you route on.** One endpoint receives the events of every NIF
  you operate; `null` simply means the event is not about a single company.

---

## Limits

| Parameter | Value |
|-----------|-------|
| Max active subscriptions per account | **10** — paused or turned-off ones do not count. Creating an 11th, or turning a paused one back on when 10 are active, is rejected with `400` [`WEBHOOK_ACTIVE_SUBSCRIPTION_LIMIT_REACHED`](/errors/WEBHOOK_ACTIVE_SUBSCRIPTION_LIMIT_REACHED) |
| Required URL scheme | `https://` |
| Delivery log history | Every attempt, paginated — see [Delivery logs](/webhooks/retries#delivery-logs) |

Delivery timeouts and attempt counts are part of the retry policy — see
[Retries](/webhooks/retries#retry-policy). An endpoint that keeps failing is paused
automatically — see [Endpoint health](/webhooks/retries#endpoint-health-and-automatic-pause).

---

## Headers sent on every delivery

| Header | Description |
|--------|-------------|
| `Content-Type` | `application/json` |
| `BeeL-Signature` | HMAC-SHA256 signature — see [Signatures](/webhooks/signatures) |
| `BeeL-Event` | Event type (e.g. `invoice.issued`) |
| `BeeL-Event-Id` | UUID identifying this logical event. **Identical across all retry attempts.** Matches the `id` field in the payload. |
| `BeeL-Delivery-Id` | UUID unique to this specific delivery attempt. Matches the delivery log `id` in the dashboard. |
| `Idempotency-Key` | Same value as `BeeL-Event-Id` — for automatic deduplication by frameworks |
| `User-Agent` | `BeeL-Webhooks` |

These are the only headers BeeL. sets. Apart from the ones HTTP itself adds (`Host`,
`Content-Length`, `Accept`), a delivery carries nothing else.

---

## Using the official SDK

The [Node.js SDK](/sdks/node) includes built-in webhook verification:

```ts
import { WebhookVerifier } from '@beel_es/sdk';

const verifier = new WebhookVerifier(process.env.BEEL_WEBHOOK_SECRET);

app.post('/webhooks/beel', express.raw({ type: 'application/json' }), (req, res) => {
  try {
    const event = verifier.verify(
      req.body.toString('utf8'),
      req.headers['beel-signature']
    );
    
    // Event is verified and typed
    if (event.type === 'verifactu.status.updated') {
      console.log('Invoice:', event.data.invoice_number);
      console.log('Status:', event.data.new_status);
    }
    
    res.status(200).send('OK');
  } catch (error) {
    res.status(400).send('Invalid signature');
  }
});
```

See the [SDKs documentation](/sdks) for more details.

---

## Related

<Related>

- [Events](/webhooks/events) — available event types and their data payloads
- [Signatures](/webhooks/signatures) — verify that requests come from BeeL
- [Retries](/webhooks/retries) — how failed deliveries are retried, and when an endpoint is paused
- [Deduplication](/webhooks/deduplication) — safely handle duplicate deliveries

</Related>

Questions? Email us at [it@beel.es](mailto:it@beel.es).

---

Full OpenAPI spec: https://docs.beel.es/api/openapi