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, localhost and 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 301 is 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.

¿Algo incorrecto o ausente? Escríbanos.