docs

Webhooks

Webhooks are heliumOS's outbound async channel. Register an HTTPS endpoint in the dashboard, tell us which events you care about, and we'll POST each event to you. Deliveries are signed, retried, and replayable.

Registration

Webhook endpoints are registered from the operator dashboard, not via the API. Operator API keys can't mint or delete endpoints — that's a deliberate security boundary so a compromised integration key can't redirect your event stream.

In the dashboard:

  1. Open Settings → Webhook endpoints in the operator workspace.
  2. Click Add endpoint, paste your receive URL, and select the events you want (subscription.*, sim.*, etc.).
  3. Save. The dashboard shows the signing secret (whsec_...) once — copy it into your backend's secret store.

Endpoints are scoped to the dashboard's currently-selected environment. Your sandbox endpoint and live endpoint are separate records with separate signing secrets.

URL requirements

Your receive URL must be https and must resolve to a public address — in sandbox as well as live. Endpoints on http, on a loopback or private address (http://localhost:4000, https://10.0.0.5/hook), on an internal hostname (.internal, .local, a bare service name), or with credentials in the URL (https://user:pass@host/) are refused when you save them, with a message saying which rule applied.

The same check runs again immediately before every delivery, so an endpoint whose DNS record later moves to a private address stops receiving events rather than being delivered to. When that happens the delivery is recorded as failed on the endpoint's deliveries list with the reason, so it's visible without contacting support.

Developing against a receiver on your own machine? Put a tunnel (ngrok, cloudflared) in front of it and register the tunnel's https URL.

Signature verification

Every delivery carries Platform-Signature: t=<ts>,v1=<hex>. The signed payload is <ts>.<raw-body>, HMAC-SHA256, keyed on the signing secret. Reject requests where:

  • The timestamp is older than 5 minutes (replay protection).
  • The computed signature doesn't match v1.
  • The event's livemode doesn't match the endpoint's mode.
import { createHmac, timingSafeEqual } from "node:crypto";

function verify(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(
  header.split(",").map((p) => p.split("=")),
);
const t = Number(parts.t);
if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = createHmac("sha256", secret)
  .update(`${t}.${rawBody}`)
  .digest("hex");
return timingSafeEqual(
  Buffer.from(expected, "hex"),
  Buffer.from(parts.v1, "hex"),
);
}

Retries & dead-letter

Failed deliveries retry with exponential backoff (30s, 2m, 10m, 30m, 1h, 2h, 6h, 12h, 24h — 9 total attempts over ~48h). After the ninth, the event lands in the dead-letter queue, viewable in the dashboard's event stream and replayable with one click — there is no operator-key API for replay, replay is a dashboard-only action.

Ordering

Delivery is not ordered. If two events land milliseconds apart, they may be delivered out of order. Keep your handlers idempotent and resolve state from the event payload rather than assumed sequence.