Webhooks
A webhook is an HTTPS address of yours that Hela calls when an event happens in the company. It is how you react in real time without polling the API.
Creating a webhook
In Administration → Developers → New webhook, or through the API:
POST /companies/{companyId}/webhooks
{
"url": "https://example.com/hooks/hela",
"events": ["invoice.paid", "payment.received", "stock.low"],
"description": "Internal ERP"
}
→ { "id": "…", "secret": "whsec_…", … }
The secret is returned only at creation and rotation. Keep it: it verifies that deliveries really come from Hela.
Events are written aggregate.action. Three forms are accepted:
- an exact type:
invoice.paid; - a family:
invoice.*; - everything:
*.
The event catalogue lists all that exists.
What you receive
A POST request with a JSON body:
{
"id": "evt_48213",
"type": "invoice.paid",
"createdAt": "2026-09-28T14:03:12.000Z",
"companyId": "4c1d…",
"object": "sale",
"objectId": "9f3a…",
"data": {
"id": "9f3a…",
"number": "ERNS-SALE-260928-0012",
"total": { "amount": 12500, "currency": "USD" },
"paid": { "amount": 12500, "currency": "USD" },
"partner": { "id": "…", "name": "Marie Kabila" },
"paidAt": "2026-09-28T14:03:11.000Z"
}
}
data is a stable view of the document, not the database row: a field may be added, never removed. If you need the whole document, call the API with objectId.
And the headers:
| Header | Content |
|---|---|
Content-Type |
application/json |
Hela-Event-Type |
The type (invoice.paid) |
Hela-Event-Id |
The event's numeric id, the same on every attempt |
Hela-Delivery-Attempt |
The attempt number, 1 for the first |
Hela-Signature |
t=<unix seconds>,v1=<hex hmac> |
User-Agent |
Hela-Webhooks/1.0 |
Verifying the signature
The signature is an HMAC-SHA256, with the webhook's secret, of the string "<t>.<raw body>". Verify it on the raw body as received, before any parsing; refuse if the timestamp is more than five minutes off.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret, rawBody, header, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
const t = Number(parts.t);
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const given = Buffer.from(parts.v1 ?? "", "hex");
return given.length === 32 && timingSafeEqual(given, Buffer.from(expected, "hex"));
}
After a secret rotation, following deliveries are signed with the new one: update your server before rotating, or accept both secrets for the switch. The SDK provides verifyWebhook().
Answering
Answer 2xx within 15 seconds. Anything else — a 4xx, a 5xx, a timeout, a connection error — is a failure and will be retried. Do the work after answering, not before: put the event in your own queue and answer 200 right away.
Retries
After a failure, Hela retries at 1 minute, 5 minutes, 30 minutes, 2 hours, 12 hours, then 24 hours. On the seventh consecutive failure the delivery is marked exhausted and the webhook is disabled with the reason; the company owner gets an integration.failed notification. Re-enabling is one button, and missed deliveries replay one by one from the page.
Every attempt carries the same event id and an incrementing Hela-Delivery-Attempt. An idempotent consumer simply remembers the ids already handled.
Order and duplicates
Events of one document are emitted in order, but deliveries can arrive out of order when one of them is retried. Read createdAt, or call the API for the current state, rather than assuming the last received is the last that happened.
An event may exceptionally be delivered twice (a 200 that never made it back to us). Deduplicate on id.
Security
- HTTPS required.
http://addresses, private IPs,localhostand names resolving to a private network are refused at creation (webhook-url:private-address) and re-checked on every delivery. - Redirects are not followed: a
301is a failure. - The secret is never returned by
GET. If lost, rotate it (POST /webhooks/{id}/rotate).
Testing locally
Hela cannot reach your machine. Expose it with a tunnel (cloudflared tunnel --url http://localhost:3000, ngrok http 3000) and use the public address. The Test button sends an immediate ping event:
{ "id": "evt_test", "type": "ping", "companyId": "…", "data": { "at": "2026-09-28T14:00:00.000Z" } }
Seeing what happened
The Developers page lists, per webhook, the last fifty deliveries: date, event, HTTP status, duration, attempt, error. Each row has a Resend button. Deliveries are kept thirty days; the events themselves, ninety.