Skip to main content

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_events is "*" 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
}
  • id is your idempotency key — see below.

  • sequence is monotonic per account — use it to reason about order.

  • previous carries the changed fields on licence.plan_changed ({ "plan_id": "pln_old…" }); it's null on 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.id and skip duplicates.
  • Ordering is not guaranteed. A retry can arrive after a newer event. Don't apply state blindly — compare sequence or 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.