Authentication

Authenticate server-to-server calls with API keys and browser calls with an Atlas session plus the x-org-id header.

Embed
Preview of the card
<a href="https://referalli.com/docs/authentication"><img src="https://referalli.com/docs/_api/public/orgs/07f432f4-34b9-46a7-aaad-c0e395d2e4f9/spaces/2bf4f614-3630-4fbf-85f9-5ecafa6b7d97/pages/a4420396-cbef-4bfb-a65c-d2134859fcee/social-card.svg?v=3e7ae33eec8b" alt="Authentication" width="600" height="315"></a>

Referalli accepts two kinds of credentials. Servers use an API key in the Authorization header. Browsers use a session cookie obtained by signing in with Atlas. A small set of public endpoints (tracked links, the tracking script, inbound webhooks) need neither.

API keys

API keys start with rk_ and belong to one organization. Send them as a bearer token:

curl https://referalli.com/v1/programs \
  -H "Authorization: Bearer rk_3f9c..."

The key decides the org. You do not send x-org-id with an API key; if you do, it is ignored.

Creating and revoking keys

Create keys in the dashboard under Settings → API keys, or through the API with an admin key (scope admin) or an owner or admin session:

curl -X POST https://referalli.com/v1/api-keys \
  -H "Authorization: Bearer $ADMIN_KEY" \
  -H "content-type: application/json" \
  -d '{"name": "production server"}'
# { "id": "key_...", "key": "rk_live_...", "scopes": ["full"], "livemode": true, ... }

The key value is returned once and only its SHA-256 hash is stored. If you lose it, create a new key.

To see which key you're calling with, GET /v1/me with the key returns apiKey: its id, name, prefix, scopes, apiVersion (the version it's pinned to), livemode, owner and expiresAt. Keys created before versioning are pinned to 2026-10-21.

Call

What it does

Who can call it

POST /v1/api-keys

Issue a key. Body: name, and optionally scopes (below), mode (live, test or both), owner, expiresInDays, apiVersion. For a browser key add "publishable": true and "allowedOrigins".

admin key; owner, admin

GET /v1/api-keys

List the org's live and test keys: id, name, prefix, scopes, livemode, owner, pairId, createdBy, expiresAt, expired, lastUsedAt, publishable, allowedOrigins, createdAt, revokedAt. Filter with ?owner= and ?mode=live|test.

any org member, any key

GET /v1/api-keys/{id}

One key, in the same shape as the list.

any org member, any key

PATCH /v1/api-keys/{id}

Change apiVersion, owner or expiresInDays (null removes the expiry).

admin key; owner, admin

DELETE /v1/api-keys/{id}

Revoke a key. ?pair=true also revokes its live/test twin; ?cascade=true also revokes every key it minted. The response lists every key revoked.

admin key; owner, admin

Developer credentials

Give each developer their own keys instead of sharing one. Whoever hands out credentials holds an admin key, and mints each developer a matching live and test pair:

curl -X POST https://referalli.com/v1/api-keys \
  -H "Authorization: Bearer $ADMIN_KEY" \
  -H "content-type: application/json" \
  -d '{"name": "alice laptop", "owner": "alice@acme.io", "mode": "both", "expiresInDays": 90}'
# { "pairId": "kpair_...",
#   "live": { "key": "rk_live_...", "livemode": true, ... },
#   "test": { "key": "rk_test_...", "livemode": false, ... } }

Or from a terminal with the CLI: referally keys create --name "alice laptop" --owner alice@acme.io --mode both --expires-days 90, then referally keys list and referally keys revoke <id> --pair.

  • The live key (rk_live_…) reaches production data. The test key (rk_test_…) reaches only the org's test mode data.

  • New keys get full access unless you pass scopes. A full key can use the whole API but can't create or revoke keys; only an admin key can.

  • A key never grants more than it holds: an admin key can mint any key, and nothing else can mint at all.

  • An expired key fails with 401 like a revoked one. lastUsedAt (updated at most once a minute) shows keys nobody uses any more.

  • A test-mode admin key (rk_test_, scope admin) can only mint, list and revoke test keys. A CI pipeline can hold one without being able to touch production.

  • When a developer leaves, revoke their pair. If they held an admin key, revoke it with ?cascade=true to revoke every key they minted too.

Scopes

A key's scopes are chosen when you create it. In the dashboard this is the Access for the next key picker in Settings → API keys. A key's scopes can't be changed; revoke it and create another.

scopes

What the key can do

["admin"]

Everything a full key can do, plus creating and revoking API keys (see Developer credentials). Keep one, with whoever hands out credentials. Keys created before admin keys existed were made admin keys, so they kept the access they had.

["full"] (default)

