API keys and webhook endpoints in SvelteKit
Every B2B SaaS eventually gets the same two requests: "can I call this from CI?" and "can you call me when something changes?". Both are programmatic access, and both are usually bolted on late, by which point the tenancy model has to answer a question it was never asked — which organization does this credential belong to?
This page covers the two services that answer it in the SvelteKit starters:api-keys.ts and webhooks.ts. Both are scoped to an organization from the first line, which is the decision that matters.
API keys: hash at rest, show the prefix
An API key is a password that never changes and is often pasted into places you do not control. The storage model has to assume it will leak:
// api-keys.ts — the whole security model in five lines.
const rawKey = `vs_${randomBytes(24).toString('hex')}`; // returned once
const prefix = rawKey.slice(0, 8); // shown forever
const keyHash = sha256(rawKey); // stored
await db.insert(apiKeys).values({ id, orgId, userId, name, keyHash, prefix, createdAtMs: Date.now() });
// Verification: hash what the caller sent, look it up.
export async function verifyApiKey(rawKey: string) {
const [row] = await db.select().from(apiKeys).where(eq(apiKeys.keyHash, sha256(rawKey)));
return row ? { orgId: row.orgId, userId: row.userId } : null;
}- Only the hash is stored.
listApiKeys()returns rows whosekeyHashis useless to an attacker — there is no code path that reconstructs a key. - The prefix is stored separately (eight characters, including the
vs_marker) so a settings screen can show "which key is this?" and a log line can say which key was used, without either of them containing the secret. - Revocation is a delete, not a flag.
revokeApiKey()removes the row, so verification fails on the next request with no cache to invalidate. - Every mutation is audited —
api_key.createdwith the acting user and the prefix. When a customer asks "who issued the key that just deleted our data", this row is the answer.
One honest limitation: sha256() is a fast hash, which is correct here and wrong for passwords. A key is 24 random bytes, so there is nothing to brute-force; a password needs a slow KDF precisely because its entropy is low. Using scrypt for API keys would add latency to every programmatic request for no security gain.
Webhook endpoints: a registry, and only a registry
Here is the part most starter kits get wrong in the other direction — they ship a "webhooks" feature that is a single POST and no state. The shipped service is deliberately the opposite: it owns the durable part (the registry) and leaves the transient part (delivery) to you, because delivery is where your infrastructure lives.
// webhooks.ts — what actually ships.
const secret = rb(32).toString('hex'); // per-endpoint, returned once
await db.insert(webhooks).values({
id, orgId,
url, // must start with http:// or https://
secret,
events: events.join(','), // e.g. 'member.invited,seat.changed'
active: 1,
createdAtMs: Date.now(),
});
return { id, secret };
// …and that is the end of the file's scope. There is no deliver().
// The signing step you must write on the sending side:
const signature = createHmac('sha256', secret).update(rawBody).digest('hex');What ships: create (with URL validation and a 32-byte per-endpoint secret), list, delete, and an active toggle that lets a customer pause deliveries without losing the endpoint. Every one of those writes an audit row.
What does not ship: the sender. There is no fetch() in webhooks.ts, no retry queue, no signature header. That is not an omission to paper over — it is the correct seam, because a delivery loop needs to survive your process restarting, and the right home for it is a queue (Cloudflare Queues, SQS, pg-boss), not a request handler.
The signing contract your customer verifies
// On YOUR receiver (the customer's endpoint), verifying a delivery:
const expected = createHmac('sha256', endpointSecret).update(rawBody).digest('hex');
const given = request.headers.get('x-verdantstack-signature') ?? '';
// Compare in constant time — a plain === leaks byte positions through timing.
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(given ?? ''));
// Two rules that break more integrations than anything else:
// 1. verify against the RAW body bytes, before JSON.parse
// 2. dedupe on the delivery id — a retry is not a new eventTwo details in that snippet are the difference between an integration that works for a year and one that breaks in a week. Signing the parsed JSON instead of the raw bytes means a key reorder or a whitespace change on your side silently invalidates every signature. Treating a retry as a new event means a customer's "send receipt" handler fires twice on a network blip.
A delivery loop you can actually run
If you are writing the sender, the shape that survives production is:
- Write the event to an outbox row in the same transaction that changes the data — never after it. An event emitted "on the way out" is an event you eventually lose.
- A worker claims pending rows with a visibility timeout, so two workers never send the same event and a crashed worker's row returns to the queue.
- Retry with exponential backoff and a delivery id that stays constant across attempts. The id is what lets the receiver deduplicate.
- Disable the endpoint after sustained failure rather than retrying forever — a dead URL should cost you an alert, not a growing queue.
Production checklist
- The raw API key is returned exactly once, from createApiKey(), and only its SHA-256 hash is stored — a database dump yields no usable credentials.
- The key format carries a visible prefix (`vs_` plus eight hex characters) so a leaked key is recognisable in a log or a support ticket without exposing the secret.
- verification is one indexed lookup by hash, not a scan: the caller passes the raw key and gets back { orgId, userId } or null.
- Every create, revoke, delete and toggle writes an audit row with the acting user — programmatic access is exactly the surface an auditor asks about.
- The webhook signing secret is generated per endpoint and returned once at creation; storing it in plain text is fine because it is a shared secret the receiver also holds, unlike an API key.
- The endpoint registry is not the delivery loop. webhooks.ts stores URL, events, secret and an active flag — it does not POST anything. You still write the sender, the retry policy and the signature header.
Related guides: TOTP two-factor authentication · Append-only audit log · RBAC & role hierarchy
Related reading
- Usage-based billing — metering the calls these keys make
- Seat billing adapter pattern — the billing seam next to this one
- Hashed Session Auth — the same hash-at-rest reasoning for sessions
- Product page — features, pricing, FAQ
- All documentation — guides, deep dives, and evaluations
Get in touch
Questions about the product, team licenses, or anything else? We'll aim to respond within 48 hours.