$ feature / webhooks

Real-time HTTP for license events.

Every license lifecycle action emits a webhook. Configure an endpoint per app (or several), receive signed JSON payloads in real time, and pipe the events wherever you want: a Slack channel for support visibility, a Postgres table for analytics, your own backend for fulfilling licenses against orders, or a Discord bot for "new sale" pings.

Event catalog

license.created

A new license has been issued (manually from the dashboard, programmatically through the developer API, or automatically by a Stripe / Lemon Squeezy commerce flow). Payload includes the license key, the issuing app id, and any metadata you attached at creation time.

license.validated

A successful validation has been processed. Use this for analytics ('how many active users do I have today'), session monitoring, or to push a row into your own data warehouse. Failed validations are not delivered as events.

license.revoked

A license has been revoked, either manually, through a refund event, or by the self-ban flow. Use this to lock the customer out of any additional services your backend exposes.

license.activated

A license has just been bound to its first HWID. Useful for first-launch analytics ('this user actually opened the app') or for sending a welcome email with onboarding tips.

license.hwid_bound

A new HWID has been added to a license's seat list (filling slot N of M). The payload includes the slot index, the new HWID hash, and the source IP that bound it.

license.hwid_reset

All HWIDs have been cleared from a license. Useful for audit trails (who reset what, when) and for triggering a tier-2 review if the same license is reset frequently.

license.deleted

A license has been permanently deleted. This is irreversible: the key cannot be reactivated or rebound after this event fires.

license.offline_file_minted

An offline .authforge license file was minted. Payload includes the license key, file id (jti), expiry, HWID policy, signing key id, file SHA-256, and source (dashboard or developer-api). The armored file, payload, and signature are never included: the server does not store or re-emit the file body. Idempotent replays of the same mint do not fire this event.

Signed payloads (HMAC-SHA256)

Each delivery includes an X-AuthForge-Signature-V2 header of the form t=<unix seconds>,v1=<hex>, where the hex value is the HMAC-SHA256 of t, a dot, and the raw request body, keyed by your webhook secret. Compute the same HMAC on your side, do a constant-time compare, and reject anything that doesn't match or whose t is more than five minutes old. This stops anyone who finds your webhook URL from forging an event, and anyone who captures a delivery from replaying it later. Each event also has a stable id in the JSON body and an X-AuthForge-Event-Id header (evt_ plus hex). Retries send that same id and only change t, so deduplicate on the id.

// Node.js example using the standard crypto module
import crypto from "node:crypto";

const TOLERANCE_SECONDS = 5 * 60;

// header is the X-AuthForge-Signature-V2 value: "t=<unix seconds>,v1=<hex>"
function verifyWebhook(rawBody, header, secret) {
  const parts = Object.fromEntries(
    header.split(",").map((part) => part.trim().split("=", 2)),
  );
  const t = Number(parts.t);
  if (!Number.isInteger(t) || !parts.v1) return false;
  if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(t + ".")
    .update(rawBody)
    .digest();
  const received = Buffer.from(parts.v1, "hex");
  return received.length === expected.length &&
    crypto.timingSafeEqual(received, expected);
}

The older X-AuthForge-Signature header (the HMAC of the body alone) is still sent so existing integrations keep working, but it doesn't cover the timestamp. Switch to the V2 header when you can.

The webhook secret is generated when you create the endpoint in the dashboard (App settings → Webhooks → Add Webhook) and is shown exactly once. Rotate it whenever a developer leaves your team.

Test delivery

Each webhook endpoint has a Test button in the dashboard. It sends a test.ping event, whose data holds the webhookId and appId, to your endpoint with the same headers as a real event, signed with your real webhook secret, and shows the HTTP status your endpoint returned. Use it to check that your URL is reachable and your signature verification works before a real event arrives.

Retries

Each attempt waits up to 5 seconds for a response, so acknowledge with a 2xx quickly and do slow work afterwards. Failed deliveries (a network error, a timeout, or a 5xx response) are retried up to three times, waiting about 1, 5, and then 25 seconds between attempts. Redirects are not followed; a 3xx response counts as a failed delivery. The dashboard shows each endpoint's last delivery time and HTTP status. If the last retry also fails, the event is dropped, so alert on your own handler errors rather than relying on redelivery.

An endpoint that fails ten events in a row, every retry included, is switched off automatically. The dashboard marks it as auto-disabled and the app owner is emailed. Fix the endpoint and check it with a test ping, then edit the webhook and re-enable it. Re-enabling keeps the failure count until a delivery succeeds, so if the next event also fails the webhook is switched off again straight away. Events that fired while it was off are not replayed.

Each event carries a unique id. Retries can deliver the same event more than once, so make your handler idempotent keyed on that id.

Related

Contact support

Feel free to reach out if you have questions, need help getting set up, or run into something unexpected. We'll get back to you as soon as we can.

Email us at support@authforge.cc