> ## Documentation Index
> Fetch the complete documentation index at: https://invoiceapi.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> The API POSTs a signed JSON event to your endpoint whenever something happens (an invoice is

The API POSTs a signed JSON event to your endpoint whenever something happens (an invoice is
stamped, a cancellation resolves, a download is ready…). Deliveries retry with exponential backoff
until your endpoint returns a 2xx.

## Register an endpoint

```bash theme={null}
curl -s -X POST localhost:3000/v1/webhook_endpoints \
  -H "authorization: Bearer $API_KEY" -H 'content-type: application/json' \
  -d '{ "url": "https://you.example/hook", "enabled_events": ["invoice.stamped","invoice.cancelled"] }'
```

The response includes the signing secret **exactly once**:

```json theme={null}
{ "object": "webhook_endpoint", "id": "we_...", "url": "https://you.example/hook",
  "enabled_events": ["invoice.stamped","invoice.cancelled"], "enabled": true,
  "livemode": false, "created_at": "...", "updated_at": "...", "secret": "whsec_..." }
```

Store `whsec_...` securely — it is never shown again (rotate by creating a new endpoint). In
**live mode the URL must be https**. `enabled_events` entries may be `"*"`, an exact type, or a
namespace wildcard like `"invoice.*"`.

## The delivery

Headers:

| Header              | Value                                                                    |
| ------------------- | ------------------------------------------------------------------------ |
| `Webhook-Id`        | The event id (`evt_...`) — also the idempotency key for your handler.    |
| `Webhook-Timestamp` | Unix seconds when the delivery was signed.                               |
| `Webhook-Signature` | `v1=<hex>` (space-separated list allows multiple `v1=` during rotation). |

Body (the same object as `GET /v1/events/:id`):

```json theme={null}
{ "id": "evt_...", "type": "invoice.stamped", "created": "2026-07-10T04:09:28.700Z",
  "data": { "id": "inv_..." }, "livemode": false }
```

## Verifying the signature

The signature is `HMAC-SHA256` over the string `` `${id}.${timestamp}.${rawBody}` `` using your raw
`whsec_...` secret, hex-encoded. **Verify over the exact raw request body bytes** — do not
re-serialize the parsed JSON. Reject deliveries whose timestamp is outside a tolerance window
(default 300s; see `WEBHOOK_TOLERANCE_SECS`) to blunt replays.

### Node.js

```js theme={null}
import crypto from 'node:crypto'

// `rawBody` MUST be the exact bytes received (e.g. Buffer). With Express:
//   app.use('/hook', express.raw({ type: 'application/json' }))
export function verify(req, secret, toleranceSecs = 300) {
  const id = req.headers['webhook-id']
  const ts = req.headers['webhook-timestamp']
  const header = req.headers['webhook-signature'] || ''
  const rawBody = req.body // a Buffer/string of the raw payload

  if (Math.abs(Date.now() / 1000 - Number(ts)) > toleranceSecs) return false

  const signed = `${id}.${ts}.${rawBody}`
  const expected = crypto.createHmac('sha256', secret).update(signed).digest('hex')

  // header may carry several "v1=<hex>" tokens (key rotation); accept any constant-time match.
  return header.split(' ').some((tok) => {
    const [scheme, sig] = tok.split('=')
    if (scheme !== 'v1' || !sig || sig.length !== expected.length) return false
    return crypto.timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expected, 'hex'))
  })
}
```

### Python

```python theme={null}
import hashlib, hmac, time

def verify(headers, raw_body: bytes, secret: str, tolerance_secs: int = 300) -> bool:
    msg_id = headers["Webhook-Id"]
    ts = headers["Webhook-Timestamp"]
    sig_header = headers.get("Webhook-Signature", "")

    if abs(time.time() - int(ts)) > tolerance_secs:
        return False

    signed = f"{msg_id}.{ts}.{raw_body.decode()}".encode()
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()

    for token in sig_header.split(" "):
        scheme, _, sig = token.partition("=")
        if scheme == "v1" and hmac.compare_digest(sig, expected):
            return True
    return False
```

Return `2xx` fast (do the real work async). Any non-2xx or a timeout (10s) is treated as a failure
and the delivery is retried with exponential backoff on its own schedule.

## Events

This list is the complete set of event types, generated from and verified against `EVENT_TYPES`
in `src/core/events.ts` — subscribe to any of them exactly, by namespace wildcard (`invoice.*`), or
with `*`.

| Namespace          | Types                                                                                  |
| ------------------ | -------------------------------------------------------------------------------------- |
| `fiscal_profile.*` | `created`, `updated`, `deleted`, `manifest_signed`                                     |
| `invoice.*`        | `created`, `stamped`, `stamp_failed`, `cancel_pending`, `cancelled`, `cancel_rejected` |
| `download.*`       | `created`, `ready`, `no_data`, `failed`                                                |

`fiscal_profile.manifest_signed` fires when you sign a manifiesto de cuentas
(`POST /v1/fiscal_profiles/:id/manifest`); `download.created` fires when a descarga masiva job is
accepted; `download.no_data` fires when the SAT confirms a request found zero CFDIs in range.

Each event is also readable at `GET /v1/events/:id` and listable at `GET /v1/events` — the event
log is the source of truth deliveries replay from, so a missed webhook can always be reconciled by
polling events. Endpoints only receive events emitted **after** they were created.

## Managing endpoints

```bash theme={null}
curl -s localhost:3000/v1/webhook_endpoints -H "authorization: Bearer $API_KEY" | jq
curl -s -X PATCH localhost:3000/v1/webhook_endpoints/$WE_ID \
  -H "authorization: Bearer $API_KEY" -H 'content-type: application/json' \
  -d '{ "disabled": true }'
curl -s -X DELETE localhost:3000/v1/webhook_endpoints/$WE_ID -H "authorization: Bearer $API_KEY"
```
