For the complete documentation index, see llms.txt. Prefer markdown by appending .md to documentation URLs or sending Accept: 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

  1. Enable Better Auth's API Key plugin with enableSessionForAPIKeys, regenerate auth schema, and allow x-api-key in CORS.
  2. Add webhook_endpoint + webhook_delivery tables and a small @workspace/webhooks package (emit, sign, deliver, verify helper).
  3. Expose session-only CRUD under Settings → Developers (keys + endpoints).
  4. Call emitWebhookEvent from auth hooks and billing upserts.
  5. Deliver with retries and a Stripe-style HMAC signature customers can verify.

Inbound vs outbound

DirectionWho calls whomIn TurboStarter today
InboundBilling provider → your APIShips at POST /api/billing/webhook/:provider. See Billing webhooks.
OutboundYour app → customer HTTPS URLThis recipe
API keysCustomer → your Hono routesThis 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

LayerLocation
Auth pluginpackages/auth + regenerated packages/db/src/schema/auth.ts
CORS / headerspackages/api/src/index.ts (x-api-key)
Webhook schema + emit/deliverpackages/db + new @workspace/webhooks
CRUD APIpackages/api/src/modules/webhooks/ (session auth only)
Dashboard UISettings → 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.22

Register it on the server:

packages/auth/src/server.ts
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:

packages/auth/src/client/web.ts
export { apiKeyClient } from "@better-auth/api-key/client"; 
apps/web/src/lib/auth/client.ts
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:migrate

Org-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):

packages/api/src/index.ts
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/organizations

Server-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):

packages/db/src/schema/webhook.ts
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.ts

Event catalog

Start with a small set wired to hooks that already exist in core:

EventEmit from
user.createdpackages/auth/src/hooks/user/create.ts (after hook)
organization.member.joinedafterAddMember / afterAcceptInvitation in hooks/organization/add-member.ts
organization.member.removedafterRemoveMember in hooks/organization/remove-member.ts
billing.subscription.updatedAfter upsertSubscription in packages/billing/shared/src/server/subscription.ts
billing.subscription.canceledSame path when status is canceled
webhook.pingDashboard “Send test event”

Payload shape:

packages/webhooks/src/events.ts
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)

packages/webhooks/src/sign.ts
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

packages/webhooks/src/emit.ts
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:

packages/auth/src/hooks/user/create.ts
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:

packages/auth/src/hooks/organization/add-member.ts
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:

packages/billing/shared/src/server/subscription.ts
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 org
  • POST /: create endpoint (HTTPS URL, event list, generate secret, return secret once)
  • DELETE /:id: revoke
  • POST /:id/rotate-secret: regenerate signing secret
  • POST /:id/ping: emit webhook.ping
  • GET /: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:

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:

apps/web/src/config/paths.ts
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 needMirror
Settings section shellSettingsCard* in ~/modules/common/layout/dashboard/settings-card
Create + show secret onceModal + copy button (passkeys / invite member flows)
Revoke with confirmPasskeys ConfirmModal in modules/user/settings/security/passkeys.tsx
Delivery log tableMembersDataTable + useDataTable

Two tabs on the page:

  1. API keys: authClient.apiKey.list(), create with label, show full key once, revoke.
  2. 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

  1. pnpm services:setup && pnpm dev
  2. Open Settings → Developers, create an API key, call a protected route with x-api-key
  3. Register a webhook.site HTTPS URL, subscribe to webhook.ping, click Send test event
  4. Confirm the signature header and apiVersion on the received body
  5. Accept an org invite and confirm organization.member.joined delivery
  6. Revoke the key → same curl returns 401
  7. 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

RuleWhy
HTTPS-only endpoint URLsAvoid cleartext payloads and SSRF to local networks
Hash secrets / keys at restBetter Auth hashes API keys; do the same for webhook secrets
Constant-time signature comparePrevent timing leaks (same idea as Lemon Squeezy inbound verify)
Reject stale timestampsReplay protection (toleranceSec)
Session-only management APIsStolen API keys should not rotate webhooks
Idempotent delivery updatesRetries must not flip deliveredpending twice

Delivery at scale

VolumeApproach
Early / low trafficInline deliverWebhook + DB status columns
Serverless with retriesUpstash QStash signed HTTP jobs
Durable workflowsTrigger.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

SymptomWhat to check
401 with a fresh keyHeader name (x-api-key vs Authorization), key revoked/expired, CORS allowHeaders
Auth schema driftRan pnpm --filter @workspace/auth db:generate after adding the plugin?
Ping never arrivesEndpoint enabled, event list includes webhook.ping, HTTPS URL reachable from your host
Signature always invalidCustomer must verify t.body bytes exactly (raw JSON string you signed), not a re-serialized object
Duplicate customer side effectsMake their handlers idempotent on envelope id
Billing events missing org scopeJoin customer.referenceId before emitWebhookEvent

File structure

server.ts - apiKey() plugin
user/create.ts - user.created emit
organization/add-member.ts - member.joined emit
webhook.ts - endpoints + deliveries
events.ts
sign.ts
emit.ts
deliver.ts
middleware.ts - session or API key
router.ts - session CRUD + ping

Checklist

LayerDone when…
AuthPlugin enabled; apikey table migrated; client can create/list/delete
API authenableSessionForAPIKeys + CORS; x-api-key works on enforceAuth routes
Webhooks packageEmit → delivery row → signed POST → status update
CRUDSession-only routes; secret shown once
UIDevelopers settings for user (and org admin)
EventsAt least ping + one real domain event from an existing hook
Docs for customersSignature verification sample published

How is this guide?

Last updated on

On this page

Ship your startup everywhere. In minutes.Try TurboStarter