Authentication

How to authenticate requests to the Forkpoint API using JWT bearer tokens or API keys with scopes.

Authentication

Forkpoint exposes a REST API under https://forkpoint.app/api/. Every request must authenticate using one of two mechanisms: a short-lived JWT (interactive session) or a long-lived API key (server-to-server). Both arrive in the same Authorization: Bearer <token> header — the server inspects the token shape to decide how to validate it.

JWT bearer tokens

Tokens are issued by POST /api/auth/login (email + password) or by the Google OAuth callback. They are signed, short-lived (1 hour), and carry the authenticated user’s id. Client-side apps store them and attach them on every request:

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

A JWT-authenticated request is treated as a full user session: every scope is implicit, the same way an interactive user in the web app has access to all of their own data. JWTs are the right choice for first-party clients.

Google OAuth callback

The Google sign-in flow deliberately does NOT deliver the JWT in the redirect URL. Query strings end up in browser history, Referer headers, and CDN access logs, which makes a long-lived JWT radioactive in a URL.

Instead, the API redirects the browser to:

<web>/auth/callback?code=<one-time-code>

The code is a 32-byte random value, stored on the server as a SHA-256 hash, valid for 60 seconds, single-use. The browser then POSTs it to POST /api/auth/exchange with body { "code": "<one-time-code>" } and receives the real JWT in the response body (data.token). The endpoint requires no authentication — the code itself is the proof of identity.

Failure modes (all 400, code INVALID_EXCHANGE_CODE):

  • Unknown code (never issued or already reaped by the TTL index).
  • Expired code (older than 60 s).
  • Already-consumed code (we mark usedAt atomically on first success).

The frontend collapses all three into a single “sign-in link expired, please try again” state and sends the user back to the landing page.

Token revocation (logout, password reset)

Every JWT carries a tokenVersion claim pinned to the user’s current value at sign time. The auth middleware compares the claim against the latest User.tokenVersion on every request — a mismatch yields 401 TOKEN_REVOKED.

Two events bump the counter:

  1. POST /api/auth/logout — explicit session end. Every tab, every device, every script that still holds an old JWT stops working instantly.
  2. Password reset confirm (POST /api/auth/reset-password) — same mechanic; a stolen token from before the reset is now useless.

API keys are unaffected — they have their own revocation path (DELETE /api/api-keys/:id).

API keys

API keys are long-lived credentials you generate at /profile/api-keys for scripts and external integrations. They have the format:

fp_live_<44-char base64url body>

You attach an API key with the same header as a JWT — the server uses the fp_live_ prefix to short-circuit and validate it against the API key store:

Authorization: Bearer fp_live_AbCdEfGhIjKlMnOpQrStUvWxYz012345...

Unlike JWTs, API keys are scope-gated. Each key carries an explicit list of scopes; calls to endpoints outside those scopes return 403 (see Error responses).

Security notes:

  • The plaintext key is returned exactly once, at creation and rotation time. Forkpoint only stores a SHA-256 hash. If you lose it, rotate.
  • The first 12 characters of the plaintext (fp_live_AbCd) are retained as a display prefix so you can identify a key in the UI without exposing the full value.
  • Revoked or expired keys fail at the auth middleware with 401.

Scopes

Scopes follow a least-privilege model. Grant the narrowest set that gets the job done.

