Skip to content

Authentication

Every API request is made as someone: a person signed in to the web app, or a system holding an API key. SpeculaPro uses that identity to decide which workspace’s data the request can reach and what it is allowed to do, and records it in the audit log.

When a person signs in with their workspace, email, password and, if they have set it up, a two-factor code (or a recovery code in its place), or with a passkey, SpeculaPro sets a session cookie. The browser sends it automatically with each request.

Property Value
Name __Host-sp_session (or sp_session when SP_SECURITY_COOKIE_SECURE=false)
Readable by JavaScript No (HttpOnly)
Sent with requests from other sites No (SameSite=Strict)
HTTPS only Yes, by default
Expires After SP_SECURITY_SESSION_TTL (default 12 hours), or after SP_SECURITY_SESSION_IDLE (default 30 minutes) without activity

Signing in is POST /api/v1/auth/login, with this body:

Field Value
workspace, email, password The workspace’s short name and the person’s email and password
totp_code The six-digit code from the authenticator app. Required when two-factor is set up, unless recovery_code is sent
recovery_code One of the person’s recovery codes, in place of totp_code when the authenticator is lost. Up to 40 characters; case, spaces and dashes don’t matter. It works once. When both fields are sent, the recovery code is the one checked

When it fails, detail says why:

Response Meaning What to do
401 totp_required The account has two-factor set up and no code was sent Send totp_code with the six-digit code, or recovery_code
401 temporary_password_expired The password was right, but it is a temporary one more than 72 hours old Ask an administrator to issue a new temporary password. See Managing people
401 invalid_credentials Anything else: unknown workspace or email, wrong password, authenticator code or recovery code, disabled or locked account. A wrong recovery code counts toward the lockout like a wrong authenticator code (10 failures lock the account for 15 minutes) Check the details. SpeculaPro deliberately doesn’t say which part was wrong
401 sso_required The workspace requires single sign-on. Given for every refused password attempt there, whether or not the account exists; only break-glass administrators sign in with a password Select Continue with SSO. See Signing in with single sign-on
403 workspace_suspended On the hosted service: the password was right, but SpeculaPro has suspended the workspace. Told only to someone whose password was right Contact SpeculaPro. See A suspended workspace
429 too many attempts, try again later More than 30 attempts in a minute from one address, or more than 10 in 15 minutes for one account Wait: the limit clears within 15 minutes

Only a fingerprint of the session token is stored on the server, so a database leak does not expose live sessions.

The signed-in person’s details come from GET /api/v1/auth/me. Among them, locale is the language they chose (en, fr, or empty when they follow the workspace), and default_locale is the workspace’s default language. See Languages.

These endpoints are what the sign-in page, the account setup screen and Settings → Sign-in call. They work with a person’s own session cookie, never with an API key (401 sign in required), and the passkey ones need a browser: the options they return go to navigator.credentials, and the browser’s answer comes back. A support session can use none of them.

Endpoint What it does Who
POST /api/v1/auth/totp/verify Confirms the authenticator at setup, and returns the recovery codes The signed-in person, during account setup too
GET /api/v1/auth/security The person’s own sign-in methods The signed-in person
POST /api/v1/auth/recovery-codes Replaces the recovery codes with ten new ones The signed-in person, with an authenticator code
POST /api/v1/auth/passkey/begin, POST /api/v1/auth/passkey Sign in with a passkey Anyone: no session needed
POST /api/v1/auth/passkeys/begin, POST /api/v1/auth/passkeys Add a passkey The signed-in person, with an authenticator code
DELETE /api/v1/auth/passkeys/{id} Remove one of your passkeys The signed-in person

Except for the two sign-in endpoints and totp/verify, a person who hasn’t finished account setup gets 403 complete account setup first (two-factor enrolment or password change).

A person has ten recovery codes, each of the form XXXX-XXXX-XXXX-XXXX (digits and capital letters, without I, L, O and U). Each signs in once in place of the authenticator’s code. SpeculaPro stores only a salted fingerprint of each code, so the codes are returned once, when they are made.

POST /api/v1/auth/totp/verify, body {"code": "<six digits>"}, confirms the authenticator and answers 200 with the codes:

{
"recovery_codes": ["0000-0000-0000-0000", "1111-1111-1111-1111", "…eight more"]
}

POST /api/v1/auth/recovery-codes, body {"code": "<six digits>"} with a code from the authenticator app, replaces every code, used or not, with ten new ones and answers 200 with the same body. The old codes stop working at once.

Answer Meaning
400 code is incorrect The authenticator code is wrong, or was already used. Each wrong code is audited (auth.login_failed, reason bad_step_up_code) and counts toward the account lockout. A locked account is refused the same way without the code being checked (reason locked_step_up)
429 too many attempts, try again later More than five tries at this code in fifteen minutes for one person, counted across making new codes and adding a passkey. Refused right code or not, until the fifteen minutes pass
409 set up your authenticator app first The person has no authenticator app set up, or signs in through an identity provider
422 code isn’t six digits

Typing a code at sign-in is forgiving: case, spaces and dashes are ignored, and the letters O, I and L are read as 0, 1 and 1.

