Public endpoints

The unauthenticated endpoints of the SAQ API, health, the plan catalogue, login discovery, and invitation landing, with their request and response shapes.

Written for
integration-developer
Roles
Requires
Feature
api

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