ScopeUnlocks
estimations:readGET /api/estimations, GET /api/estimations/:id, decision tree reads (GET /api/estimations/:id/tree, GET /api/estimations/:id/tree/hours), version reads (GET /api/estimations/:id/versions, GET /api/estimations/:id/versions/:versionId, diffs)
estimations:writePOST / PUT / DELETE /api/estimations, submit/mark-exported/mark-tasks-generated transitions, milestone and timeline edits, decision tree mutations (nodes, options, select/deselect), version creation and thread management, template create/delete, apply-template
estimations:suggestionGET /api/estimations/:id/suggestion — the AI-generated hours hint. Separate scope so you can grant it to thin clients (e.g. chatbots) without giving full read access to every estimation field
tasks:readGET /api/estimations/:id/tasks, POST /api/estimations/:id/tasks/preview
tasks:writePOST /api/estimations/:id/tasks/generate, PUT /api/estimations/:id/tasks/:taskId, DELETE /api/estimations/:id/tasks/:taskId
tasks:exportPOST /api/estimations/:id/tasks/export (Jira / GitHub export)
templates:readGET /api/templates, GET /api/templates/:id
reviews:readGET /api/estimations/:id/reviews, GET /api/estimations/:id/reviews/mine, GET /api/estimations/:id/reviews/:reviewId
reviews:writePOST /api/estimations/:id/reviews (request a review), POST /api/estimations/:id/reviews/:reviewId/approve, POST .../request-changes, POST .../comments — lets automations approve, block, or comment on estimations
shares:manageGET / POST /api/estimations/:id/shares, POST /api/estimations/:id/shares/:shareId/resend, DELETE /api/shares/:shareId, GET /api/shares/me
webhooks:manageFull /api/webhooks router (list, create, update, delete, rotate secret, send test, list deliveries)
adminSuperuser — satisfies every other scope. Hand out sparingly; a leaked admin key is as bad as a leaked session.

Template creation and deletion currently require estimations:write (templates are derived from estimations). If you just need to read or apply templates, templates:read + estimations:write is the minimum.

Creating an API key

  1. Go to /profile/api-keys.
  2. Click Create API key.
  3. Name it (max 80 chars — use something that identifies the integration, e.g. "CI: nightly export").
  4. Pick the scopes.
  5. Optionally set an expiry.
  6. Copy the plaintext token from the one-time reveal dialog. Forkpoint will not show it again.

Programmatically, the same flow is:

curl -X POST https://forkpoint.app/api/api-keys \
  -H 'Authorization: Bearer <YOUR_JWT>' \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "CI: nightly export",
    "scopes": ["estimations:read", "tasks:export"],
    "expiresAt": null
  }'

The response includes data.apiKey (the metadata: id, prefix, scopes…) and data.plaintext (the full fp_live_... token). Store the plaintext in your secret manager immediately.

Note: the /api/api-keys endpoints themselves require a JWT session — you can’t bootstrap a key using another key. This is intentional: key management is an interactive-user operation.

Rotating a key

Rotation invalidates the old plaintext and returns a new one. The key id stays the same, so any bookkeeping tied to that id survives.

curl -X POST https://forkpoint.app/api/api-keys/<key-id>/rotate \
  -H 'Authorization: Bearer <YOUR_JWT>'
// Node 20+ with global fetch
const res = await fetch(
  `https://forkpoint.app/api/api-keys/${keyId}/rotate`,
  {
    method: 'POST',
    headers: { Authorization: `Bearer ${jwt}` },
  },
);
const { data } = await res.json();
console.log(data.plaintext); // fp_live_...
import requests

res = requests.post(
    f"https://forkpoint.app/api/api-keys/{key_id}/rotate",
    headers={"Authorization": f"Bearer {jwt}"},
    timeout=10,
)
res.raise_for_status()
print(res.json()["data"]["plaintext"])

The old plaintext stops working immediately — any client still using it will start getting 401 on the next call. Roll your consumers forward before you rotate.

Revoking a key

Revocation is a soft-delete — the key stays in the database (so audit trails resolve), but the entity is marked revokedAt and fails validation at the middleware layer.

curl -X DELETE https://forkpoint.app/api/api-keys/<key-id> \
  -H 'Authorization: Bearer <YOUR_JWT>'

Once revoked, a key cannot be un-revoked. Create a new one.

Error responses

All error responses share the same envelope:

{
  "success": false,
  "message": "api_key_insufficient_scope"
}

The message is a snake_case key; the frontend translates it for display. Machine clients should match on the key, not the translated text.

StatusWhenExample message
401Missing Authorization header, malformed token, expired JWT, revoked or expired API key.Missing or invalid authorization header, api_key_invalid, Invalid or expired token
403Authenticated via API key but the key lacks the scope the endpoint requires. JWT sessions never receive a 403 from the scope gate.api_key_insufficient_scope
429Rate limit hit. Back off and retry with jitter. TODO: verify — a global rate limit middleware applies to /api/auth/login but a public-API rate-limit policy is not documented in source yet.

Next

  • Webhook events — every event you can subscribe to, with payload shapes.
  • Jira integration — how to wire the tasks:export scope to a connected Jira project.