Webhooks
The Simplo API's webhook contract — DTE lifecycle events, endpoint registration and Standard Webhooks signature verification. Delivery isn't live yet — use polling.
Webhooks push DTE lifecycle changes to your endpoint instead of you polling for them.
Events
Section titled “Events”| Event | Fires when |
|---|---|
dte.created |
A DTE was emitted |
dte.accepted |
The SII accepted the DTE |
dte.rejected |
The SII rejected the DTE |
dte.repaired |
The SII accepted the DTE with observations (con_reparos) |
caf.low_stock |
A CAF is running low on folios |
certificado.expiring |
The digital certificate is close to expiring |
API endpoints
Section titled “API endpoints”| Method | Path | Description |
|---|---|---|
GET |
/api/v1/billing/webhooks |
List registered webhooks. |
POST |
/api/v1/billing/webhooks |
Register an HTTPS URL to receive events. |
DELETE |
/api/v1/billing/webhooks/{id} |
Delete a webhook. |
These endpoints are session-only (your account’s JWT): a billing:dte
API key can’t call them (403 BILLING_API_KEY_ROUTE_NOT_ALLOWED) — webhooks
are managed from your account, not from the integration.
Register an endpoint
Section titled “Register an endpoint”const webhook = await simplo.webhooks.create({ url: 'https://tu-app.example/webhooks/simplo', // must be HTTPS events: ['dte.accepted', 'dte.rejected', 'caf.low_stock'],});
// The signing secret is returned ONLY here. Save it now.await secrets.store('SIMPLO_WEBHOOK_SECRET', webhook.secret!);simplo.webhooks.list() shows registered endpoints (secrets masked);
simplo.webhooks.delete(id) removes one.
Verify every delivery — always
Section titled “Verify every delivery — always”Anyone can POST to your endpoint. Verify every delivery before trusting it.
Delivery uses the
Standard Webhooks signature scheme —
this is the contract deliveries will carry once enabled, and the one
webhooks.verify implements:
| Header | Content |
|---|---|
webhook-id |
Unique message ID (stable across redeliveries) |
webhook-timestamp |
Unix timestamp (seconds) |
webhook-signature |
v1,<HMAC-SHA256 in base64> (space-separated list) |
The signature covers `${id}.${timestamp}.${rawBody}`. The SDK verifies
it with a constant-time comparison and a ±5 minute timestamp tolerance:
import Simplo, { WebhookVerificationError } from '@simplohq/sdk';import express from 'express';
const simplo = new Simplo();const app = express();
// IMPORTANT: the RAW body, not the parsed JSON.app.post('/webhooks/simplo', express.raw({ type: 'application/json' }), async (req, res) => { try { const event = await simplo.webhooks.verify( req.body.toString('utf8'), req.headers, process.env.SIMPLO_WEBHOOK_SECRET!, );
switch (event.type) { case 'dte.accepted': await marcarFacturaAceptada(event.data); break; case 'dte.rejected': await alertarFacturacion(event.data); break; } res.sendStatus(200); } catch (error) { if (error instanceof WebhookVerificationError) { res.sendStatus(401); // untrusted delivery — reject it return; } throw error; }});Also available as a standalone function (no client needed):
import { verifyWebhook } from '@simplohq/sdk';const event = await verifyWebhook(rawBody, headers, secret, { toleranceSeconds: 300 });Verification rules
Section titled “Verification rules”- Verify against the raw body. Re-serializing parsed JSON changes byte/whitespace order and the signature won’t match.
- Respond 2xx fast. Do slow work after responding (queue it). Non-2xx responses and timeouts get redelivered.
- Deliveries can arrive more than once. Use
webhook-id(or your own state keyed onevent.data.id) to deduplicate. - Order isn’t guaranteed. Treat each event as “something changed;
re-verify the state” when order matters — or read the authoritative
state with
dtes.retrieve.
Testing locally
Section titled “Testing locally”Compute a valid signature in your tests instead of hitting the network:
import { createHmac } from 'node:crypto';
const secret = 'test-secret';const id = 'msg_1';const timestamp = Math.floor(Date.now() / 1000);const payload = JSON.stringify({ type: 'dte.accepted', data: { id: 'dte_1' } });const signature = createHmac('sha256', secret) .update(`${id}.${timestamp}.${payload}`) .digest('base64');
const headers = { 'webhook-id': id, 'webhook-timestamp': String(timestamp), 'webhook-signature': `v1,${signature}`,};Related
Section titled “Related”- Errors —
WebhookVerificationError - Quickstart — polling as an alternative