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 or wrong password on login |
| 401 | invalid_totp_code | /platform/v1/auth/login | 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 — 201 Created
{
"user_id": "usr_01JAXBKM3N4P5Q6R7S8T9UVWXY",
"email": "you@example.com",
"message": "Account created. Check your email to verify."
}
Error responses
| Status | Code | When |
|---|---|---|
| 400 | invalid_request | Email is invalid, password too short/long, or accept_terms is missing. |
| 409 | email_already_registered | 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. |
| 401 | invalid_totp_code | 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.
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.
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. |