Skip to content

Webhooks ​

Bilteo reports relevant events by sending POST requests to your URL. This is the recommended way to close the asynchronous Veri*Factu loop: issue an invoice, then receive verifactu.accepted or verifactu.rejected when the AEAT responds.

Concepts ​

  • Endpoint: your receiving https:// URL.
  • Event: something that happened, such as invoice.issued.
  • Delivery: each attempt to send an event.
  • Secret: key Bilteo uses to sign every delivery.

Configure an endpoint ​

Manage endpoints in the Bilteo dashboard or through the public API. With an API key or OAuth2 token carrying webhooks:write:

bash
curl -X POST https://api.test.bilteo.com/api/v1/webhook-endpoints \
  -H "Authorization: Bearer bk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com/webhooks","description":"My integration","events":["invoice.issued","verifactu.*"]}'

The response includes the signing secret; store it securely. GET /webhook-endpoints and GET /webhook-endpoints/{id} require webhooks:read. PATCH /webhook-endpoints/{id} and DELETE /webhook-endpoints/{id} require webhooks:write. Lists use limit and cursor; read and update responses omit the secret. Subscribe to an exact type, invoice.* / verifactu.*, or the global * wildcard.

The secret remains visible in the dashboard. The public API can rotate it with POST /webhook-endpoints/{id}/actions/roll-secret (webhooks:write), which returns the new value. Rotation immediately invalidates the previous value, and pending deliveries use the new secret.

Available events ​

EventWhen
invoice.createdA draft is created.
invoice.updatedAn invoice is edited.
invoice.issuedAn invoice is numbered and issued.
invoice.cancelledAn issued invoice is cancelled.
verifactu.submittedThe record has been sent to the AEAT.
verifactu.acceptedThe AEAT accepted the record.
verifactu.rejectedThe AEAT rejected the record.
verifactu.errorA technical processing error occurred.

Ignore unknown types: adding new events is a backwards-compatible change.

Event structure ​

json
{
  "id": "evt_0192f3a4-...",
  "type": "verifactu.accepted",
  "created": 1716120000,
  "tenant_id": "0192...",
  "livemode": true,
  "api_version": "v1",
  "data": {
    "object": {
      "id": "0192...",
      "invoice_id": "0192...",
      "number": 42,
      "state": "accepted",
      "operation_type": "registration",
      "hash": "A1B2C3...",
      "sent_at": "2026-05-19T10:00:03Z"
    }
  }
}
  • id remains stable across retries and manual resends: use it for deduplication.
  • livemode separates production from the test environment.
  • created is a Unix timestamp in seconds.
  • data.object is a summary; retrieve the full resource from the API.

invoice.* objects contain identifiers, type, state, currency, total, number, and customer when available. verifactu.* objects contain record, invoice, state, operation, fingerprints, batch, and AEAT response fields.

Verifactu Direct API

verifactu.* also covers /external-invoices. In that case invoice_id is null; correlate using the object's id, which is the verifactu_record_id returned when registering the external invoice.

The dashboard test button sends webhook.test to verify receipt and signature handling. It does not represent a business operation.

Headers ​

Content-Type: application/json
User-Agent: Bilteo-Webhooks/1
Bilteo-Event-Id: evt_0192f3a4-...
Bilteo-Event-Type: verifactu.accepted
Bilteo-Signature: t=1716120000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e...

Verify the signature ​

v1 = HMAC_SHA256(secret, "<t>.<raw_body>"), encoded as hexadecimal.

  1. Calculate the HMAC over the raw body, before parsing JSON.
  2. Compare it with v1 in constant time.
  3. Reject timestamps outside a short tolerance, such as 300 seconds.
python
import hashlib, hmac, time

def verify(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    timestamp = int(parts["t"])
    signed = f"{timestamp}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return (
        hmac.compare_digest(expected, parts.get("v1", ""))
        and abs(time.time() - timestamp) <= tolerance
    )
js
const crypto = require('crypto')

function verify(secret, header, rawBody, toleranceSec = 300) {
  const parts = Object.fromEntries(header.split(',').map((kv) => kv.split('=')))
  const timestamp = Number(parts.t)
  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex')
  const a = Buffer.from(expected)
  const b = Buffer.from(parts.v1 || '')
  return (
    a.length === b.length &&
    crypto.timingSafeEqual(a, b) &&
    Math.abs(Date.now() / 1000 - timestamp) <= toleranceSec
  )
}

Return 2xx as soon as you have verified and accepted the event; process heavy work asynchronously.

Retries and duplicates ​

  • Without 2xx, Bilteo retries after 1m, 5m, 30m, 2h, 6h, 12h, then every 24h, up to roughly ten attempts.
  • 410 Gone disables the endpoint.
  • Delivery is at least once: deduplicate by id and return 2xx for repeats.
  • Closed delivery history is purged after roughly 90 days; do not use it as an audit log.

Always verify the signature

Treat the body as untrusted until Bilteo-Signature has been validated.

Best practices ​

  • Verify the signature and timestamp.
  • Deduplicate by evt_….
  • Respond quickly and process asynchronously.
  • Ignore unknown event types.
  • Separate test and production using livemode.
  • Retrieve full resources by id when needed.

Bilteo · Documentation