Handling webhooks
Webhooks are how Licet tells your backend that something happened — a licence was issued, a payment landed, a refund revoked something. If you sell through Checkout or the sale-events lane, your webhook handler is your fulfilment path, so it's worth building properly. This guide covers registering an endpoint, verifying signatures, and the delivery semantics you must design for.
The catalogue of event types lives in the events reference.
Register an endpoint
In the portal: Payments → Webhook endpoints, or via the API:
curl -X POST https://api.licet.app/v1/apps/{app_id}/webhook-endpoints \
-H "Authorization: Bearer sk_live_…" \
-H "Content-Type: application/json" \
-d '{ "url": "https://yourapp.example/licet/webhooks", "subscribed_events": "*" }'
{ "id": "whe_…", "secret": "whsec_…" }
Rules and behaviour:
- HTTPS only, and the URL must be publicly reachable — private/internal addresses are rejected at registration and re-checked at send time.
subscribed_eventsis"*"or a comma-separated list of event types.- The signing secret is shown once. Store it in your secret manager immediately; the list endpoint never returns it again.
- You can register several endpoints per application; each event is delivered to every enabled endpoint subscribed to its type.
- Deleting an endpoint disables it permanently — to resume, register a new one (which gets a new secret).
Verify the signature — every time
Every delivery carries a Webhook-Signature header:
Webhook-Signature: t=1752537600,v1=5257a869e7…
where v1 = HMAC-SHA256(secret, "{t}." + raw request body), hex-encoded. It's
the same scheme Stripe uses, so existing verification code adapts directly.
Verify before you parse anything:
// ASP.NET Core minimal API
app.MapPost("/licet/webhooks", async (HttpRequest request) =>
{
using var reader = new StreamReader(request.Body);
var body = await reader.ReadToEndAsync();
var header = request.Headers["Webhook-Signature"].ToString();
var parts = header.Split(',')
.Select(p => p.Split('=', 2))
.ToDictionary(p => p[0], p => p[1]);
var timestamp = long.Parse(parts["t"]);
if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - timestamp) > 300)
return Results.Unauthorized(); // stale — possible replay
var payload = $"{parts["t"]}.{body}";
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var expected = Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(payload)))
.ToLowerInvariant();
if (!CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(parts["v1"])))
return Results.Unauthorized();
var evt = JsonSerializer.Deserialize<JsonElement>(body);
// …handle, quickly, then:
return Results.Ok();
});
// Node (Express) — mount with express.raw() so you sign the exact bytes
const crypto = require("node:crypto");
app.post("/licet/webhooks", express.raw({ type: "*/*" }), (req, res) => {
const parts = Object.fromEntries(
req
.get("Webhook-Signature")
.split(",")
.map((p) => p.split("=")),
);
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300)
return res.status(401).end();
const expected = crypto
.createHmac("sha256", secret)
.update(`${parts.t}.${req.body}`)
.digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1)))
return res.status(401).end();
const event = JSON.parse(req.body);
// …handle, quickly, then:
res.sendStatus(200);
});
Two classic mistakes: verifying against a re-serialized body instead of the
raw bytes (frameworks love to helpfully parse JSON first), and comparing
signatures with == instead of a constant-time comparison.
:::note Rotating the secret
Roll secret (portal) or POST …/rotate-secret returns a new secret —
again shown once. Signing switches to the new secret immediately, so deploy
the new value to your handler right away and replay anything that failed
during the swap from the deliveries page.
:::
The envelope
Every event has the same shape:
{
"id": "evt_01J…",
"type": "licence.created",
"api_version": "2026-06-01",
"created_at": "2026-07-14T12:00:00Z",
"sequence": 1024,
"account_id": "acct_…",
"application_id": "app_…",
"data": {
"object": { "licence_id": "lic_…", "plan_id": "pln_…", "status": "active" }
},
"previous": null
}
-
idis your idempotency key — see below. -
sequenceis monotonic per account — use it to reason about order. -
previouscarries the changed fields onlicence.plan_changed({ "plan_id": "pln_old…" }); it'snullon everything else. -
Payloads are deliberately thin — ids and status, no customer data. When you need the full licence (its key, entitlements, expiry), fetch it:
curl https://api.licet.app/v1/licences/{licence_id} \-H "Authorization: Bearer sk_live_…" # key with the read scope
Delivery semantics — design for these
- At-least-once. You may receive the same event twice (retries, replays).
Record
event.idand skip duplicates. - Ordering is not guaranteed. A retry can arrive after a newer event.
Don't apply state blindly — compare
sequenceor re-fetch the entity. - Respond fast. The delivery times out after 5 seconds. Acknowledge with a 2xx, then do real work asynchronously (queue it).
- Retries: failures are retried with backoff — 1 minute, 5 minutes, 30 minutes, 2 hours, then 6-hour steps — up to 12 attempts over roughly two days.
- Auto-disable: an endpoint that exhausts its retries is disabled and you're alerted. Disabling is permanent — fix the receiver, register a fresh endpoint, and replay what was missed.
- Every attempt is logged. Payments → Webhook deliveries shows each delivery's payload, response code, and attempt count, with one-click Replay. Payloads are retained for 90 days; the delivery record itself stays.
Test your handler
The deliveries page has Send test event: a real, signed delivery of type
webhook.test with data.object = { "ping": true } through the normal
pipeline. Use it to prove signature verification and your 2xx path before any
money moves.