Webhooks
How heru tells you that asynchronous work finished, and how to verify it came from heru.
Building Webhook delivery for the v1 routes is in development. The event bodies below are final, so you can build your handler today.
Register one HTTPS URL per environment. heru sends a POST with a JSON event every time a resource reaches a state you need to know about.
- Each event has a unique
id. Deliveries can repeat, so discard ids you have already processed. - Reply
2xxwithin 10 seconds and do the work asynchronously. Anything else counts as a failed delivery. - Failed deliveries are retried with exponential backoff for 24 hours.
datacarries the full resource, in the same shape as itsGET. You do not need a second call.
Events
| Event | Sent when |
|---|---|
| Fiscal validation | |
fiscal_validation.completed | A fiscal validation finished: with the profile, with a partial result, or with a code when the SAT answered there is no profile. |
fiscal_validation.failed | A fiscal validation could not finish after retries. |
| Social security (IMSS) | |
social_security_validation.completed | An NSS lookup or IMSS registration finished: with the NSS, or with a code when the IMSS answered there is none. |
social_security_validation.failed | An NSS lookup or registration could not finish after retries. |
| Workers | |
worker.updated | A worker’s consent, e.firma, CSD or status changed. |
signature.completed | The worker signed, rejected or let a document expire. |
| Invoices | |
invoice.stamped | An invoice was stamped. Batches send one per invoice, so you learn each result without waiting for the whole batch. |
invoice.cancelled | A cancellation was accepted. |
| Retention certificates | |
retention_certificate.stamped | A retention certificate was stamped. Batches send one per certificate. |
retention_certificate.cancelled | The SAT confirmed the cancellation of a retention certificate. |
| Batches | |
batch.done | A batch closed: with status done when every valid document has its outcome, or with status cancelled when it was cancelled after process, and what was pending was never processed and counts in cancelled. Its counts and report are final. |
| Point of sale | |
merchant.activated | The merchant uploaded a valid CSD and can invoice. |
merchant.csd_expiring | 30 days before the merchant’s CSD expires. |
charge.invoiced | The customer invoiced a charge. |
Examples
One example of each event, as heru delivers it to your URL. The examples use https://api.acme-delivery.example/v1/webhooks/compliance as the URL you registered: an endpoint of your own system.
Example events
Pick an event to see the request heru sends.
POST /v1/webhooks/compliance HTTP/1.1Host: api.acme-delivery.exampleContent-Type: application/jsonHeru-Event-Id: evt_01m4ra8x08eq7vth7a13qb5y8fHeru-Event-Type: fiscal_validation.completedHeru-Signature: t=1760281481,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd{ "id": "evt_01m4ra8x08eq7vth7a13qb5y8f", "type": "fiscal_validation.completed", "created_at": "2026-10-12T15:04:41.203Z", "data": { "id": "fv_01m4ra8vqve0xv4yfe92canf5a", "external_id": "FV-2026-004413", "status": "completed", "message": null, "pending": [], "result": { "curp": "HEGL900101HDFRRS06", "name": "JOSE LUIS HERNANDEZ GARCIA", "given_names": "JOSE LUIS", "first_surname": "HERNANDEZ", "second_surname": "GARCIA", "rfc": "HEGL900101K44", "rfc_valid": true, "rfc_active": true, "postal_code": "06600", "regimes": [ { "code": "625", "description": "Régimen de las Actividades Empresariales con ingresos a través de Plataformas Tecnológicas" } ], "efirma": { "active": true, "valid_from": "2024-03-02", "valid_to": "2028-03-02" }, "csd": { "active": false, "valid_from": null, "valid_to": null }, "third_party_invoicing": { "eligible": true, "reason": null }, "validation_url": "https://siat.sat.gob.mx/app/qr/faces/pages/mobile/validadorqr.jsf?D1=10&D2=1&D3=89097221992_HEGL900101K44" }, "created_at": "2026-10-12T15:04:05.115Z", "updated_at": "2026-10-12T15:04:41.203Z" }}Verify the signature
The Heru-Signature header is t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of t + "." + raw body, keyed with your webhook secret. Compute it over the raw bytes of the body, before any JSON parsing, and reject events older than five minutes.
import crypto from 'node:crypto';
export function verify(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`)
.digest('hex');
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}A minimal handler
import express from 'express';
import { verify } from './verify.js';
const app = express();
app.post('/v1/webhooks/compliance', express.raw({ type: 'application/json' }), async (req, res) => {
if (!verify(req.body.toString('utf8'), req.get('Heru-Signature') ?? '', process.env.HERU_WEBHOOK_SECRET)) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body);
if (await alreadyProcessed(event.id)) return res.sendStatus(200);
await queue.push(event); // process outside the request
res.sendStatus(200);
});Testing without a public URL
While your endpoint is not reachable from the internet, read the resource with GET: its status is the same one the event carries.