Firsty

Search...

Search...

Core concepts

Webhooks

Subscribe to activation and usage events.

Webhooks push lifecycle events to your backend so you don't have to poll.

Setting up an endpoint

Webhook endpoints are configured per client by Firsty. Sandbox accounts don't have self-serve webhook management yet. Get in touch with the HTTPS URL you want events delivered to and we'll wire it up with a signing secret.

Events

Event familyEvents
eSIM profileesim.assigned, esim.downloaded, esim.installed, esim.enabled, esim.disabled, esim.deleted, esim.mccswitch
Packagespackage.created, package.activated, package.usage, package.usage.threshold, package.throttled, package.exhausted, package.renewed, package.ended
Number port-inportin.submitted, portin.approved, portin.rejected, portin.completed, portin.failed, portin.cancelled

The ones most integrations care about first: esim.enabled (the user genuinely has working data) and package.usage.threshold (time to offer a top-up).

Verifying signatures

Deliveries are signed following the Standard Webhooks spec: an HMAC-SHA256 over {webhook-id}.{webhook-timestamp}.{raw-request-body}, sent in the webhook-signature header as v1,{base64}. Verify before trusting the payload. An unverified webhook endpoint is an unauthenticated write endpoint.

import { createHmac, timingSafeEqual } from "node:crypto";

function verify(headers: Headers, rawBody: string, secret: string): boolean {
  const id = headers.get("webhook-id");
  const timestamp = headers.get("webhook-timestamp");
  const header = headers.get("webhook-signature"); // "v1,{base64}"
  if (!id || !timestamp || !header) return false;

  // Reject stale deliveries to blunt replay attacks.
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  const expected = `v1,${createHmac("sha256", secret)
    .update(`${id}.${timestamp}.${rawBody}`)
    .digest("base64")}`;
  const a = Buffer.from(expected);

  // The header can carry more than one signature, space separated, so a
  // secret rotation doesn't drop deliveries. Any match is a pass.
  return header.split(" ").some((candidate) => {
    const b = Buffer.from(candidate);
    return a.length === b.length && timingSafeEqual(a, b);
  });
}

Use the raw request body. Parsing to JSON and re-serialising changes key order and whitespace, and the signature will never match.

Delivery

Respond 2xx within 30 seconds and do the real work asynchronously. A 5xx, a 408, a timeout or a connection failure is retried; nothing else is. So don't answer 400 to an event you can't process yet, because that delivery will not come back.

Delivery is attempted up to ten times over roughly 24 hours, backing off from 30 seconds to 8 hours. Events are retained for 7 days, and support can replay anything inside that window.

Delivery is at-least-once and ordering is best-effort, so the same event can arrive twice, and package.activated can land before esim.enabled. Make the handler idempotent by keying it on the webhook-id header, and treat each event on its own rather than assuming a sequence.