s46.apiv1

Auth

Invite-gated login, passkeys, cookies, token refresh, logout, and device approval. A user must hold an active invitation before login can start.

Route inventory

MethodRoutePurpose
POST/v1/auth/device/startStart invite-gated device login. (alias: /v1/auth/login/start)
GET/v1/auth/magic/consumeRender magic-link confirmation. Does not consume the token.
POST/v1/auth/magic/consumeConsume the token and approve the device login.
POST/v1/auth/device/pollPoll until approval returns a token set.
POST/v1/auth/token/refreshRotate access/refresh credentials.
POST/v1/auth/passkeys/…WebAuthn register & login (start / finish).
POST/v1/auth/logoutClear browser auth cookies.
GET·POST/deviceCheck & approve a pending device code as the browser user.

Start device login

POST/v1/auth/device/start202

Begin the device authorization grant. Returns a short userCode for the human to approve plus a deviceCode the client polls with.

Auth · none (invite-gated)

Body

FieldRequiredNotes
emailrequiredInvited account email. Must exist in the org directory.
deviceIdrequiredStable, client-chosen, revocable device id. Reuse on refresh.
deviceNameoptionalHuman label shown in the device list. Defaults to hostname.
curl -X POST https://api.s46.dev/v1/auth/device/start \
  -H "Content-Type: application/json" \
  -d '{
    "email": "dscape@s46.dev",
    "deviceId": "dev-laptop",
    "deviceName": "Dev laptop"
  }'
const start = await fetch("https://api.s46.dev/v1/auth/device/start", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    email: "dscape@s46.dev",
    deviceId: "dev-laptop",
    deviceName: "Dev laptop",
  }),
}).then((r) => r.json());
import requests

start = requests.post("https://api.s46.dev/v1/auth/device/start", json={
    "email": "dscape@s46.dev",
    "deviceId": "dev-laptop",
    "deviceName": "Dev laptop",
}).json()
{
  "deviceCode": "s46_device_...",
  "userCode": "WXYZ-1234",
  "verificationUri": "https://api.s46.dev/v1/auth/magic/consume?token=...",
  "intervalSeconds": 2,
  "expiresAt": "2030-01-01T12:00:00Z"
}

Errors

403not_invitedEmail is not invited or invite was revoked.
400invalid_requestMissing or malformed field.
503email_unavailableMagic-link email delivery is not configured or failed.

Approve the login

GET/v1/auth/magic/consume?token=…page
POST/v1/auth/magic/consume200

The GET renders the confirmation page and validates but does not consume the token, so email scanners can't approve a login. The POST consumes the token, creates a browser session cookie, and approves the pending device login.

Auth · token-scoped

# JSON clients
curl -X POST https://api.s46.dev/v1/auth/magic/consume \
  -H "Content-Type: application/json" \
  -d '{ "token": "..." }'

# Browser forms post application/x-www-form-urlencoded: token=...
await fetch("https://api.s46.dev/v1/auth/magic/consume", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ token: "..." }),
});
requests.post("https://api.s46.dev/v1/auth/magic/consume",
              json={"token": "..."})

Errors

401unauthorizedLink reused or session invalid.
410expiredMagic link expired.

Poll for credentials

POST/v1/auth/device/poll200 / 428

Exchange a deviceCode for a token set once the user approves. Respect intervalSeconds; polling faster earns 429 rate_limited. Send X-S46-Use-Cookies: true to receive HTTP-only cookies instead (the JSON then redacts the token values).

Auth · none (device-scoped)

curl -X POST https://api.s46.dev/v1/auth/device/poll \
  -H "Content-Type: application/json" \
  -d '{ "deviceCode": "s46_device_..." }'
async function poll(deviceCode, intervalSeconds) {
  for (;;) {
    const res = await fetch("https://api.s46.dev/v1/auth/device/poll", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ deviceCode }),
    });
    if (res.status === 200) return res.json();          // token set
    if (res.status !== 428) throw new Error(await res.text());
    await new Promise((r) => setTimeout(r, intervalSeconds * 1000));
  }
}
import time