GET /api/v1/auth/security answers:

{
"authenticator": true,
"recovery_codes_left": 8,
"recovery_codes_made_at": "2026-10-12T09:14:03Z",
"passkeys": [
{
"id": "0192f3d0-aaaa-7000-8000-000000000001",
"name": "Work laptop",
"created_at": "2026-10-12T09:20:41Z",
"last_used_at": "2026-10-13T07:58:10Z",
"synced": false
}
],
"passkeys_available": true,
"managed_by_sso": false
}
Field Meaning
authenticator An authenticator app is set up
recovery_codes_left How many unused recovery codes remain
recovery_codes_made_at When the current set was made. Absent when there is none
passkeys The person’s passkeys, never their keys. synced is true for a passkey kept in a password manager or cloud keychain, which is available on the person’s other devices
passkeys_available false when this server has no public https address that passkeys can be bound to
managed_by_sso The person has no password here and signs in through an identity provider: sign-in methods are managed there

A passkey is bound to the host name of the server’s public address (SP_SSO_PUBLIC_URL or SP_NOTIFY_PUBLIC_URL, or the region’s public address on a multi-region service). That address must be https (or localhost in development); without one, every passkey endpoint answers 409 passkeys are not available on this server.

Both flows take two requests. The first returns options for the browser and a state; the second sends the browser’s answer back with that state, unchanged. A state is good for 5 minutes and is accepted once: after that the answer is 409 that took too long or was already used; start again.

Signing in. POST /api/v1/auth/passkey/begin takes no body, no workspace and no email:

{
"options": { "challenge": "…", "rpId": "speculapro.example.com", "userVerification": "required" },
"state": "…"
}

Pass options to navigator.credentials.get as its publicKey, then send POST /api/v1/auth/passkey:

{
"state": "…",
"credential": { "id": "…", "type": "public-key", "response": { "…": "…" } }
}

On success the answer is the same as POST /api/v1/auth/login: the session cookie and the person’s details. The device checked the person itself, so the session counts as two-factor and no password or code is asked; in the audit log auth.login records the method passkey.

Answer Meaning
401 invalid_credentials The passkey isn’t known, the signature doesn’t check out, the device didn’t verify the person, the passkey’s counter went backwards (a copy is in use), or the account is disabled or locked. The caller isn’t told which; the audit log records auth.login_failed with the reason (bad_passkey, passkey_unverified, passkey_cloned, account_unavailable)
401 sso_required The workspace requires single sign-on, and the person isn’t a break-glass administrator
403 workspace_suspended On the hosted service: the workspace is suspended
409 Passkeys aren’t available on this server, or the state expired or was used
421 wrong_region The workspace has moved to another region. As for a password sign-in
429 too many attempts, try again later More than 30 sign-in attempts in a minute from one address. Password and passkey attempts, and both passkey requests, count together

A refused passkey doesn’t count toward the account’s lockout.

Adding a passkey. POST /api/v1/auth/passkeys/begin, body {"code": "<six digits>"} with a code from the authenticator app, returns options (the publicKey for navigator.credentials.create) and a state. The options exclude the person’s existing passkeys, so a device that already holds one refuses to make another. Then send POST /api/v1/auth/passkeys:

{
"state": "…",
"name": "Work laptop",
"credential": { "id": "…", "type": "public-key", "response": { "…": "…" } }
}

The answer is 201 with the passkey as it appears in GET /api/v1/auth/security.

Answer Meaning
400 code is incorrect The authenticator code is wrong, or was already used. Each wrong code is audited (auth.login_failed, reason bad_step_up_code) and counts toward the account lockout. A locked account is refused the same way without the code being checked (reason locked_step_up)
429 too many attempts, try again later More than five tries at this code in fifteen minutes for one person, counted across making new codes and adding a passkey. Refused right code or not, until the fifteen minutes pass
409 set up your authenticator app first The person has no authenticator app set up, or signs in through an identity provider
409 you already have the most passkeys an account can hold; remove one first A person keeps at most 10 passkeys
409 Passkeys aren’t available on this server, or the state expired, was used, or was issued to someone else
422 passkey not accepted: … The browser’s answer doesn’t check out, the device didn’t verify the person, or name isn’t 1 to 80 characters

Removing a passkey. DELETE /api/v1/auth/passkeys/{id} answers 204, or 404 not found when the passkey isn’t one of yours. It asks for no code. The other sessions that passkey opened are ended; the session making the request stays. The audit entry records how many as sessions_ended.

Adding and removing are written to the audit log (auth.passkey_added, auth.passkey_removed), as are making new recovery codes (auth.recovery_codes_generated) and using one (auth.recovery_code_used, with how many remain). See Signing in and two-factor.

Every POST, PUT, PATCH and DELETE made with a session cookie is checked for where it came from. This blocks cross-site request forgery.

