Skip to main content
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

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:
{ "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:
HeaderValue
Webhook-IdThe event id (evt_...) — also the idempotency key for your handler.
Webhook-TimestampUnix seconds when the delivery was signed.
Webhook-Signaturev1=<hex> (space-separated list allows multiple v1= during rotation).
Body (the same object as GET /v1/events/:id):
{ "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

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

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 *.
NamespaceTypes
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

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"