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
2xxacknowledgement after validation and deduplication. - Do not perform long synchronous business logic before acknowledging the delivery.
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.
pendingmeans the delivery has not finished and may still retry.failedmeans a recent delivery attempt failed and retry may still be in progress.deadmeans the delivery is no longer progressing automatically.sentmeans 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
- Read the raw request body unchanged.
- Verify the webhook signature before trusting the payload.
- Validate the replay window using the signed timestamp.
- Persist and deduplicate the stable webhook event identifier.
- Enqueue background work.
- Return a fast
2xxacknowledgement.
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.
- 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.
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 });
}