Event Architecture

Outbound webhook delivery contract

Implement production-safe Licenzy webhook receivers with the right assumptions about delivery semantics, retries, verification, and fast acknowledgements.

Start free in test mode. Go live when you're ready.

Category: Event Architecture4 sections

Delivery semantics

Licenzy outbound delivery should be treated as an at-least-once event stream. The same event can be delivered more than once, and global ordering should not be assumed across all events or endpoints.

  • Duplicate deliveries are possible and must be handled safely.
  • Ordering is not documented as a global guarantee.
  • Receivers should process work asynchronously and return a fast 2xx acknowledgement after validation and deduplication.
  • Do not perform long synchronous business logic before acknowledging the delivery.
Consumer rule
Persist the stable webhook event identifier exposed by the sender implementation and use it for deduplication before running side effects.

Retry behavior and delivery states

The portal exposes delivery history with statuses, attempt counts, last HTTP status, error context, and next retry timing when retry is still pending.

  • pending means the delivery has not finished and may still retry.
  • failed means a recent delivery attempt failed and retry may still be in progress.
  • dead means the delivery is no longer progressing automatically.
  • sent means Licenzy considers the delivery completed successfully.

Do not depend on an exact retry schedule unless the backend formally publishes one. Treat retries as transient-failure handling and use portal delivery history to inspect whether a delivery is still progressing or needs operator attention.

Portal delivery history also makes retries visible and allows operators to requeue a delivery from the operational surface when recovery is needed.

Receiver architecture best practices

  1. Read the raw request body unchanged.
  2. Verify the webhook signature before trusting the payload.
  3. Validate the replay window using the signed timestamp.
  4. Persist and deduplicate the stable webhook event identifier.
  5. Enqueue background work.
  6. Return a fast 2xx acknowledgement.

This keeps webhook receivers resilient under retries, duplicate deliveries, and temporary downstream failures.

HMAC verification and replay guidance

Licenzy signs outbound deliveries with an endpoint-specific signing secret. Verify the signature against the raw body before JSON parsing side effects, and reject stale deliveries outside your replay window.

Header contract boundary
This frontend repository does not contain the outbound sender implementation, so the exact signature-header constants and stable event-identifier field name are not exposed here. Use the published sender constants from the backend implementation when turning this receiver pattern into production code.
  • Use constant-time comparison for the computed and received signatures.
  • Validate the signed timestamp against a bounded replay window before enqueueing work.
  • Store the processed event identifier so duplicate deliveries do not replay side effects.
Minimal receiver patternConcrete integration example
TypeScript
import crypto from "node:crypto";

export async function POST(request: Request) {
  const rawBody = await request.text();
  const signatureHeader = request.headers.get("<Licenzy signature header>");
  const timestampHeader = request.headers.get("<Licenzy timestamp header>");

  if (!signatureHeader || !timestampHeader) {
    return new Response("Missing webhook signature headers.", { status: 400 });
  }

  const webhookSecret = process.env.LICENZY_WEBHOOK_SECRET;

  if (!webhookSecret) {
    return new Response("Missing webhook secret.", { status: 500 });
  }

  const expectedSignature = crypto
    .createHmac("sha256", webhookSecret)
    .update(`${timestampHeader}.${rawBody}`)
    .digest("hex");

  const received = Buffer.from(signatureHeader, "utf8");
  const expected = Buffer.from(expectedSignature, "utf8");

  if (received.length !== expected.length || !crypto.timingSafeEqual(received, expected)) {
    return new Response("Invalid signature.", { status: 400 });
  }

  const timestamp = Number(timestampHeader);
  const ageSeconds = Math.abs(Math.floor(Date.now() / 1000) - timestamp);

  if (!Number.isFinite(timestamp) || ageSeconds > 300) {
    return new Response("Stale webhook timestamp.", { status: 400 });
  }

  const event = JSON.parse(rawBody);
  const eventId = event["<stable event id field>"];

  if (!eventId) {
    return new Response("Missing event identifier.", { status: 400 });
  }

  if (await alreadyProcessed(eventId)) {
    return new Response(null, { status: 204 });
  }

  await recordEventId(eventId);
  await enqueueLicenzyWebhook(event);

  return new Response(null, { status: 204 });
}