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
usedAtatomically 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:
POST /api/auth/logout— explicit session end. Every tab, every device, every script that still holds an old JWT stops working instantly.- 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 displayprefixso 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.
| Scope | Unlocks |
|---|---|
estimations:read | GET /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:write | POST / 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:suggestion | GET /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:read | GET /api/estimations/:id/tasks, POST /api/estimations/:id/tasks/preview |
tasks:write | POST /api/estimations/:id/tasks/generate, PUT /api/estimations/:id/tasks/:taskId, DELETE /api/estimations/:id/tasks/:taskId |
tasks:export | POST /api/estimations/:id/tasks/export (Jira / GitHub export) |
templates:read | GET /api/templates, GET /api/templates/:id |
reviews:read | GET /api/estimations/:id/reviews, GET /api/estimations/:id/reviews/mine, GET /api/estimations/:id/reviews/:reviewId |
reviews:write | POST /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:manage | GET / POST /api/estimations/:id/shares, POST /api/estimations/:id/shares/:shareId/resend, DELETE /api/shares/:shareId, GET /api/shares/me |
webhooks:manage | Full /api/webhooks router (list, create, update, delete, rotate secret, send test, list deliveries) |
admin | Superuser — 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
- Go to
/profile/api-keys. - Click Create API key.
- Name it (max 80 chars — use something that identifies the integration, e.g.
"CI: nightly export"). - Pick the scopes.
- Optionally set an expiry.
- 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.
| Status | When | Example message |
|---|---|---|
401 | Missing Authorization header, malformed token, expired JWT, revoked or expired API key. | Missing or invalid authorization header, api_key_invalid, Invalid or expired token |
403 | Authenticated 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 |
429 | Rate 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:exportscope to a connected Jira project.