Answer When What to do
403 cross-site request rejected The browser says the request comes from another website: Sec-Fetch-Site is not same-origin or none, or the host in Origin differs from the host the request was sent to. This applies to signing in too Use SpeculaPro on its own address. Behind a reverse proxy, make sure the proxy passes the Host header through unchanged
403 a signed-in request that changes something must carry an Origin or Sec-Fetch-Site header: browsers add one; a script must send Origin with this site's own address The request carries a session cookie and neither header Browsers always send one. In a script, add -H "Origin: https://speculapro.example.com", with the address you reach SpeculaPro on

Requests that only read (GET, HEAD) are not checked, and neither are requests made with an API key: a key is never attached by a browser on its own.

Sessions are meant for the web app. Do not script against the API with a copied cookie. Use an API key.

There are two exceptions today, because API keys can’t do them:

  • Rule management. Rules have screens now, but a person may also sign in with curl as themselves to script rule changes, as described in Rules API: before you start.
  • Alerts and cases. These have screens now, so use the Alerts and Cases screens for day-to-day work. If you do call the endpoints yourself, it must be with your own session, so every action is recorded under your name. See Alerts and cases API. That is still your own interactive session, with two-factor, under your own name; never put it in an unattended script.

An API key lets one of your systems call SpeculaPro without a person signing in.

spk_AbC123xYz789_Q2hvb3NlIGEgcmFuZG9tIHNlY3JldCBoZXJl…
  • spk_ marks it as a SpeculaPro key, which helps secret scanners recognise it if it leaks.
  • The next part is the prefix. It identifies the key and is shown in lists, so you can tell keys apart.
  • The rest is the secret. SpeculaPro stores only a fingerprint of it.

Where SpeculaPro is hosted in more than one region, a key also carries its region after spk: spk_gh_AbC123xYz789_…. See Regions for integrators.

Send it in the Authorization header on every request:

Terminal window
curl https://speculapro.example.com/api/v1/system/health \
-H "Authorization: Bearer $SPECULAPRO_API_KEY"
Response Meaning
401 unsupported authorization scheme The header doesn’t start with Bearer (with a space)
401 invalid api key The key is wrong, revoked or expired
421 with code: wrong_region The key belongs to another region of a multi-region service. The body names the region and, when known, its base_url. See Regions for integrators
503 with Retry-After The workspace is being moved to another region: keep what you were sending and retry
403 with code: workspace_suspended On the hosted service: the key is valid, but SpeculaPro has suspended the workspace. Takes effect within 5 seconds of the suspension. Retrying or rotating the key changes nothing: keep what you were sending and contact SpeculaPro. See A suspended workspace
401 api key required The endpoint needs an API key and none was sent (a browser session doesn’t count)
403 api key lacks scope … The key is valid but doesn’t have the scope this endpoint needs

A key can only do what its scopes allow. Give each key the fewest scopes it needs.

Scope Allows Status
ingest:write Sending customers, accounts, transactions, account events and FX rates, and sending feedback. See Sending data Available
decide Asking for real-time decisions and sending feedback. See Decisions API Available

You need the API keys: manage permission (held by the Administrator role).

Send POST /api/v1/api-keys while signed in, with a name, the scopes and, optionally, an expiry date:

{
"name": "Core banking – production",
"scopes": ["ingest:write"],
"expires_at": "2027-10-01T00:00:00Z"
}

Administrators usually create keys on the API keys tab of Settings instead; see Managing API keys.

The response (201 Created) contains the full key in its key field. An expiry date in the past is refused with 422.

  • GET /api/v1/api-keys lists the workspace’s keys: names, prefixes, scopes and expiry, never the secrets. Each key also carries who created it and, when that person has been deactivated, the rotation-due fields below.
  • POST /api/v1/api-keys/{id}/rotate replaces a key. It takes no body.
  • DELETE /api/v1/api-keys/{id} revokes a key. It returns 204, or 404 if the key does not exist or is already revoked.
Field on a listed key Meaning
created_by, created_by_name The person who created the key (their name, or their email when they have none): the only one who ever saw its secret
rotation_due_at When that person was deactivated while the key still worked. Present until the key is rotated or revoked
rotation_due_user_id, rotation_due_user_name Who was deactivated

Rotating makes a new key with the same name, scopes and expiry, and stops the old one in the same step. You become the new key’s creator, and a rotation-due mark is cleared.

Answer Meaning
200 The same body as creating a key: the new key’s details and the full key in key, once
202 The workspace requires approval for API keys: send X-Change-Reason. The old key keeps working until someone else approves; you then collect the new key once. See the Change requests API
404 No such key, or it is already revoked: api key not found or already revoked
409 The key has expired: this key has expired: create a new key instead of rotating it

Whether a deactivated person’s keys are marked rotation due or revoked at once is the workspace setting revoke_api_keys_on_deactivation; see Workspace settings API and When the person who created a key leaves.

Revocation is immediate on the server that handled it, and takes effect on every other server within 5 seconds: keys are briefly cached so that real-time decisions stay fast.

Revoke a key at once if it might have leaked: if it was pasted into a ticket or chat, committed to a repository, or held by someone who has left.

  • Store keys in a secret manager, never in source code or in plain configuration files.
  • Use HTTPS only. A key sent over plain HTTP can be read by anyone on the network path.
  • Set an expiry date and rotate before it: create the new key, deploy it, then revoke the old one.