Skip to content
Lucent ImagingLucent Imaging icon
Core concepts

Webhooks

Have us tell you when an order moves, instead of asking. Polling works and is where most integrations start - webhooks exist so you do not have to.

Events

One event per order status, so a status and its event can never disagree about what happened.

order.received

We have the order and it is queued.

order.in_production

The studio has started work.

order.on_hold

Work has paused - the studio will normally have been in touch.

order.shipped

At least one parcel is on its way. The order carries the tracking details.

order.completed

Finished.

order.cancelled

Cancelled, by you or by the studio.

There is deliberately no order.failed. A submission that fails never becomes an order, so there is nothing to notify about - the failure is the HTTP response to POST /orders, synchronously, where you can act on it.

New event types may appear as the status vocabulary grows, so do not switch exhaustively on type.

Payload

data is the complete order - exactly what GET /orders/:id returns - rather than a diff, so acting on an event never requires calling us back, and one parser serves both surfaces.

POST /your-endpoint HTTP/1.1
Content-Type: application/json
Lucent-Signature: t=1787923200,v1=5f2b8c1d…
Lucent-Event-Id: evt_9c4e1f70a2b34d58bd6e0f1a2c3d4e5f
Lucent-Event-Type: order.shipped
User-Agent: Lucent-Print-API-Webhooks/1

Verifying a delivery

A bare POST to a public URL is unauthenticated by definition, so verify every delivery before acting on it. The header is:

Lucent-Signature: t=<unix seconds>,v1=<hex hmac-sha256>

The signed payload is `${timestamp}.${rawBody}`, HMAC-SHA256, keyed with your endpoint secret. The timestamp is inside the MAC, so a captured body cannot be replayed under a fresh one. Reject anything more than five minutes old and compare in constant time.

import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 5 * 60;

/**
 * Verify a Lucent webhook.
 *
 * `rawBody` must be the EXACT bytes we sent. Parsing to an object and
 * re-serialising changes key order and whitespace, and the signature will not
 * match - so capture the raw body before any JSON middleware runs.
 */
export function verify(rawBody, secret, header) {
  if (!header) return false;

  const parts = new Map(
    header.split(",").map((segment) => segment.split("=", 2).map((s) => s.trim())),
  );

  const timestamp = Number(parts.get("t"));
  const provided = parts.get("v1");
  if (!Number.isFinite(timestamp) || !provided) return false;

  // Both directions: a far-future timestamp is as suspicious as a stale one.
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) return false;

  const expected = createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest();
  const actual = Buffer.from(provided, "hex");

  // timingSafeEqual throws on a length mismatch, so check that first.
  if (actual.length !== expected.length) return false;
  return timingSafeEqual(actual, expected);
}

Delivery guarantees

  • At least once. A receiver that answers 200 after our socket timed out has processed an event we will send again. Deduplicate on id (also in Lucent-Event-Id).
  • Any 2xx is success. Everything else is retried, including 4xx - a receiver answering 401 because its signature check was mid-deploy is exactly what retries are for.
  • Backoff runs 1m, 5m, 15m, 1h, 4h, 12h - about 17 hours in total, enough to cover an overnight outage. After that the delivery is marked failed and kept, and the studio can resend it.
  • Redirects are not followed. A 302 would replay a signed body at a host we did not intend to sign for.
  • An endpoint that fails 20 deliveries in a row is disabled, and needs re-enabling by the studio.

Respond quickly - acknowledge with a 2xx and do your work afterwards. Requests time out after 10 seconds.

Registering an endpoint

Ask the studio, giving the URL and whether it is for live or test. An endpoint hears one environment only, so your integration tests can never page the team watching production.

The URL must be public HTTPS. You get a secret starting lpw_, shown once - store it somewhere your handler can read.

Until an endpoint is registered, poll GET /orders/:id. Nothing about the order surface changes when webhooks are switched on.