Webhooks

KeyGrant sends events about your licenses and devices, such as a key issued or a device activated, to your own HTTPS endpoints. The full list is on Webhook events.

Add an endpoint

In the dashboard, under Developers > Webhooks, choose Add endpoint:

  • Endpoint URL: https:// on a public host (not localhost, .local, .internal or a private address), up to 500 characters. It must respond within 10 seconds.
  • Name, for your own reference, optional.
  • Events to send: at least one. license.issued and license.revoked are selected by default.
  • Send test license events: off by default. Events about test licenses go only to endpoints with this setting on, and are marked test: true. Live events go to every endpoint that subscribes to them.

Manage an endpoint

  • Signing secret: shown when you choose Reveal: whsec_ followed by 48 hex characters.
  • Roll: replaces the secret immediately. Deliveries are never signed with the old secret again.
  • Disable: stops deliveries to the endpoint. A retry that comes due while it is disabled is marked Parked without being sent.
  • Delete endpoint: removes the endpoint and its delivery history.

Warning

Delete endpoint is for good: deliveries still waiting to be sent or tried again are dropped, and adding the URL again makes a new endpoint with a new signing secret.

The request

Each event is a POST with a JSON body:

Body
{  "id": "evt_9b2c7e1f0a4d4c3b8e6f5a2d1c0b9e8f",  "type": "license.issued",  "created": 1791257861000,  "version": "2026-06-01",  "data": {    "key": "MYPROD-9K4T-2M8P-XQ7V-H3DW",    "product": "my-product",    "email": "[email protected]",    "model": "perpetual",    "modelId": "mod_5c1e0a7b9d2f4e8a6b3c1d0e9f8a7b6c",    "source": "checkout"  }}
  • id: the event's ID, the same for every endpoint and every retry of it.
  • type: the event type (see Webhook events).
  • created: when it happened, in Unix milliseconds.
  • version: the payload version, 2026-06-01.
  • data: the event's fields.
  • test: true on an event about a test license. Absent otherwise.

With these headers:

Headers
KeyGrant-Signature: t=1791257861,v1=5f0c…e41aKeyGrant-Event-Id: evt_9b2c7e1f0a4d4c3b8e6f5a2d1c0b9e8fKeyGrant-Delivery-Attempt: 1user-agent: KeyGrant-Webhooks/1content-type: application/json

Verify the signature

Check KeyGrant-Signature before you trust the payload:

  1. Read the request body exactly as received, before any JSON parsing.
  2. Take t (the time this attempt was signed, in Unix seconds) and v1 from the header.
  3. Compute the HMAC-SHA256 of t, a dot and the raw body, keyed with the whole signing secret (whsec_ included), in lowercase hex.
  4. Compare it with v1 in constant time.
  5. Refuse a t more than 5 minutes from now.
Node
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody: the request body exactly as received, before any JSON parsing.function verifyKeyGrantSignature(rawBody, header, secret, toleranceSeconds = 300) { if (typeof header !== "string") return false; const parts = Object.fromEntries(header.split(",").map((part) => part.split("=", 2))); const t = Number(parts.t); if (!Number.isInteger(t) || typeof parts.v1 !== "string") return false; if (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, "utf8"); const wanted = Buffer.from(expected, "utf8"); return given.length === wanted.length && timingSafeEqual(given, wanted);}
  • Each attempt is signed when it is sent, so a 5-minute tolerance on t works for retries too.
  • Respond with a 2xx status within 10 seconds and do the work afterward.
  • A delivery can arrive more than once (for example, when your handler timed out after it had already acted), so use id to process each event only once.

Deliveries and retries

A delivery succeeds on a 2xx status. A redirect is not followed and counts as a failure, as do a timeout and any other status.

  • When one sale's keys are issued, revoked or reinstated together, the first six deliveries go at once and the rest within a few minutes, up to 120 a minute. Until it is sent, a delivery reads queued.
  • Each attempt carries its number in KeyGrant-Delivery-Attempt.

When a failed delivery is tried again

A failed delivery is retried, waiting longer after each failure, for up to 9 attempts in all:

AttemptSent
1When the event happens
21 minute after the first failure
3, 4, 55, 15 and 45 minutes after the failure before
6, 7, 8, 92, 4, 8 and 12 hours after the failure before

A delivery that fails all 9 attempts is marked Parked.

An endpoint that keeps failing

  • KeyGrant never disables an endpoint for failing.
  • When an endpoint has 10 or more failed deliveries in the last 24 hours, KeyGrant sends the Webhook endpoint failing alert, at most once a day.
  • It goes to your account's developers by default (Settings > Alerts).

Event deliveries

The endpoint's Event deliveries tab lists every delivery for 30 days, with the request sent and your handler's response.

  • Retry this delivery: sends a failed delivery again at once, unless a retry is already sending it. Then the delivery says so and shows the result once it lands.
  • Retry all failed: queues up to 500 of the newest failed deliveries, each with a new budget of 9 attempts. They go out up to 120 a minute.
  • Neither retry button works while the endpoint is disabled.

Send a test event

Send a test event sends one request whose type is the first event in the endpoint's list, marked test: true, regardless of the endpoint's settings. Its data always holds test: true, key: "SAMPLE-0000-0000-0000-0000" and product: "sample".