Backend

API keys

Personal keys with read or write scopes and expiry, hashed at rest and accepted as Bearer tokens by every endpoint.

pleate_backend.apikeys lets users create personal keys for scripts, CI and integrations. A key looks like pl_<prefix>_<secret>: 8 characters of prefix, stored and indexed so the key can be found and shown, and 32 random bytes of secret. Only the SHA-256 of the whole key is stored; the key itself is returned once, by the create call.

Personal API keys

Create a key, copy it while it is shown, revoke it. useApiKeys maps the API onto ApiKeyList.

Mock backendSigned in as ada@plate.dev

Using a key

sh
curl https://plate.app/api/catalog/products \
  -H "Authorization: Bearer pl_k3J9xQ2m_8s7d6f5g4h3j2k1l0z9x8c7v6b5n4m3q2w1e0r9t8y7u6"

APIKeyAuthentication runs before the session check on every endpoint. It finds the key by prefix, compares the hash in constant time and sets request.auth to the APIKey. Requests with a key skip CSRF, since they carry no cookie. last_used_at and last_used_ip are written at most once a minute.

  • read keys may only use GET, HEAD and OPTIONS. write implies read.
  • Keys can expire after 1 to 3650 days, or never.
  • A user holds at most 25 active keys.
  • Keys of deactivated users stop working at once.

From the TypeScript client, on a server or in a script:

ts
import { createPleateClient } from "pleate-client";

const plate = createPleateClient({
  baseUrl: "https://plate.app/api",
  headers: { Authorization: `Bearer ${process.env.PLATE_API_KEY}` },
  csrf: false,
});

const page = await plate.list("/catalog/products", { search: "oat", page_size: 100 });

Endpoints

Mounted at /api/api-keys. Managing keys needs a session: a request made with an API key gets api_key_not_allowed, so a leaked key cannot mint more keys. The same goes for every account security endpoint: password and email change, session revocation, account update and deletion, the data export, 2FA setup and removal, and linking or unlinking social accounts. Changing or resetting the password revokes every key.

MethodPathPurpose
GET/api-keysMy keys. Page list, ?search= name or prefix, ?ordering=created_at,name,last_used_at,expires_at.
POST/api-keys{name, scopes?: ["read"] | ["read", "write"], expires_in_days?} → 201 with key, the only time it is returned.
GET/api-keys/<id>One key without key.
PATCH/api-keys/<id>{name}
DELETE/api-keys/<id>Revokes (sets revoked_at). 204.
POST /api/api-keys → 201
{
  "id": "6720c9a1b2c3d4e5f6a7b8c9",
  "name": "CI deploys",
  "prefix": "k3J9xQ2m",
  "display": "pl_k3J9xQ2m_...",
  "scopes": ["read", "write"],
  "status": "active",
  "expires_at": "2027-01-24T10:00:00Z",
  "last_used_at": null,
  "last_used_ip": null,
  "created_at": "2026-10-26T10:00:00Z",
  "revoked_at": null,
  "key": "pl_k3J9xQ2m_8s7d6f5g4h3j2k1l0z9x8c7v6b5n4m3q2w1e0r9t8y7u6"
}

status is active, expired or revoked.

Helpers

python
from pleate_backend.apikeys.services import create_key, find_key
from pleate_backend.apikeys.permissions import DenyAPIKey

key, raw = create_key(user, "Nightly export", scopes=["read"], expires_in_days=90)

class BillingPortalView(APIView):
    permission_classes = [IsAuthenticated, DenyAPIKey]   # session only

Error codes

CodeStatusWhen
invalid_api_key401Unknown, revoked, or belonging to a deactivated user.
api_key_expired401Past expires_at.
insufficient_scope403A read key on POST, PUT, PATCH or DELETE.
api_key_not_allowed403A session only endpoint called with a key.
api_key_limit40025 active keys already.