@lucerna-dev/webhooks
v0.0.1-alpha.0
Published
Signature verification for [Lucerna](https://uselucerna.app) webhook deliveries. Verification runs locally in your process — no network call, and your signing secret never leaves your server. Zero dependencies, WebCrypto only: the same build runs on Node
Readme
@lucerna-dev/webhooks
Signature verification for Lucerna webhook deliveries. Verification runs locally in your process — no network call, and your signing secret never leaves your server. Zero dependencies, WebCrypto only: the same build runs on Node 18+, Cloudflare Workers, Deno, Bun and browsers.
Lucerna signs every delivery following the
Standard Webhooks scheme:
HMAC-SHA256 over `${id}.${timestamp}.${body}` with your endpoint's
whsec_… secret, carried in the svix-signature (alias
webhook-signature) header.
Install
pnpm add @lucerna-dev/webhooksQuickstart
Get your endpoint's signing secret from the dashboard (the eye icon on the endpoint row) and keep it server-side.
import { webhooks, WebhookVerificationError } from "@lucerna-dev/webhooks";
export async function handleWebhook(request: Request): Promise<Response> {
const payload = await request.text(); // the RAW body — never re-serialize
let event;
try {
event = await webhooks.verify({
payload,
headers: {
id: request.headers.get("svix-id") ?? "",
timestamp: request.headers.get("svix-timestamp") ?? "",
signature: request.headers.get("svix-signature") ?? "",
},
webhookSecret: process.env.LUCERNA_WEBHOOK_SECRET ?? "",
});
} catch (error) {
if (error instanceof WebhookVerificationError) {
return new Response("invalid signature", { status: 400 });
}
throw error;
}
if (event.event === "signup.created") {
console.log(`${event.data.email} joined ${event.waitlistId}`);
}
return new Response(null, { status: 204 });
}headers also accepts a Fetch Headers instance or a Node-style plain
object (req.headers) as-is — the svix-* / webhook-* names are
looked up case-insensitively.
API
| Export | Signature | |
| ----------------------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| webhooks.verify / verifyWebhook | (input: VerifyWebhookInput) => Promise<WaitlistWebhookEvent> | Verifies a delivery and returns its parsed payload; throws on anything untrustworthy. |
| WebhookVerificationError | class extends Error | Thrown for missing headers, a stale timestamp, or a signature mismatch. Respond 400. |
| VerifyWebhookInput | { payload, headers, webhookSecret, toleranceSeconds? } | payload is the raw body string; toleranceSeconds defaults to 300. |
| WaitlistWebhookEvent | { id, event, createdAt, waitlistId, data } | The delivery envelope; data is the WaitlistEntry the event is about. |
| WaitlistWebhookEventName | "signup.created" \| "invite.sent" \| "invite.accepted" \| "entry.removed" | |
Guarantees & semantics
- Verification runs against the raw body bytes. Pass the body exactly as received; a parsed-and-re-stringified body will not match the signature.
- Stale deliveries are rejected. Timestamps further than 5 minutes
(configurable via
toleranceSeconds) from your clock fail — replay protection. - Signature comparison is constant-time.
event.idis unique per (event, entry) — safe to use as an idempotency key when your handler may see retries.- Only a
2xxresponse counts as delivered. Anything else is retried with backoff, and endpoints that keep failing are eventually disabled — return204fast and do heavy work asynchronously. - The secret is accepted with or without its
whsec_prefix.
Failure modes
verify never returns an unverified payload — it throws
WebhookVerificationError with one of: missing id/timestamp/
signature headers, non-numeric timestamp, timestamp outside the
tolerance, malformed secret, no matching signature, or a payload that
isn't valid JSON. Treat every case the same way: respond 400 and do
not act on the payload.
Not a Lucerna client
This package deliberately stops at verification. Creating endpoints, revealing secrets and browsing deliveries happen in the dashboard; capturing signups is a plain REST call — see the Waitlist API docs.
