For the complete documentation index, see llms.txt. Prefer markdown by appending.mdto documentation URLs or sendingAccept: text/markdown.
API keys and webhooks
Add user-facing API keys and outbound webhooks so customers can call your Hono API and subscribe to domain events.
B2B customers expect two integration surfaces: API keys to call your API programmatically, and outbound webhooks so your app pushes events to their stack. TurboStarter already handles inbound billing webhooks from Stripe, Polar, Lemon Squeezy, and friends. This recipe adds the outbound path plus Better Auth API keys on the same Hono stack.
TL;DR
- Enable Better Auth's API Key plugin with
enableSessionForAPIKeys, regenerate auth schema, and allowx-api-keyin CORS. - Add
webhook_endpoint+webhook_deliverytables and a small@workspace/webhookspackage (emit, sign, deliver, verify helper). - Expose session-only CRUD under Settings → Developers (keys + endpoints).
- Call
emitWebhookEventfrom auth hooks and billing upserts. - Deliver with retries and a Stripe-style HMAC signature customers can verify.
Inbound vs outbound
| Direction | Who calls whom | In TurboStarter today |
|---|---|---|
| Inbound | Billing provider → your API | Ships at POST /api/billing/webhook/:provider. See Billing webhooks. |
| Outbound | Your app → customer HTTPS URL | This recipe |
| API keys | Customer → your Hono routes | This recipe (Better Auth plugin) |
Do not reuse the billing webhook handlers for customer endpoints. Inbound handlers verify provider signatures and update your DB. Outbound delivery signs your payloads and POSTs to URLs your customers register.
Philosophy
When something meaningful happens (member joined, subscription updated, and so on), call emitWebhookEvent({ type, data, organizationId? }). That helper inserts a pending webhook_delivery row and runs (or queues) delivery: a signed POST to the customer URL with an X-Webhook-Signature header. A 2xx response marks the delivery as delivered; 5xx or timeouts retry with backoff.
API keys sit on the request path: customers send x-api-key (plugin default) or Authorization: Bearer …. Your middleware resolves a user the same way a cookie session does, so existing enforceAuth routes keep working.
Building blocks
| Layer | Location |
|---|---|
| Auth plugin | packages/auth + regenerated packages/db/src/schema/auth.ts |
| CORS / headers | packages/api/src/index.ts (x-api-key) |
| Webhook schema + emit/deliver | packages/db + new @workspace/webhooks |
| CRUD API | packages/api/src/modules/webhooks/ (session auth only) |
| Dashboard UI | Settings → Developers (user + org) |
Not a drop-in kit feature
Core does not ship this UI or package yet. Treat the snippets as a production pattern to implement on top of Better Auth, Drizzle, and Hono, the same way Prisma or feature-based access recipes extend the kit.
Enable Better Auth API keys
Install the plugin next to your other Better Auth packages (pin the same version as better-auth in packages/auth/package.json):
pnpm --filter @workspace/auth add @better-auth/api-key@1.6.22Register it on the server:
import { apiKey } from "@better-auth/api-key";
export const auth = betterAuth({
// ...
plugins: [
// ...existing plugins
apiKey({
enableMetadata: true,
// Lets auth.api.getSession resolve a user from x-api-key
enableSessionForAPIKeys: true,
rateLimit: {
enabled: true,
maxRequests: 1000,
timeWindow: 1000 * 60 * 60, // 1 hour
},
}),
nextCookies(),
],
});Re-export the client plugin and wire it in the web auth client:
export { apiKeyClient } from "@better-auth/api-key/client"; import {
// ...
apiKeyClient,
} from "@workspace/auth/client/web";
export const authClient = createAuthClient({
// ...
plugins: [
// ...existing plugins
apiKeyClient(),
inferAdditionalFields<typeof auth>(),
],
});Regenerate the auth schema and apply migrations:
pnpm --filter @workspace/auth db:generate
pnpm with-env turbo db:generate
pnpm with-env pnpm --filter @workspace/db db:migrateOrg-owned keys
For organization-scoped keys, pass a config array with references: "organization" (and a configId such as org-keys). Creating those keys requires an organizationId in the create body. Start with user-owned keys plus metadata (label, optional organizationId) if you want a smaller first ship.
Accept API keys on Hono routes
With enableSessionForAPIKeys: true, existing enforceAuth already works: it calls auth.api.getSession({ headers }), and Better Auth treats a valid x-api-key as a session for that user. You do not need a separate verify path for cookie-vs-key.
Allow the header in CORS (browser-based tools and local demos):
cors({
origin: "*",
allowHeaders: ["Content-Type", "Authorization", "x-api-key"],
maxAge: 3600,
credentials: true,
}),Smoke test against any existing protected route:
curl -H "x-api-key: YOUR_KEY" http://localhost:3000/api/organizationsServer-side check (same as the plugin docs):
const session = await auth.api.getSession({
headers: new Headers({ "x-api-key": apiKey }),
});If you need permission checks on the key itself (not only "is this a valid user?"), call auth.api.verifyApiKey({ body: { key, permissions } }) in dedicated middleware.
Keep webhook CRUD on sessions
Customers should manage keys and webhook endpoints from the dashboard with a normal login. Do not allow creating or rotating endpoints with an API key unless you add explicit scopes later.
Add webhook tables
Follow the app-owned table style in packages/db/src/schema/billing.ts (generateId, timestamps, Zod insert schemas):
import { relations } from "drizzle-orm";
import {
boolean,
integer,
jsonb,
pgEnum,
pgTable,
text,
timestamp,
} from "drizzle-orm/pg-core";
import { generateId } from "@workspace/shared/utils";
import { createInsertSchema, createSelectSchema } from "../lib/zod";
import { organization, user } from "./auth";
export const webhookDeliveryStatusEnum = pgEnum("webhook_delivery_status", [
"pending",
"delivered",
"failed",
]);
export const webhookEndpoint = pgTable("webhook_endpoint", {
id: text().primaryKey().$defaultFn(generateId),
organizationId: text().references(() => organization.id, {
onDelete: "cascade",
}),
userId: text()
.references(() => user.id, { onDelete: "cascade" })
.notNull(),
url: text().notNull(),
secretHash: text().notNull(),
events: jsonb().$type<string[]>().notNull(),
enabled: boolean().notNull().default(true),
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp()
.notNull()
.$onUpdate(() => new Date()),
});
export const webhookDelivery = pgTable("webhook_delivery", {
id: text().primaryKey().$defaultFn(generateId),
endpointId: text()
.references(() => webhookEndpoint.id, { onDelete: "cascade" })
.notNull(),
eventType: text().notNull(),
payload: jsonb().notNull(),
status: webhookDeliveryStatusEnum().notNull().default("pending"),
attempts: integer().notNull().default(0),
responseCode: integer(),
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp()
.notNull()
.$onUpdate(() => new Date()),
});
export const webhookEndpointRelations = relations(
webhookEndpoint,
({ many }) => ({
deliveries: many(webhookDelivery),
}),
);
export const insertWebhookEndpointSchema = createInsertSchema(webhookEndpoint);
export const selectWebhookEndpointSchema = createSelectSchema(webhookEndpoint);Export from packages/db/src/schema/index.ts, then generate and migrate.
Store only a hash of the signing secret. Return the plaintext secret once on create (same UX as API keys). Reject non-HTTPS URLs in v1.
Create @workspace/webhooks
Add a focused package for the event catalog, signing, emit, and a customer-facing verify helper. Keep CRUD in packages/api.
packages/webhooks/
package.json # name: @workspace/webhooks
src/
events.ts # Zod payloads + event type union
sign.ts # HMAC sign / verify
emit.ts # look up endpoints, enqueue deliveries
deliver.ts # POST + retries
index.tsEvent catalog
Start with a small set wired to hooks that already exist in core:
| Event | Emit from |
|---|---|
user.created | packages/auth/src/hooks/user/create.ts (after hook) |
organization.member.joined | afterAddMember / afterAcceptInvitation in hooks/organization/add-member.ts |
organization.member.removed | afterRemoveMember in hooks/organization/remove-member.ts |
billing.subscription.updated | After upsertSubscription in packages/billing/shared/src/server/subscription.ts |
billing.subscription.canceled | Same path when status is canceled |
webhook.ping | Dashboard “Send test event” |
Payload shape:
import * as z from "zod";
export const webhookEventTypes = [
"user.created",
"organization.member.joined",
"organization.member.removed",
"billing.subscription.updated",
"billing.subscription.canceled",
"webhook.ping",
] as const;
export const webhookEnvelopeSchema = z.object({
id: z.string(),
type: z.enum(webhookEventTypes),
apiVersion: z.literal("2026-06-28"),
createdAt: z.string().datetime(),
data: z.record(z.string(), z.unknown()),
});
export type WebhookEnvelope = z.infer<typeof webhookEnvelopeSchema>;Signature (Stripe-style)
const encoder = new TextEncoder();
export const signPayload = async (secret: string, body: string, t: number) => {
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const mac = await crypto.subtle.sign(
"HMAC",
key,
encoder.encode(`${t}.${body}`),
);
const v1 = [...new Uint8Array(mac)]
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
return `t=${t},v1=${v1}`;
};
export const verifySignature = async (
secret: string,
body: string,
header: string,
toleranceSec = 300,
) => {
const parts = Object.fromEntries(
header.split(",").map((p) => p.trim().split("=")),
);
const t = Number(parts.t);
if (!t || !parts.v1) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSec) return false;
const expected = await signPayload(secret, body, t);
const expectedV1 = expected.split("v1=")[1]!;
return timingSafeEqual(expectedV1, parts.v1);
};
const timingSafeEqual = (a: string, b: string) => {
if (a.length !== b.length) return false;
let result = 0;
for (let i = 0; i < a.length; i++) {
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return result === 0;
};Customers verify with the same helper (document it; mirror Lemon Squeezy's inbound verify style in packages/billing/web/.../webhook/verify.ts).
Emit + deliver
import { and, eq, sql } from "@workspace/db";
import { webhookDelivery, webhookEndpoint } from "@workspace/db/schema";
import { db } from "@workspace/db/server";
import { generateId } from "@workspace/shared/utils";
import { deliverWebhook } from "./deliver";
import type { WebhookEnvelope } from "./events";
export const emitWebhookEvent = async (input: {
type: WebhookEnvelope["type"];
data: Record<string, unknown>;
organizationId?: string;
userId?: string;
}) => {
const envelope: WebhookEnvelope = {
id: generateId(),
type: input.type,
apiVersion: "2026-06-28",
createdAt: new Date().toISOString(),
data: input.data,
};
const endpoints = await db
.select()
.from(webhookEndpoint)
.where(
and(
eq(webhookEndpoint.enabled, true),
input.organizationId
? eq(webhookEndpoint.organizationId, input.organizationId)
: input.userId
? eq(webhookEndpoint.userId, input.userId)
: sql`false`,
),
);
const matching = endpoints.filter((endpoint) =>
endpoint.events.includes(input.type),
);
for (const endpoint of matching) {
const [row] = await db
.insert(webhookDelivery)
.values({
endpointId: endpoint.id,
eventType: input.type,
payload: envelope,
status: "pending",
})
.returning();
if (row) {
void deliverWebhook(row.id);
}
}
};deliverWebhook should load the delivery + endpoint, rebuild the plaintext secret only if you store an encrypted form (or keep a separate vault), sign the JSON body, POST with a short timeout, and update attempts / status / responseCode. Retry with exponential backoff on network errors and 5xx (for example 3-5 attempts). Treat 2xx as success; treat 4xx as permanent failure unless you intentionally retry 429.
Core has no job runner today. For low volume, inline void deliverWebhook(...) is enough to ship. When you need durable retries, follow the same optional-provider approach Achromatic documents for background work: Trigger.dev, Upstash QStash, or Inngest. Install one, enqueue deliveryId, and keep idempotent status transitions in Postgres.
Wire emit points in existing hooks
User create currently only has a before hook. Add after and emit:
import { emitWebhookEvent } from "@workspace/webhooks";
export const create = {
before: async (user) => {
/* existing name normalization */
},
after: async (user) => {
await emitWebhookEvent({
type: "user.created",
userId: user.id,
data: { id: user.id, email: user.email, name: user.name },
});
},
};Organization membership:
afterAcceptInvitation: async ({ organization, member }) => {
await syncSubscriptionSeats(organization.id);
await emitWebhookEvent({
type: "organization.member.joined",
organizationId: organization.id,
data: {
organizationId: organization.id,
userId: member.userId,
role: member.role,
},
});
},Billing: emit once after the shared upsert so every provider benefits:
export const upsertSubscription = async (data: InsertSubscription) => {
const rows = await db
.insert(subscription)
.values(data)
.onConflictDoUpdate({
/* existing conflict target */
})
.returning();
const row = rows[0];
if (row) {
await emitWebhookEvent({
type:
row.status === "canceled"
? "billing.subscription.canceled"
: "billing.subscription.updated",
data: {
id: row.id,
status: row.status,
variantId: row.variantId,
customerId: row.customerId,
},
});
}
return rows;
};Resolve organizationId / userId for billing events from the related customer.referenceId when you need tenant-scoped fan-out.
Add session-only webhook API routes
Create packages/api/src/modules/webhooks/router.ts with:
GET /: list endpoints for the current user or orgPOST /: create endpoint (HTTPS URL, event list, generate secret, return secret once)DELETE /:id: revokePOST /:id/rotate-secret: regenerate signing secretPOST /:id/ping: emitwebhook.pingGET /:id/deliveries: last 7 days of delivery rows
Protect with enforceAuth (and enforceMembership / org permissions for org-scoped endpoints). Register the router in packages/api/src/index.ts:
.route("/webhooks", webhooksRouter) API key management can stay on Better Auth endpoints (authClient.apiKey.create / .list / .delete) from the dashboard. No need to duplicate CRUD unless you want a Hono facade.
Build Settings → Developers UI
Add paths and nav entries next to security / billing:
settings: {
index: `${DASHBOARD_PREFIX}/settings`,
security: `${DASHBOARD_PREFIX}/settings/security`,
billing: `${DASHBOARD_PREFIX}/settings/billing`,
developers: `${DASHBOARD_PREFIX}/settings/developers`,
},Mirror the same under dashboard.organization(slug).settings.developers for org admins.
Reuse existing patterns:
| UI need | Mirror |
|---|---|
| Settings section shell | SettingsCard* in ~/modules/common/layout/dashboard/settings-card |
| Create + show secret once | Modal + copy button (passkeys / invite member flows) |
| Revoke with confirm | Passkeys ConfirmModal in modules/user/settings/security/passkeys.tsx |
| Delivery log table | MembersDataTable + useDataTable |
Two tabs on the page:
- API keys:
authClient.apiKey.list(), create with label, show full key once, revoke. - Webhooks: list endpoints, multi-select events, test ping, recent deliveries.
Keep copy short: show the key/secret once, then only prefixes / last-used metadata.
Verify the happy path
pnpm services:setup&&pnpm dev- Open Settings → Developers, create an API key, call a protected route with
x-api-key - Register a webhook.site HTTPS URL, subscribe to
webhook.ping, click Send test event - Confirm the signature header and
apiVersionon the received body - Accept an org invite and confirm
organization.member.joineddelivery - Revoke the key → same curl returns
401 - Disable the endpoint → no further deliveries
Document a customer verify snippet beside your public API docs (Node sample using verifySignature is enough for v1).
Security checklist
| Rule | Why |
|---|---|
| HTTPS-only endpoint URLs | Avoid cleartext payloads and SSRF to local networks |
| Hash secrets / keys at rest | Better Auth hashes API keys; do the same for webhook secrets |
| Constant-time signature compare | Prevent timing leaks (same idea as Lemon Squeezy inbound verify) |
| Reject stale timestamps | Replay protection (toleranceSec) |
| Session-only management APIs | Stolen API keys should not rotate webhooks |
| Idempotent delivery updates | Retries must not flip delivered → pending twice |
Delivery at scale
| Volume | Approach |
|---|---|
| Early / low traffic | Inline deliverWebhook + DB status columns |
| Serverless with retries | Upstash QStash signed HTTP jobs |
| Durable workflows | Trigger.dev or Inngest tasks keyed by deliveryId |
Always record pending → delivered/failed in Postgres so the dashboard stays the source of truth regardless of the worker.
Troubleshooting
| Symptom | What to check |
|---|---|
401 with a fresh key | Header name (x-api-key vs Authorization), key revoked/expired, CORS allowHeaders |
| Auth schema drift | Ran pnpm --filter @workspace/auth db:generate after adding the plugin? |
| Ping never arrives | Endpoint enabled, event list includes webhook.ping, HTTPS URL reachable from your host |
| Signature always invalid | Customer must verify t.body bytes exactly (raw JSON string you signed), not a re-serialized object |
| Duplicate customer side effects | Make their handlers idempotent on envelope id |
| Billing events missing org scope | Join customer.referenceId before emitWebhookEvent |
File structure
Checklist
| Layer | Done when… |
|---|---|
| Auth | Plugin enabled; apikey table migrated; client can create/list/delete |
| API auth | enableSessionForAPIKeys + CORS; x-api-key works on enforceAuth routes |
| Webhooks package | Emit → delivery row → signed POST → status update |
| CRUD | Session-only routes; secret shown once |
| UI | Developers settings for user (and org admin) |
| Events | At least ping + one real domain event from an existing hook |
| Docs for customers | Signature verification sample published |
Related
How is this guide?
Last updated on