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:
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
| Event | When |
|---|---|
invoice.created | A draft is created. |
invoice.updated | An invoice is edited. |
invoice.issued | An invoice is numbered and issued. |
invoice.cancelled | An issued invoice is cancelled. |
verifactu.submitted | The record has been sent to the AEAT. |
verifactu.accepted | The AEAT accepted the record. |
verifactu.rejected | The AEAT rejected the record. |
verifactu.error | A technical processing error occurred. |
Ignore unknown types: adding new events is a backwards-compatible change.
Event structure
{
"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"
}
}
}idremains stable across retries and manual resends: use it for deduplication.livemodeseparates production from the test environment.createdis a Unix timestamp in seconds.data.objectis 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.
- Calculate the HMAC over the raw body, before parsing JSON.
- Compare it with
v1in constant time. - Reject timestamps outside a short tolerance, such as 300 seconds.
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
)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 Gonedisables the endpoint.- Delivery is at least once: deduplicate by
idand return2xxfor 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
idwhen needed.