Webhooks
Get notified when a license is created, suspended, revoked or activated. Payload format, HMAC signature verification and how retries work.
Webhooks tell your other systems when something happens to a license. A CRM can tag a customer when they buy, a SaaS backend can lock an account when a license is revoked, and a chat channel can announce every new sale. Keygate sends an HTTPS POST to your endpoint for each event you subscribe to.
Add an endpoint
Open Webhooks in the dashboard, enter the URL that should receive events and choose the events you want. Keygate generates a signing secret for the endpoint; store it on your side, you need it to check that a request really came from Keygate.
Use the test button to send a webhook.test event and see it arrive. Every delivery is listed with its response code, and any of them can be sent again from there.
What a delivery looks like
The body is JSON with the event name, a timestamp and the data for that event:
{
"event": "license.created",
"timestamp": "2026-10-04T09:12:44Z",
"data": {
"license_id": "4f0c6c1e-2a7b-4d4e-9a55-0d3b7e1f2c11",
"email": "[email protected]"
}
}Three headers come with it:
| Header | Contains |
|---|---|
X-Keygate-Event | The event name, the same as in the body. |
X-Keygate-Signature | sha256= followed by the HMAC of the body, in hex. |
X-Keygate-Delivery | A unique ID for this delivery. |
Verify the signature
Compute an HMAC SHA256 of the raw request body with your endpoint's secret and compare it with the header. Use the body exactly as it arrived; parsing and serializing the JSON again changes the bytes and the signature no longer matches.
import { createHmac, timingSafeEqual } from "node:crypto";
export function isFromKeygate(rawBody, signatureHeader, secret) {
const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signatureHeader ?? "");
return a.length === b.length && timingSafeEqual(a, b);
}func isFromKeygate(body []byte, signatureHeader, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signatureHeader))
}Answer with any 2xx status once you have accepted the event. Do slow work after you reply, because Keygate waits ten seconds by default before it treats the delivery as failed.
Events
| Event | Sent when |
|---|---|
license.created | A license is created by a checkout, in the dashboard or through the API. |
license.activated | A device or user is activated on a license. |
license.deactivated | A device is removed from a license: by your app, in the customer portal or in the dashboard. |
license.suspended | A license is suspended by an admin, or its subscription is paused in Stripe. |
license.reinstated | A suspended license is back in use. |
license.revoked | A license is revoked in the dashboard, or fully refunded in the dashboard or in Stripe. |
license.canceled | The subscription behind a license ends. |
license.expired | A license passes its end date and grace days. |
license.expiry_changed | An admin changes a license's end date. |
license.payment_failed | A subscription payment fails. |
license.payment_recovered | A failed payment goes through after all. |
plan.changed | A license moves to another plan, in the dashboard or the customer portal. |
seat.added | A team member joins a license. |
seat.removed | A team member leaves a license. |
quota.warning | A license crosses the warning level of a usage quota. |
quota.exceeded | A license uses up a usage quota. |
release.published | A release is published. |
release.yanked | A release is pulled. |
release.unyanked | A pulled release is put back. |
Retries and duplicates
If your endpoint does not answer with 2xx, Keygate tries again after one minute, then after two, four and eight minutes. It gives up after five attempts in all, a number you can change with WEBHOOK_MAX_ATTEMPTS.
- Automatic retries keep the same
X-Keygate-DeliveryID, so you can skip an ID you have already processed. - A delivery sent again by hand from the dashboard gets a new ID but carries exactly the same body. If an event must never be processed twice, remember the event name, the timestamp and the license ID together.
Private addresses
To protect your network, Keygate refuses to deliver to private, loopback and link local addresses by default. If your receiver runs on the same machine or Docker network, set WEBHOOK_ALLOW_PRIVATE=true.
Last updated October 4, 2026