These endpoints need no session and no token. They carry no tenant data. All of them are subject to the general per-address rate limit; the login and invitation endpoints have their own budgets, noted below.
GET /api/health
Service status for monitoring. Token: not needed.
Request
curl https://saq.no/api/health
Response 200 (Health)
{ "status": "ok", "service": "saq", "version": "v1.4.2" }
| Field | Type | Meaning |
|---|---|---|
status |
"ok" |
Always ok when the service answers |
service |
"saq" |
Service name |
version |
string | Deployed release, its tag (v1.4.2) |
GET /api/plans
The public plan catalogue. The marketing site reads it at build time; integrators can use it to show the API rate a plan includes. Cached for 300 seconds (Cache-Control: public, max-age=300). Token: not needed.
Request
curl https://saq.no/api/plans
Response 200 { "plans": Plan[] }
{
"plans": [
{
"key": "business",
"name": "Business",
"description": "For organisations with several projects and clients.",
"seats": 25,
"amountMinor": 490000,
"currency": "NOK",
"interval": "month",
"public": true,
"features": ["sso", "scim"],
"apiRatePerMinute": 1200,
"priceFrom": false
}
]
}
| Field | Type | Meaning |
|---|---|---|
key |
string | Stable plan key |
name |
string | Display name |
description |
string | One-line description |
seats |
integer or null | Workspace users included; null is unlimited |
amountMinor |
integer or null | Price in minor units (øre for NOK); null until decided |
currency |
string | ISO 4217 code |
interval |
string | Billing interval |
public |
boolean | Listed publicly |
features |
string[] | Feature keys; unknown values may appear as features are added |
apiRatePerMinute |
integer | Requests per minute shared by every API token in a workspace on this plan |
priceFrom |
boolean | The amount is a "from" price agreed on contact |
POST /api/login/discover
Step two of identity-first login: which login methods an email address can use. The response never reveals whether the address exists. Budget: 20 requests per minute per address. Token: not needed.
| Body field | Type | Required | Validation |
|---|---|---|---|
email |
string | yes | 3–320 characters as typed; normalised to lower case before lookup; must look like an address |
Request
curl -X POST https://saq.no/api/login/discover \
-H 'Content-Type: application/json' \
-d '{ "email": "kari@acme.example" }'
Response 200 (LoginDiscoverResponse)
{
"password": true,
"passkey": true,
"magicLink": true,
"sso": [
{ "workspace": "Acme", "slug": "acme", "forced": false, "configured": true }
],
"redirect": null
}
| Field | Type | Meaning |
|---|---|---|
password, passkey |
true |
Always offered |
magicLink |
boolean | Whether a magic link may be sent |
sso[] |
object[] | Workspaces with single sign-on for this domain: workspace, slug, forced, configured |
redirect |
{ slug } or null |
Set when exactly one forced-SSO workspace applies and the login page should redirect |
| Error | Status | When |
|---|---|---|
invalid |
400 | Not an email address (details.field: "email") |
rate_limited |
429 | More than 20 requests per minute from the address |
Invitation endpoints
Invitation landing pages use three endpoints keyed by the invitation token from the email. They are used by the app's /invite/{token} page; an integration has no reason to call them, so they are listed briefly.
| Operation | Purpose | Response |
|---|---|---|
GET /api/invitations/{token} |
Look up an invitation: workspaceName, slug, email, role, state (pending, expired, accepted, revoked), identityExists. 30 per minute per address. |
200 InvitationPublic |
POST /api/invitations/{token}/accept |
Accept with an existing browser session whose email matches. 20 per minute per address. | 200 { "slug": "acme" } |
POST /api/invitations/{token}/register |
Create the account (name 1–120, password 8–128) and accept. 10 per 10 minutes per address. |
200 { "slug": "acme" } |
| Error | Status | When |
|---|---|---|
not_found |
404 | Unknown token |
invitation_expired, invitation_accepted, invitation_revoked |
409 | The invitation is no longer open |
invitation_email_mismatch |
403 | The session's email is not the invited address |
full_login_required |
403 | Accepting needs a password or passkey session, not a magic link |
identity_exists |
409 | Register was called for an address that already has an account |
rate_limited |
429 | The per-address budget is spent |
POST /api/stripe/webhook
Stripe calls this endpoint; integrations never do. It is listed here because it is part of the public surface and appears in the OpenAPI document.
The stripe-signature header is verified against the raw request body before
anything is read, so an unsigned or replayed payload changes nothing and is
not stored. The body is capped at one megabyte. A deployment without Stripe
configured answers 404. There is nothing to call and nothing to configure:
subscription state reaches SAQ this way, and your integration reads it, if
at all, through the app.
| Error | Status | When |
|---|---|---|
invalid |
400 | Missing or invalid signature, or oversize body |
not_found |
404 | Stripe is not configured for this deployment |