Skip to content

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.

  • You handle your own customer email. Set disableCustomerEmail and use license.created to 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; the license.refunded event lets your own systems mirror the action.
  1. 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.
  2. Keylight POSTs a JSON envelope to that URL whenever an event fires.
  3. Your server verifies the X-Keylight-Signature HMAC, then acts on the payload.
  4. 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.

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 the X-Keylight-Delivery-Id header.
  • 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.

Every POST includes:

HeaderExampleWhat it’s for
Content-Typeapplication/jsonAlways JSON.
X-Keylight-Signaturesha256=a3f8…HMAC-SHA256 of the raw body using your webhook secret. Verify this before trusting the payload.
X-Keylight-Eventlicense.createdMatches the event field in the body. Useful for routing in front of JSON parsing.
X-Keylight-Delivery-Id5d3c7e02-…Same value as id in the body — see retries.

The signature is sha256=<hex hmac> where the HMAC is computed over the raw request body using your webhook secret.

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);
});
import hmac, hashlib, os
from 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 "", 200
func 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)
}

Keylight retries failed deliveries on this schedule:

AttemptWhen
1Immediately (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 work

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.