Skip to content

API Keys & External API

  • Create the api_keys table, key management UI, and public API routes for external integrations

Prerequisite

Complete the Get Started guide first.

Permissions

Add these permissions to shared/permissions.ts if they don't already exist:

"api_keys.view": ["owner", "admin"],
"api_keys.create": ["owner", "admin"],
"api_keys.revoke": ["owner", "admin"],

Prompt 1 — API Keys

txt
Build the API key management system so external services can authenticate
with the app.

Database (via Supabase MCP):

Create an `api_keys` table — id (uuid, default gen_random_uuid(), primary
key), team_id (uuid, references teams(id) on delete cascade, not null),
created_by (uuid, references profiles(id), not null), name (text, not null),
key_prefix (text, not null), key_hash (text, not null), last_used_at
(timestamptz, nullable), expires_at (timestamptz, nullable), revoked_at
(timestamptz, nullable), created_at (timestamptz, default now()).

Enable RLS. Add select, insert, and update policies using `is_team_member()`
scoped to team_id.

Also add to realtime publication:
```sql
ALTER PUBLICATION supabase_realtime ADD TABLE api_keys;
ALTER TABLE api_keys REPLICA IDENTITY FULL;
```

Server routes (all use `authUser`):

- `GET /api/teams/[teamId]/api-keys` — uses
  `authUser(event, "api_keys.view")`. Returns all API keys for the team
  (NEVER return key_hash in the response), ordered by created_at desc.
  Include the creator's name by joining profiles.

- `POST /api/teams/[teamId]/api-keys` — uses
  `authUser(event, "api_keys.create")`. Reads { name } from the body.
  Generates a new API key:
  1. Generate 32 random bytes using Node's `crypto.randomBytes`
  2. Base64url encode the bytes to create the raw key string
  3. Create a prefix from the first 8 characters of the encoded key
  4. Hash the full key with SHA-256 using Node's `crypto.createHash`
  5. Convert the hash to a hex string
  6. Store team_id, name, key_prefix, key_hash, and created_by
  7. Return the full key in the response — this is the ONLY time it is
     ever visible. The server never stores the raw key.

- `PATCH /api/teams/[teamId]/api-keys/[keyId]` — uses
  `authUser(event, "api_keys.revoke")`. Sets revoked_at to now. Validates
  the key belongs to the team and is not already revoked.

UI:

Add an "API Keys" tab to the settings page. Follow the existing tab pattern
in `app/pages/settings/index.vue` (or create `app/pages/settings/api-keys.vue`
if settings uses file-based routing for tabs).

The API Keys settings tab should:

- List all keys in a table showing: name, prefix (displayed as
  `sk_...{prefix}`), created by (name), created date, last used date
  (or "Never"), and status. Status is a badge: "Active" (green) if not
  revoked and not expired, "Revoked" (red) if revoked_at is set,
  "Expired" (neutral) if expires_at is in the past.

- A "Create Key" button at the top right, wrapped in
  `CanAccess permission="api_keys.create"`. Opens a `UModal` with a
  single field: key name (required). Show loading on submit.

- After creation, show a second modal displaying the full API key with
  a copy-to-clipboard button and a warning message: "Copy this key now.
  You will not be able to see it again." The modal should only be
  dismissible after the user has seen the key.

- Each active key row has a "Revoke" button wrapped in
  `CanAccess permission="api_keys.revoke"`. Clicking it shows a
  confirmation modal before revoking. Show per-row loading state.

- Show an empty state when no keys exist with a message like
  "No API keys yet. Create one to allow external services to access
  your data."

- Show `USkeleton` placeholders on initial load.

Add "API Keys" to the settings navigation tabs with the icon
`i-solar-key-bold-duotone`.

Regenerate TypeScript types via Supabase MCP and save to
`shared/types/database.types.ts`.

Prompt 2 — External API

txt
Build the external API so tools like n8n, Zapier, or custom integrations
can interact with the app's data programmatically.

1. Create `server/utils/authApiKey.ts` with an `authApiKey` function that
   takes an H3 event. It should:

   - Read the `Authorization` header and extract the Bearer token — throw
     a 401 error if the header is missing or not in `Bearer <token>` format
   - Hash the token with SHA-256 using Node's `crypto.createHash` (same
     approach as the key generation step) and convert to hex
   - Look up the hash in the `api_keys` table using the service role
     Supabase client (`serverSupabaseServiceRole`)
   - Throw 401 if not found
   - Throw 401 if `revoked_at` is set (key has been revoked)
   - Throw 401 if `expires_at` is set and is in the past (key has expired)
   - Update `last_used_at` to now (fire and forget — do not await)
   - Return `{ apiKey, client }` where `client` is the service role
     Supabase client and `apiKey` is the full row including team_id
     and created_by

2. Create public API routes under `/api/v1/`. These routes do NOT use
   `authUser` — they use `authApiKey` instead. All queries are scoped to
   the API key's `team_id`.

   Analyze the app's existing database tables (exclude system tables like
   teams, profiles, members, invitations, api_keys, rate_limits, and
   schema_migrations). For each domain table found, create:

   - `GET /api/v1/{table}` — returns all records for the key's team,
     ordered by created_at desc. Support `?limit=` and `?offset=` for
     pagination (default limit 50, max 100).

   - `POST /api/v1/{table}` — creates a record. Accept all non-system
     columns from the body. Set team_id from the API key and created_by
     from the API key's created_by. Validate that required columns (not
     null without defaults) are present.

   - `PATCH /api/v1/{table}/[id]` — updates a record. Validate the record
     belongs to the API key's team before updating.

   Include relevant joins where foreign keys exist (e.g., if a table
   references another by ID, include the referenced name in GET responses).

Both `authUser` (browser sessions) and `authApiKey` (API keys) return the
same service role Supabase client. The only difference is how the caller
authenticates. This is what makes API keys useful — external services can
manage data without needing a browser session.

Error responses for the v1 routes should use a consistent JSON format:
`{ "error": "message" }` with appropriate HTTP status codes (400 for
validation errors, 401 for auth errors, 404 for not found).

Testing with curl

Once built, you can test the external API with curl:

bash
# List records
curl -H "Authorization: Bearer YOUR_API_KEY" http://localhost:3000/api/v1/your-table

# Create a record
curl -X POST -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "Example", "email": "test@example.com"}' \
  http://localhost:3000/api/v1/your-table