Everything except managing API keys. A full key passes every other role check in the API, so treat it like an owner credential: keep it on your server, never in browser or mobile code.

["read"]

Only GET, HEAD and OPTIONS requests, plus POST /v1/graphql (which has no mutations). Any other request fails with 403 and this API key is read-only. Use it for reporting jobs and dashboards.

Resource scopes, e.g. ["referrals:write", "payouts:read"]

Only those parts of the API. <resource>:read allows reads; <resource>:write allows reads and writes. Anything else fails with 403 and a message naming the scope it needed, such as this API key lacks the payouts:write scope.

A read-only or read-scoped key still reaches read endpoints that are limited to owner, admin or finance for people (such as GET /v1/payout-connections, GET /v1/fx-rates and GET /v1/tax/1099).

Create a key that can only attribute customers and read referrals:

curl -X POST https://referalli.com/v1/api-keys \
  -H "Authorization: Bearer $KEY" \
  -H "content-type: application/json" \
  -d '{"name": "signup service", "scopes": ["referrals:write"]}'

Resources

Every /v1 endpoint belongs to exactly one resource:

Resource

Endpoints

programs

/v1/programs/… (enrollments, rules per program, graph, referee rewards, policies)

partners

/v1/partners/… (except payout profile, tax form and balances), invitations, contracts, assets, quality

links

/v1/links, /v1/coupons, embed tokens

events

POST /v1/track/events

referrals

/v1/referrals/… (including associate), /v1/attribution/…

commissions

/v1/commissions/…, /v1/commission-rules, rewards, disputes, partner balances

payouts

payout runs, payouts, payout connections, tax, FX rates, payout schedule, partner payout profiles and tax forms, accounting export

billing

/v1/billing/…, mobile purchases, on-chain, sandbox

webhooks

/v1/webhook-endpoints, /v1/webhook-deliveries

analytics

analytics, reports, GraphQL, anomalies, setup status, assistant, incrementality, full export

fraud

/v1/fraud/…

privacy

/v1/privacy/…

team

team members, API keys, /v1/me, /v1/orgs

org

/v1/org, branding, notifications

integrations

auth connections, imports

growth

discovery, prospects, outreach, creators, marketplace, network, agents, negotiations, clean room, deals

audit

/v1/audit-logs

Platform admin endpoints and the partner portal (/v1/partner/…) take a person's session and never accept a key.

Publishable keys

A publishable key (rk_pub_…) is safe to put in browser code. It can hold only programs:read and events:write (custom events through POST /v1/track/events; clicks are refused), and it only answers requests from the origins you list:

