Authentication
All endpoints under /flare/v1/* require authentication via an API key. Canonical Flare health endpoints (/flare/health/live and /flare/health/ready) are public and do not require authentication.
Platform endpoints (/platform/v1/*) use JWT tokens obtained from the login endpoint. Email and password management endpoints are anonymous and IP-rate-limited.
API Key Format
API keys follow a structured prefix format that indicates the environment:
| Prefix pattern | Environment |
|---|---|
zf_live_sk_... | Production |
zf_test_sk_... | Test |
Each key has the shape zf_<env>_sk_<32-char-hex> — the zf_<env>_sk_ prefix is 11 characters and the random body is a 32-character lowercase hex string (16 bytes of cryptographically random data), for a total length of 43 characters.
The first 24 characters of the key serve as a prefix used for constant-time project lookup. The full key is verified against a stored SHA-256 hash to prevent timing attacks.
Using the API Key
Include your API key in the Authorization header as a Bearer token on every /flare/v1/* request:
Authorization: Bearer <your_api_key>
curl example
curl -X GET https://api.zeridion.com/flare/v1/jobs \
-H "Authorization: Bearer zf_test_sk_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"
C# HttpClient example
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", "zf_test_sk_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4");
var response = await client.GetAsync("https://api.zeridion.com/flare/v1/jobs");
If you are using the Zeridion.Flare SDK, authentication is handled automatically. Set your API key in configuration:
builder.Services.AddZeridionFlare(options =>
{
options.ApiKey = "zf_test_sk_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4";
});
Request ID
Every authenticated request is assigned a unique request ID (a ULID). This ID:
- Is generated before any endpoint logic runs
- Is included in all error responses as
request_id - Can be used when contacting support to trace a specific request
Auth Flow
The Authorization header is verified per request and resolves to a project ID and plan.
Error Responses
If authentication fails, the API returns a 401 Unauthorized response. The error code differs by surface:
/flare/v1/*(API-key surface) — every authentication failure returns codeunauthorized(ApiKeyAuthMiddleware)./platform/v1/auth/login(JWT surface) — a wrong email, password, or TOTP code returns codeinvalid_credentialsinstead. SeePOST /platform/v1/auth/loginbelow.
| Status | Code | Surface | When |
|---|---|---|---|
| 401 | unauthorized | /flare/v1/* | Missing Authorization header, invalid Bearer format, unknown key, hash mismatch |
| 401 | invalid_credentials | /platform/v1/auth/login | Wrong email, wrong password, or wrong TOTP code on login |
{
"error": {
"code": "unauthorized",
"message": "Invalid API key.",
"request_id": "01JAXBKM3N4P5Q6R7S8T9UVWXY"
}
}
See the Errors page for the full error code reference and the Rate Limits page for request throttling details.
Idempotency
Idempotency is supported in two complementary ways:
- The body-level
idempotency_keyfield onPOST /flare/v1/jobs(legacy surface — see Jobs API). A duplicate request with the same value and the same body returns the original201 Createdresponse plus anIdempotent-Replay: trueheader; a duplicate value paired with a different body returns409 idempotency_key_reuse. - The RFC-style
Idempotency-Key: <value>HTTP header on any state-changing endpoint. A duplicate request with the same body returns the original response status and body plus anIdempotent-Replay: trueheader. A duplicate key with a different body returns409 idempotency_key_reuse. Keys expire 24 h after first use.
Dashboard auth (JWT)
The endpoints under /platform/v1/auth/* back the dashboard's signup, login, profile, password, and 2FA flows. The signature-verified billing webhook and a few admin endpoints aside, every other /platform/v1/* route consumes a JWT minted by POST /platform/v1/auth/login.
Anonymous endpoints (signup, verify, login, resend, forgot, reset) are IP-rate-limited to 20 requests per 15-minute window per IP. Exceeding the limit returns 429 Too Many Requests with code rate_limit_exceeded. Signed inbound webhook paths (Stripe, SendGrid) are exempt.
Authenticated endpoints (logout, change-password, me, profile, all 2FA endpoints) accept either an Authorization: Bearer <jwt> header or the zf_jwt httpOnly Secure SameSite=Strict cookie that login installs. Cross-origin browser callers must use credentials: 'include' for the cookie to round-trip.
POST /platform/v1/auth/signup
Create a new account. The user receives a verification email containing a single-use token (24 h TTL). Until they call POST /platform/v1/auth/verify with the token, login returns 403 email_not_verified.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
email | string | Yes | Email address. Must be a valid address; max 320 characters. |
password | string | Yes | Account password. Min 8, max 128 characters. |
accept_terms | boolean | Yes | Must be true. The signup is recorded with a TermsAcceptedAt timestamp. |
plan | string | No | Plan slug the user intends to start on (free, starter, pro, business). Used for the post-verify upsell flow. Defaults to free. |
{
"email": "you@example.com",
"password": "correct-horse-battery-staple",
"accept_terms": true,
"plan": "free"
}
Response — 200 OK
{
"user_id": "usr_01JAXBKM3N4P5Q6R7S8T9UVWXY",
"email": "you@example.com",
"message": "Account created. Check your email to verify."
}
Error responses
| Status | Code | When |
|---|---|---|
| 400 | validation_error | Email is invalid, password too short/long, or accept_terms is missing. |
| 409 | email_in_use | An account with this email already exists. |
POST /platform/v1/auth/verify
Exchange the single-use token from the verification email for a verified account. On first verify the server also auto-creates the user's first project and an owner membership row.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
token | string | Yes | Token from the verification email. |
Response — 200 OK
{
"user_id": "usr_01JAXBKM3N4P5Q6R7S8T9UVWXY",
"project_id": "prj_01JAXBKM3N4P5Q6R7S8T9UVWXY",
"project_name": "you's Project",
"api_key": "zf_live_sk_<plaintext_once>",
"message": "Email verified. Your first project and API key have been created."
}
The
api_keyis shown exactly once here. Store it before closing the response.
Error responses
| Status | Code | When |
|---|---|---|
| 400 | invalid_verification_token | Token not found, already used, or expired (24-hour TTL). |
POST /platform/v1/auth/login
Exchange email and password (and a TOTP code if 2FA is enrolled) for a JWT.
The JWT is never returned in the response body. It is set on a zf_jwt httpOnly Secure SameSite=Strict cookie that the JWT bearer handler reads back on every subsequent request.
Brute-force defense. Two independent counters guard this endpoint and both fire on every login attempt — they work together so a single attacker IP can't credential-stuff a single account AND a botnet can't credential-stuff one account from many IPs:
- Per-IP — 20 POSTs per 15-minute sliding window per IP across all anonymous
/platform/v1/auth/*endpoints (signup, verify, login, resend, forgot, reset). Hitting the cap returns429 rate_limit_exceeded. The per-IP counter increments on every POST (successful or failed), not just failures — abusive clients can't simply rotate passwords for free.- Per-account — 10 failed attempts per 15-minute sliding window per user. Hitting the threshold returns
429 account_lockedwith aRetry-Afterheader. The check runs before password verification so the lockout latency does not leak password-complexity timing. A successful login resets the counter to 0.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
email | string | Yes | Registered email address. |
password | string | Yes | Account password. |
totp_code | string | Conditional | 6-digit TOTP code. Required when 2FA is enabled on the account. |
recovery_code | string | Conditional | Single-use recovery code in the format xxxx-xxxx. Alternative to totp_code. |
{
"email": "you@example.com",
"password": "correct-horse-battery-staple",
"totp_code": "123456"
}
Response — 200 OK
{
"user_id": "usr_01JAXBKM3N4P5Q6R7S8T9UVWXY",
"email": "you@example.com",
"email_verified": true,
"recovery_code_used": false,
"projects": [
{
"id": "prj_01...",
"name": "My Project",
"plan": "starter",
"api_key_prefix": "zf_live_sk_a1b2c3d4e5f6a" // gitleaks:allow
}
]
}
The Set-Cookie header on the response carries the JWT:
Set-Cookie: zf_jwt=<jwt>; HttpOnly; Secure; SameSite=Strict; Path=/
Error responses
| Status | Code | When |
|---|---|---|
| 401 | invalid_credentials | Email or password is wrong, or TOTP code is wrong. |
| 403 | email_not_verified | Email has not been verified yet. |
| 403 | totp_required | 2FA is enabled but no totp_code (or recovery_code) was supplied. |
| 429 | account_locked | Per-account failed-attempt threshold reached. Includes Retry-After. |
| 429 | rate_limit_exceeded | Per-IP rate limit exceeded. |
POST /platform/v1/auth/logout
Clear the zf_jwt cookie. Idempotent — returns 204 regardless of whether the cookie was set on the way in.
Request
POST /platform/v1/auth/logout
No request body.
Response — 204 No Content
The Set-Cookie header on the response invalidates the JWT cookie:
Set-Cookie: zf_jwt=; HttpOnly; Secure; SameSite=Strict; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT
POST /platform/v1/auth/resend
Resend the email verification link for an unverified account. Sends a fresh 24-hour token.
To prevent email enumeration, the response is always 200 OK with the same message regardless of whether the email is registered or already verified.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
email | string | Yes | Email address of the unverified account. Max 320 characters. |
Response — 200 OK
{
"message": "If your email is registered and unverified, a new verification link has been sent."
}
Rate limit: 20 POST requests per 15 minutes per IP (shared with all anonymous /platform/v1/auth/* endpoints).
POST /platform/v1/auth/forgot
Request a password reset link. A reset token valid for 1 hour is emailed to the address if it is registered.
To prevent timing-based email enumeration, the endpoint always waits at least 300 ms before responding and always returns the same message.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
email | string | Yes | Registered email address. Max 320 characters. |
Response — 200 OK
{
"message": "If an account with that email exists, a password reset link has been sent."
}
Rate limit: 20 POST requests per 15 minutes per IP.
POST /platform/v1/auth/reset
Reset the account password using the token from the forgot-password email. The server bumps User.PasswordChangedAt, which invalidates every JWT whose iat (issued-at) precedes the change — so any existing session anywhere is logged out.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
token | string | Yes | Reset token from the email link. |
new_password | string | Yes | New password. Min 10, max 128 characters. |
Response — 200 OK
{
"message": "Password has been reset. You can now log in with your new password."
}
Error responses
| Status | Code | When |
|---|---|---|
| 400 | invalid_reset_token | Token not found, already used, or expired (1-hour TTL). |
POST /platform/v1/auth/change-password
Change the password for the currently logged-in user. Like /auth/reset, this updates PasswordChangedAt and invalidates older JWTs.
Authentication: JWT required.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
current_password | string | Yes | The user's existing password. |
new_password | string | Yes | New password. Min 10, max 128 characters. |
Response — 200 OK
{ "message": "Password updated." }
Error responses
| Status | Code | When |
|---|---|---|
| 401 | unauthorized | Missing or invalid JWT. |
| 401 | invalid_credentials | current_password is wrong. |
GET /platform/v1/auth/me
Return the full profile object the dashboard shell hydrates on every page load: the user, the account, all owned projects (with API key prefixes only — never plaintext), and any product entitlements.
Authentication: JWT required.
Response — 200 OK
{
"user_id": "usr_01JAXBKM3N4P5Q6R7S8T9UVWXY",
"email": "you@example.com",
"email_verified": true,
"created_at": "2025-01-01T00:00:00Z",
"is_admin": false,
"display_name": "Sam",
"timezone": "Europe/London",
"locale": "en-GB",
"totp_enabled": true,
"account": {
"id": "acct_01...",
"name": "Sam's Account"
},
"products": [
{ "product_slug": "flare", "enabled": true, "plan": "starter" }
],
"projects": [
{
"id": "prj_01...",
"name": "My Project",
"plan": "starter",
"api_key_prefix": "zf_live_sk_a1b2c3d4e5f6a" // gitleaks:allow
}
]
}
| Field | Type | Description |
|---|---|---|
is_admin | boolean | true only for Zeridion staff accounts; surfaced so the dashboard can render admin-only UI. |
totp_enabled | boolean | true once the user has completed TOTP enrollment via /2fa/verify. |
account | object | The user's Account record. If no row exists yet a synthetic placeholder ({ "id": "", "name": "<email-local-part>'s Account" }) is returned. |
products | array | Per-product entitlement rows. |
projects | array | All projects owned by the user, ordered by creation time. API keys are exposed only as their 24-character prefix. |
Error responses
| Status | Code | When |
|---|---|---|
| 401 | unauthorized | Missing or invalid JWT. |
| 404 | user_not_found | The authenticated user no longer exists. |
PATCH /platform/v1/auth/profile
Update mutable profile fields (display name, timezone, locale). Each field is optional; missing fields are left unchanged.
Authentication: JWT required.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
display_name | string | null | No | Trimmed; max 100 characters. Pass an empty string to clear. |
timezone | string | No | An IANA timezone identifier (e.g. America/New_York). Validated server-side via TimeZoneInfo.FindSystemTimeZoneById. |
locale | string | No | A BCP-47 locale (e.g. en-GB, pt-BR). Validated against the runtime culture catalog — synthetic / unknown locales are rejected. |
Response — 200 OK
{ "message": "Profile updated." }
Error responses
| Status | Code | When |
|---|---|---|
| 400 | display_name_too_long | display_name exceeds 100 characters. |
| 400 | invalid_timezone | timezone is not a known IANA identifier. |
| 400 | invalid_locale | locale is not a real BCP-47 culture in the runtime catalog. |
| 401 | unauthorized | Missing or invalid JWT. |
| 404 | user_not_found | The authenticated user no longer exists. |
Two-factor authentication (TOTP)
Zeridion supports TOTP (RFC 6238) two-factor authentication via the standard otpauth:// provisioning URI, which any authenticator app (1Password, Authy, Google Authenticator, etc.) can scan. The TOTP secret is encrypted at rest with AES-256-GCM via EncryptedStringConverter, using the master key from Encryption:MasterKey — even a database dump cannot recover the secret without the key.
The verification window is ±1 step (30 s) to tolerate clock skew. The server persists LastTotpStep per user so that a code accepted at step N cannot be replayed at step N+1 to defeat one-time-use.
All four endpoints below require a valid JWT.
POST /platform/v1/auth/2fa/setup
Generate a fresh TOTP secret and provisioning URI for the current user. Refuses with 409 totp_already_enabled if 2FA is already active — call /2fa/disable first to start over.
Response — 200 OK
{
"secret": "JBSWY3DPEHPK3PXP",
"otpauth_uri": "otpauth://totp/Zeridion:you@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Zeridion&algorithm=SHA1&digits=6&period=30"
}
| Field | Type | Description |
|---|---|---|
secret | string | Base32-encoded shared secret. Display this so the user can enter it manually if they cannot scan the QR code. |
otpauth_uri | string | Standard otpauth:// URI to render as a QR code. |
2FA is not active after
/setup— the user must enter a code from their authenticator and submit it via/2fa/verifyto complete enrollment.
Error responses
| Status | Code | When |
|---|---|---|
| 401 | unauthorized | Missing or invalid JWT. |
| 409 | totp_already_enabled | 2FA is already enabled. |
POST /platform/v1/auth/2fa/verify
Confirm enrollment by submitting a code from the authenticator app. On success, 2FA is activated for the account and subsequent logins must include totp_code (or recovery_code).
Request body
| Field | Type | Required | Description |
|---|---|---|---|
code | string | Yes | 6-digit TOTP code. |
Response — 200 OK
{
"enabled": true,
"enabled_at": "2026-05-15T12:00:00.000Z"
}
Error responses
| Status | Code | When |
|---|---|---|
| 400 | validation_error | code is missing. |
| 400 | invalid_totp_code | The code does not match within the ±1-step window. |
| 400 | totp_not_initialized | /2fa/verify was called before /2fa/setup. Call setup first to generate the secret. |
| 401 | unauthorized | Missing or invalid JWT. |
POST /platform/v1/auth/2fa/disable
Disable 2FA for the current user. Requires both the current password and a current TOTP code — knowing only the session cookie (e.g. via session hijack) is not enough to disable MFA.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
password | string | Yes | Current account password. |
code | string | Yes | Current 6-digit TOTP code. |
Response — 200 OK
{ "message": "Two-factor authentication disabled." }
The encrypted TOTP secret and any existing recovery codes are deleted.
Error responses
| Status | Code | When |
|---|---|---|
| 400 | validation_error | password or code is missing. |
| 400 | invalid_totp_code | The TOTP code is wrong. |
| 401 | unauthorized | Missing or invalid JWT. |
| 401 | invalid_credentials | The password is wrong. |
| 409 | totp_not_enabled | 2FA was not enabled to begin with. |
POST /platform/v1/auth/2fa/recovery/regenerate
Issue a fresh batch of 10 single-use recovery codes in xxxx-xxxx format. The plaintext codes are returned exactly once in the response — only the SHA-256 hash of each code is persisted, so a database dump cannot recover them. Any previously-issued recovery codes are invalidated.
Response — 200 OK
{
"codes": [
"abcd-1234",
"efgh-5678",
"ijkl-9012",
"mnop-3456",
"qrst-7890",
"uvwx-1234",
"yzab-5678",
"cdef-9012",
"ghij-3456",
"klmn-7890"
],
"message": "10 new recovery codes generated. Store them securely — they will not be shown again."
}
Error responses
| Status | Code | When |
|---|---|---|
| 401 | unauthorized | Missing or invalid JWT. |
| 409 | totp_not_enabled | 2FA must be enabled before recovery codes can be generated. |
API key management
These endpoints require a valid JWT token (Authorization: Bearer <jwt_token>) and a project role of admin or higher (i.e. admin or owner). See Projects → Roles for the full role hierarchy.
POST /platform/v1/projects/{projectId}/keys/rotate
Generate a new API key for a project. The old key remains valid for a 24-hour grace window so deployed workers can be updated without a hard cutover.
:::warning Store the new key immediately The plaintext API key is returned exactly once in the response. It cannot be retrieved again. Store it securely (e.g. in Azure Key Vault or your CI/CD secrets store) before closing the response. :::
Request
POST /platform/v1/projects/{projectId}/keys/rotate
Authorization: Bearer <jwt_token>
No request body.
Response — 200 OK
{
"api_key": "zf_live_sk_<new_full_key>",
"rotated_at": "2026-05-11T12:00:00.000Z"
}
Grace window behaviour
After rotation:
- The new key is active immediately.
- The old key remains accepted for 24 hours (
GraceExpiresAt). After that, requests using the old key are rejected with401 Unauthorized. - Rapid back-to-back rotations refresh the grace window of the previous old key rather than creating duplicate rows.
Error responses
| Status | Code | When |
|---|---|---|
| 401 | unauthorized | Missing or invalid JWT token. |
| 403 | forbidden | The caller's role is below admin. |
| 404 | project_not_found | Project ID does not exist. |
| 409 | key_revoked | The project's key has been permanently revoked; rotation is not possible. |
DELETE /platform/v1/projects/{projectId}/keys/revoke
Permanently revoke the project's API key. After revocation, no key (including any grace-window keys from a prior rotation) will be accepted. Cache eviction is immediate, so the rejection takes effect within milliseconds.
:::danger No recovery Revocation is permanent. There is no undo. You will need to create a new project to get a new API key, or contact support. :::
Request
DELETE /platform/v1/projects/{projectId}/keys/revoke
Authorization: Bearer <jwt_token>
No request body.
Response — 200 OK
{
"revoked_at": "2026-05-11T12:00:00.000Z"
}
Error responses
| Status | Code | When |
|---|---|---|
| 401 | unauthorized | Missing or invalid JWT token. |
| 403 | forbidden | The caller's role is below admin. |
| 404 | project_not_found | Project ID does not exist. |
| 409 | already_revoked | The key is already revoked. |