Webhooks overview
Keylight sends signed HTTP POST events to your server when license-related things happen — a new purchase, an activation on a new device, a refund, a renewal. You can use these to provision accounts, send custom emails, mark orders in your CRM, or trigger any workflow you’d run on the merchant side.
When you’ll want a webhook
Section titled “When you’ll want a webhook”- You handle your own customer email. Set
disableCustomerEmailand uselicense.createdto deliver the key from your own template. - You provision the buyer in your app. Create their account / workspace / API tokens the moment Stripe confirms payment.
- You sync to your CRM / Notion / Slack. Record the sale, the device count, the refund — same dashboards you already have.
- You auto-revoke on refund. Keylight already revokes the license on
charge.refunded; thelicense.refundedevent lets your own systems mirror the action.
How it works
Section titled “How it works”- In Dashboard → Settings → Integrations, set License Webhook URL to an HTTPS endpoint on your server. Keylight generates and shows you a webhook secret once — save it.
- Keylight POSTs a JSON envelope to that URL whenever an event fires.
- Your server verifies the
X-Keylight-SignatureHMAC, then acts on the payload. - If your endpoint returns 2xx, the delivery is marked succeeded. If not, Keylight retries automatically (1 min, 5 min, 30 min), then gives up.
You can see every attempt — succeeded, retrying, failed — on Dashboard → Webhooks, and you can replay any of them manually with the Redeliver button.
The envelope
Section titled “The envelope”Every event Keylight sends shares a common envelope:
{ "id": "5d3c7e02-…", "created": 1714669200, "version": "2026-05-01", "event": "license.created", "tenant_id": "your-account", …event-specific fields}id— UUID for this specific delivery. Use it to de-duplicate on your side. Also echoed in theX-Keylight-Delivery-Idheader.created— Unix seconds.version— payload schema version. Today:2026-05-01. Stable across non-breaking changes.event— one of the event types.tenant_id— your Keylight account ID.
Event-specific fields are documented per event in the event reference.
Headers
Section titled “Headers”Every POST includes:
| Header | Example | What it’s for |
|---|---|---|
Content-Type | application/json | Always JSON. |
X-Keylight-Signature | sha256=a3f8… | HMAC-SHA256 of the raw body using your webhook secret. Verify this before trusting the payload. |
X-Keylight-Event | license.created | Matches the event field in the body. Useful for routing in front of JSON parsing. |
X-Keylight-Delivery-Id | 5d3c7e02-… | Same value as id in the body — see retries. |
Verify the signature
Section titled “Verify the signature”The signature is sha256=<hex hmac> where the HMAC is computed over the raw request body using your webhook secret.
Node / Express
Section titled “Node / Express”import crypto from 'node:crypto';
app.post('/keylight-webhook', express.raw({ type: 'application/json' }), (req, res) => { const sigHeader = req.header('X-Keylight-Signature') ?? ''; const expected = 'sha256=' + crypto .createHmac('sha256', process.env.KEYLIGHT_WEBHOOK_SECRET) .update(req.body) .digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(sigHeader), Buffer.from(expected))) { return res.status(401).send('bad signature'); }
const event = JSON.parse(req.body.toString('utf8')); // … route on event.event, persist event.id for idempotency res.sendStatus(200);});Python / Flask
Section titled “Python / Flask”import hmac, hashlib, osfrom flask import request, abort
@app.post("/keylight-webhook")def keylight_webhook(): body = request.get_data() expected = "sha256=" + hmac.new( os.environ["KEYLIGHT_WEBHOOK_SECRET"].encode(), body, hashlib.sha256, ).hexdigest() if not hmac.compare_digest(request.headers.get("X-Keylight-Signature", ""), expected): abort(401) event = request.get_json() # … route on event["event"], persist event["id"] for idempotency return "", 200func keylightWebhook(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) mac := hmac.New(sha256.New, []byte(os.Getenv("KEYLIGHT_WEBHOOK_SECRET"))) mac.Write(body) expected := "sha256=" + hex.EncodeToString(mac.Sum(nil)) if !hmac.Equal([]byte(r.Header.Get("X-Keylight-Signature")), []byte(expected)) { http.Error(w, "bad signature", http.StatusUnauthorized) return } // unmarshal body, route on event type, dedupe on event.id w.WriteHeader(http.StatusOK)}Retries & idempotency
Section titled “Retries & idempotency”Keylight retries failed deliveries on this schedule:
| Attempt | When |
|---|---|
| 1 | Immediately (event time) |
| 2 | +1 minute |
| 3 | +5 minutes |
| 4 | +30 minutes |
| — | Marked failed after attempt 4 |
A delivery is considered successful when your endpoint returns any 2xx status code. Any other response (3xx, 4xx, 5xx) or a network error counts as a failure and schedules the next retry.
Idempotency. Because retries can fire while your previous response is still in flight, you might receive the same id more than once. Store every id you’ve successfully processed (a small table with a unique index works) and short-circuit on duplicates.
CREATE TABLE keylight_processed_events ( id text PRIMARY KEY, received_at timestamptz NOT NULL DEFAULT now());try { await db.query('INSERT INTO keylight_processed_events (id) VALUES ($1)', [event.id]);} catch (e) { // Unique violation — already processed. Return 200 anyway. return res.sendStatus(200);}// … do the workDisable the default customer email
Section titled “Disable the default customer email”By default, Keylight sends a transactional email to the buyer after each purchase with their license key. If you’re using license.created to send your own email, you’ll want to turn that off — otherwise buyers receive two emails.
In Dashboard → Settings → Integrations, toggle Disable Keylight emails to buyers. The license still gets minted and the webhook still fires; only the default email is suppressed.
- Event reference — every event Keylight can send, with full payload schemas.
- Deliveries & redelivery — the dashboard log and the Redeliver button.