curl -X POST https://referalli.com/v1/api-keys \
  -H "Authorization: Bearer $KEY" \
  -H "content-type: application/json" \
  -d '{"name": "storefront", "publishable": true, "allowedOrigins": ["https://shop.example.com"]}'
  • A request must carry an Origin the key allows; otherwise it fails with 403 and this publishable key is not allowed from this origin.

  • Allowed requests get Access-Control-Allow-Origin for that origin, and the browser's preflight (OPTIONS) is answered for it.

  • allowedOrigins are origins only (https://app.example.com, with an optional port), up to 20. A server key can't have them.

Quotas and retries

Every key has its own request quota, reported in RateLimit-* headers, and every write accepts an Idempotency-Key header so retries are safe. See Errors and limits.

Browser sessions

People sign in through the deployment's OpenID Connect provider (on the hosted service, Atlas; a self-hosted deployment can use any OIDC provider). There are no password endpoints.

  1. Send the browser to GET /v1/auth/oidc/start (/v1/auth/atlas/start works too). Optional query parameters: as (merchant, the default, or partner), provider (instance, the default, or atlasid for "Sign in with Atlas" where it's offered), next (a path to return to after sign-in) and login_hint (an email to prefill).

  2. The provider redirects back to the callback, which sets the referally_session cookie (HTTP-only, SameSite=Lax, valid for 30 days).

  3. POST /v1/auth/logout ends the session and returns { "ok": true, "logoutUrl": "…" }. When logoutUrl isn't null, send the browser there to end the provider's session too; providers without RP-initiated logout return null, and signing out is local.

GET /v1/auth/providers lists the configured sign-in providers, so you know which buttons to draw: { "providers": ["instance", "atlasid"], "details": [{ "id": "instance", "name": "Atlas", "atlas": true }, …] }.

Choosing an org with x-org-id

A user can belong to several orgs, so session-authenticated requests must name one in the x-org-id header:

curl https://referalli.com/v1/programs \
  -H "Cookie: referally_session=..." \
  -H "x-org-id: org_01J..."

The user must be a member of that org. Two endpoints work without the header: GET /v1/me (returns the user and their memberships, each with orgId and role) and POST /v1/orgs (creates an org with the caller as owner).

Roles

Org members have one of these roles: owner, admin, finance, analyst, support. Most read endpoints are open to every member (GET /v1/payout-connections and GET /v1/tax/1099 need owner, admin or finance). Writes check the role, for example:

  • Programs, partners, enrollments, links, commission rules, webhooks, billing connections, fraud case decisions: owner or admin.

  • Approving commissions, adjusting commission lines, payout profiles and thresholds, payout runs, payout connections, FX rates and the payout schedule: owner, admin or finance.

Managing the team

Call

What it does

Who can call it

GET /v1/org/members

List members with userId, email, name and role.

any org member

POST /v1/org/members

Body { "email": "…", "role": "admin" }. The person must already have signed in to Referalli once; otherwise 404 no user with that email. An existing member returns 409.

owner, admin

PATCH /v1/org/members/{userId}

Body { "role": "finance" }. Change a member's role.

owner, admin

DELETE /v1/org/members/{userId}

Remove a member. Any member can remove themselves (leave), whatever their role. Removing an owner needs an owner, and the last owner can't be removed or leave (409).

owner, admin; any member for their own userId

Only an owner can add an owner, change an owner's role or remove an owner; an admin trying returns 403. An org always keeps at least one owner: demoting or removing the last one returns 409 an organization needs at least one owner. Every change is written to the audit log.

Cross-site requests

State-changing requests that carry the session cookie must come from an allowed Origin. A cross-site browser request is rejected with 403 and the message cross-origin request blocked. Requests with an Authorization: Bearer header are not affected.

Partner sessions

Partners sign in with as=partner. A partner session is not tied to one org and only works on the partner portal endpoints under /v1/partner/…. Calling a merchant endpoint with it fails with 400 and an org context is required (x-org-id header).

Partners can turn on two-factor sign-in (TOTP) with POST /v1/partner/security/totp/setup and POST /v1/partner/security/totp/enable ({ "code": "123456" }). While it is on, changing a payout profile (PUT /v1/partner/orgs/{orgId}/payout-profile) needs a current code in the x-totp-code header; without a valid one the call fails with 401 step_up_required. POST /v1/partner/security/totp/disable with { "code": "123456" } turns two-factor off; it also needs a valid current code (401 invalid code otherwise). GET /v1/partner/security returns { "totpEnabled": true }.

Enterprise SSO

A merchant org can let its team sign in through the company's identity provider (SAML or OIDC, with SCIM directory sync). An SSO provider (an identity broker) runs the connection; today that is Atlas. Referalli links each org to a directory tenant at the provider (an Atlas organization) and follows it. People are added to the org, with a role mapped from their directory role, the first time they sign in with SSO, and the provider's signed webhooks keep membership in step with the directory. The setup steps for merchants are in the guide Single sign-on for your team.

Call

What it does

Who can call it

GET /v1/org/sso

Status: configured, provider ({ name, label }, or null when SSO isn't configured), linked, tenantId, connections (type, status, allowedDomains), domains, requireSso, roleMapping, defaultRoleMapping, recent tickets, providerError. Refreshes the connections from the provider.

owner, admin

POST /v1/org/sso/link

Create the org's directory tenant at the provider, seating the caller as its first admin. The caller must have an identity at the provider (for Atlas: an account on Referalli's Atlas instance); otherwise 409. Returns { linked, provider, tenantId }. Repeating it is a no-op.

owner

POST /v1/org/sso/tickets

Body { "expiresInDays": 3 } (1–30). Returns { id, url, expiresAt }: a one-time link to the provider's hosted setup page for the customer's IT admin. The url is returned once and never stored.

owner, admin

POST /v1/org/sso/tickets/{id}/revoke

Revoke a pending setup link.

owner, admin

GET /v1/org/sso/connections/{id}/saml-metadata

{ spEntityId, acsUrl, metadataXml } for one of this org's SAML connections.

owner, admin

PATCH /v1/org/sso

Body { "requireSso": true, "roleMapping": { "org:admin": "admin", "finance-team": "finance" } }. requireSso needs at least one active connection with a domain (400), and the caller, if their own email is on an SSO domain, must be in an SSO session (409, so they can't lock themselves out).

owner

Role keys are the provider's. Each provider has a default mapping (defaultRoleMapping); for Atlas it is org:admin → admin and org:member → analyst. The org's own roleMapping is applied on top. Nobody is made owner unless the mapping says so, and an existing owner's role is never changed by the directory. SSO sign-in errors:

Status

code

Cause

403

sso_required

The org requires SSO and this session didn't come from it. error.details.signInUrl starts an SSO sign-in (/v1/auth/atlas/start?login_hint=…).

403

access_suspended

The directory removed or suspended this member, and they were the last owner (so the row is kept), or the provider suspended the user.

503

sso_not_configured

The deployment has no SSO provider configured (for Atlas: no ATLAS_SECRET_KEY).

502

sso_provider_error

The provider refused or failed the call; error.details.provider names it and error.details.providerCode carries its code.

GET /v1/auth/atlas/start also takes login_hint (an email), passed to Atlas so it can route an enterprise domain straight to its connection. A session counts as SSO when the ID token's amr names a federated method (saml, oidc, sso, fed) or carries sso_connection_id.

Provider webhooks

POST /webhooks/sso/{provider} receives the provider's lifecycle webhooks; for Atlas, POST /webhooks/atlas stays as its registered URL. Each delivery is verified by the provider's own scheme, processed once by its delivery id, and turned into one of these changes:

Change

Effect

Member added or role changed

Add the member, or change their role, using the org's mapping.

Member removed

Remove the member. The last active owner is suspended instead and the org is notified.

User deleted

End every session and remove the user from every org linked to this provider (same last-owner rule).

User suspended or banned

End every session and suspend access in those orgs.

User restored

Restore that access.

Connections changed

Refresh the org's SSO domains. With none left, requireSso turns off.

For Atlas, the scheme is atlas-id, atlas-timestamp (epoch milliseconds) and atlas-signature (v1, + base64 HMAC-SHA256 of {atlas-id}.{atlas-timestamp}.{raw body}, keyed with ATLAS_WEBHOOK_SECRET, with ATLAS_WEBHOOK_SECRET_PREVIOUS also accepted during a rotation), within 5 minutes. Its events map as: organizationMembership.created / .updated → member added or changed; organizationMembership.deleted → member removed; user.deleted; user.banned / user.suspended; user.unbanned / user.unsuspended → restored; ssoConnection.created / .updated / .deleted → connections changed. The operator registers the endpoint once: pnpm --filter @referally/api exec tsx scripts/register-atlas-webhook.ts (with ATLAS_SECRET_KEY and APP_BASE_URL set) prints the signing secret to store as ATLAS_WEBHOOK_SECRET.

Choosing or adding a provider

SSO_PROVIDER picks the deployment's provider (default atlas). An unknown value, or a provider without its credentials, leaves SSO "not configured" rather than failing. Each org records which provider its link uses, so switching providers never applies one provider's directory to another's orgs.

A new provider (for example WorkOS SSO or Auth0 Organizations) plugs in by implementing the SsoProvider interface in packages/api/src/ssoProvider.ts: create a tenant, create and revoke setup links, list connections and memberships, return SAML metadata, mirror "require SSO", map a sign-in identity to its subject, and verify and parse its webhooks into the changes above. It then registers in ssoProviderOf (packages/api/src/ssoProviders/index.ts). Provisioning at sign-in, role mapping, require-SSO and the last-owner rule are provider-neutral and need no changes.

Public endpoints

These need no credentials:

  • GET /go/{trackingToken}/{code}, the tracked link redirect.

  • GET /t/{trackingToken}/aff.js, POST /t/{trackingToken}/visit and POST /t/{trackingToken}/identify, used by the tracking script. The tracking token is public by design; it identifies your org but grants no API access.

  • POST /webhooks/billing/{connectionId}, POST /webhooks/auth/{connectionId}, POST /webhooks/sso/{provider} and POST /webhooks/atlas, which verify the provider's signature instead.

  • GET /v1/health and GET /v1/openapi.json.

Authentication errors

All errors use the standard envelope described in Errors and limits.

Status

code

message

Cause

401

unauthorized

authentication required

No API key and no session cookie.

401

unauthorized

invalid API key

The rk_ key is unknown or revoked.

401

unauthorized

session expired

The session cookie is no longer valid. Sign in again.

401

step_up_required

step-up verification required (x-totp-code)

A partner with two-factor on changed payout details without a valid code.

400

validation_error

an org context is required (x-org-id header)

Session request without x-org-id.

403

forbidden

not a member of this org

The user does not belong to the org in x-org-id.

403

forbidden

this organization is suspended

The org in x-org-id is suspended.

403

forbidden

insufficient permissions

The user's role is not allowed to make this call.

403

forbidden

this API key is read-only

A read key made a request other than GET, HEAD, OPTIONS or GraphQL.

403

forbidden

this API key lacks the payouts:write scope (names the missing scope)

A resource-scoped key called an endpoint outside its scopes.

403

forbidden

this publishable key is not allowed from this origin

A publishable key was used without an allowed Origin.

403

forbidden

cross-origin request blocked

Cookie-authenticated write from a disallowed origin.

Updated