while True:
    res = requests.post("https://api.s46.dev/v1/auth/device/poll",
                        json={"deviceCode": start["deviceCode"]})
    if res.status_code == 200:
        tokens = res.json()
        break
    if res.status_code != 428:
        res.raise_for_status()
    time.sleep(start["intervalSeconds"])
{ "error": { "code": "authorization_pending", "message": "authorization pending" } }
{
  "account": "dscape@s46.dev",
  "organization": "s46",
  "team": "@s46/engineering",
  "deviceId": "dev-laptop",
  "accessToken": "s46_access_...",
  "refreshToken": "s46_refresh_...",
  "expiresAt": "2030-01-01T13:00:00Z"
}

Refresh

POST/v1/auth/token/refresh200

Rotate the access token. Refresh tokens are device-bound and may themselves rotate — replace the stored refresh token whenever a new one is returned. Browser clients may send X-S46-Use-Cookies: true and omit the body; the API reads the refresh token from the s46_refresh cookie.

Auth · refresh token / cookie

curl -X POST https://api.s46.dev/v1/auth/token/refresh \
  -H "Content-Type: application/json" \
  -d '{
    "account": "dscape@s46.dev",
    "refreshToken": "s46_refresh_..."
  }'
const next = await fetch("https://api.s46.dev/v1/auth/token/refresh", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    account: "dscape@s46.dev",
    refreshToken: stored.refreshToken,
  }),
}).then((r) => r.json());
store(next.refreshToken); // always persist the new one
nxt = requests.post("https://api.s46.dev/v1/auth/token/refresh", json={
    "account": "dscape@s46.dev",
    "refreshToken": stored["refreshToken"],
}).json()
store(nxt["refreshToken"])  # always persist the new one
{
  "account": "dscape@s46.dev",
  "team": "@s46/engineering",
  "deviceId": "dev-laptop",
  "accessToken": "s46_access_...",
  "refreshToken": "s46_refresh_...",
  "expiresAt": "2030-01-01T14:00:00Z"
}

Errors

401unauthorizedRefresh token missing, invalid, or revoked.
410expiredRefresh token expired.

Authenticated device approval

GET/device200
POST/device200

GET /device reports whether the current browser session (or bearer token) can approve a device code. POST /device approves a pending device for the authenticated user.

Auth · bearer or session cookie

# Approve a pending device
curl -X POST https://api.s46.dev/device \
  -H "Authorization: Bearer $S46_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "userCode": "WXYZ-1234" }'
await fetch("https://api.s46.dev/device", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${accessToken}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ userCode: "WXYZ-1234" }),
});
requests.post("https://api.s46.dev/device",
              headers={"Authorization": f"Bearer {access_token}"},
              json={"userCode": "WXYZ-1234"})
{
  "authenticated": true,
  "user": {
    "email": "dscape@s46.dev",
    "organization": "s46",
    "team": "@s46/engineering",
    "role": "member"
  }
}
{
  "approved": true,
  "user": { "email": "dscape@s46.dev", "team": "@s46/engineering" }
}

Errors

401authenticate_firstNo authenticated session; the response carries an auth object pointing at the login routes.

Passkeys

POST/v1/auth/passkeys/register/start · /finish
POST/v1/auth/passkeys/login/start · /finish

WebAuthn registration requires an authenticated bearer token or auth cookie; start returns creation options, finish takes the browser credential. Passkey login is email-scoped: start returns request options and finish returns the same token-set shape as device polling.

Auth · bearer (register) · email (login)

# Begin passkey login for an email
curl -X POST https://api.s46.dev/v1/auth/passkeys/login/start \
  -H "Content-Type: application/json" \
  -d '{ "email": "dscape@s46.dev" }'
const options = await fetch("https://api.s46.dev/v1/auth/passkeys/login/start", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ email: "dscape@s46.dev" }),
}).then((r) => r.json());
// pass options to navigator.credentials.get(), then POST to /login/finish
options = requests.post("https://api.s46.dev/v1/auth/passkeys/login/start",
                        json={"email": "dscape@s46.dev"}).json()

Logout

POST/v1/auth/logout200

Clears browser auth cookies. Bearer-token clients usually revoke a device instead of calling logout.

curl -X POST https://api.s46.dev/v1/auth/logout \
  -H "Authorization: Bearer $S46_ACCESS_TOKEN"
await fetch("https://api.s46.dev/v1/auth/logout", {
  method: "POST",
  headers: { Authorization: `Bearer ${accessToken}` },
});
requests.post("https://api.s46.dev/v1/auth/logout",
              headers={"Authorization": f"Bearer {access_token}"})
{ "ok": true }

On this page