> ## Documentation Index
> Fetch the complete documentation index at: https://docs.spitshake.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Partner SSO (Open in SpitShake)

> Sign a console user straight into a managed account with a short-lived signed token.

Partner SSO lets a platform partner put an **Open in SpitShake** button in its own console. The
partner's server signs a short-lived token for the console user, the browser sends it to
SpitShake, and SpitShake starts a normal session in the managed account. There is no email, no
magic link and no SpitShake password.

## The secret

Each managed account has its own `partner_sso_secret`, an HS256 key.

* It is minted at [provisioning](/api-reference/partner/provisioning) and returned once as
  `partner_sso_secret` (and on exact idempotent replays).
* Rotate it, or mint the first one for an account provisioned before partner SSO existed, with
  `POST /api/partner/v1/accounts/{id}/sso_secret/rotate`. Tokens signed with the old secret are
  refused at once. A suspended account's secret can still be rotated, as its API tokens can,
  so you can answer a leak before reactivating it. An archived account is read-only: rotation
  answers `409` (`account_archived`) and changes nothing.

```bash theme={null}
curl -X POST https://spitshake.io/api/partner/v1/accounts/412/sso_secret/rotate \
  -H "Authorization: Bearer $SPITSHAKE_PARTNER_KEY" \
  -H "Idempotency-Key: sso-rotate-2026-10-16"
```

```json 200 theme={null}
{
  "account": { "id": 412, "external_id": "firm-8675309" },
  "partner_sso_secret": "5d0c7e9a41b2..."
}
```

Keep the secret on your server only; anyone holding it can sign into the account. Use the secret
string itself as the HMAC key (do not hex-decode it).

## The token

An HS256 JWT. Any other algorithm, including `none`, is refused.

| Claim | Required | Value |
| - | - | - |
| `iss` | yes | The SpitShake account id, as a **string** (`"412"`). |
| `aud` | yes | `"spitshake-sso"`, a single string. |
| `iat` | yes | Issued-at, integer Unix seconds. |
| `exp` | yes | Integer Unix seconds, **at most 60 seconds after `iat`**. Longer lifetimes are refused. |
| `jti` | yes | A unique id (a UUID). Each token works once; a replay is refused. |
| `sub` | yes | Your stable id for the console user (up to 255 characters). |
| `name` | yes | The user's display name. |
| `email` | no | Where SpitShake sends the user's mail (see [Users and roles](#users-and-roles)). Not their SpitShake sign-in, and never verified. |
| `role` | yes | `admin` or `editor`. `viewer` is refused (see [Users and roles](#users-and-roles)). |
| `return_to` | no | Where to land: a path on SpitShake starting with a single `/`. Defaults to `/templates`. |
| `amr` | no | Authentication methods (RFC 8176), e.g. `["pwd", "mfa"]`. Recorded in the audit trail, not enforced. |

SpitShake allows 5 seconds of clock skew on `iat` and `exp`. Mint the token when the user clicks
the button, not when the page loads.

`return_to` must be a relative path such as `/templates` or `/templates/123/edit`. A value
starting with `//`, containing a backslash, a scheme (`https:`, `javascript:`), spaces, CR/LF or
non-ASCII characters is refused, and so is the whole sign-in.

## Sending the user

`GET` or `POST` `https://spitshake.io/sso/partner` with the token in a `token` parameter, in the
query string or a form body (`application/x-www-form-urlencoded`). Send it in one place only: a
query token and a different form token are refused with `400`. Prefer a form `POST`: it keeps the
token out of browser history and access logs. Open it as a page (a link, a form, `window.open`),
not from an image, iframe or `fetch`; those are refused. A `HEAD` request (link previews,
scanners) gets `405` and neither signs anyone in nor uses up the token.

```html theme={null}
<form method="post" action="https://spitshake.io/sso/partner" target="_blank">
  <input type="hidden" name="token" value="TOKEN_FROM_YOUR_SERVER">
  <button type="submit">Open in SpitShake</button>
</form>
```

What happens next depends on whether that browser is already signed in to SpitShake:

* **Not signed in, or signed in as the same user:** SpitShake starts the session and redirects
  (`303`) to `return_to`.
* **Signed in as someone else:** SpitShake does not switch silently. It shows a page naming the
  current user and workspace and the one the token is for. Only after the user clicks Continue
  (a same-origin form with a CSRF token) is the token used and the session switched. The token
  must still be inside its minute when they click.

A `POST` from your console is a cross-site request, so the browser does not send its SpitShake
session cookie with it. SpitShake answers it with a page that immediately resubmits the same
token from SpitShake's own origin, which does carry the cookie, and then proceeds as above. With
JavaScript off, the page shows a Continue button instead.

<Warning>
  **Residual login-CSRF risk.** Anyone holding a valid, unused token can still sign a browser that
  has **no** SpitShake session into that token's account. For example, a page could post a token
  for an account the attacker controls, and the victim might then upload documents into it.
  Only you can mint tokens, with the account's secret. Keep the secret on your server, mint
  tokens only for the signed-in console user who clicked the button, and never put a token where a
  third party can read it.
</Warning>

On any failure SpitShake returns the same `401` page and never says which check failed. The
exceptions are for a correctly signed token only: a role SpitShake cannot grant (the page says
read-only access is not available), the account's sign-in limit (`429`, see
[Rate limits](#rate-limits)) and the plan's user limit (`403`, see [Users and roles](#users-and-roles)).
The reason is in SpitShake's server log and, once the token's signature has been verified, in the
account's audit trail as `partner.sso_login_failed`. A correctly signed token is audited only
until a minute after its `exp` (plus the 5 s skew), whatever it is refused for; after that it is
only logged, so a token found later in a browser history cannot fill the audit trail. A token
whose `exp` is not an integer, or is further ahead than any valid token's could be (60 s after an
`iat` at most 5 s ahead), is never audited, only logged.

## Minting a token

<CodeGroup>
  ```ruby Ruby theme={null}
  require "jwt"
  require "securerandom"

  now = Time.now.to_i
  token = JWT.encode(
    {
      iss: "412",                       # the SpitShake account id, as a string
      aud: "spitshake-sso",
      iat: now,
      exp: now + 60,
      jti: SecureRandom.uuid,
      sub: "user_8f3a",                 # your id for the console user
      name: "Ada Lovelace",
      email: "ada@example.com",         # optional
      role: "editor",                   # admin | editor
      return_to: "/templates",
      amr: [ "pwd", "mfa" ]             # optional
    },
    ENV.fetch("SPITSHAKE_SSO_SECRET"),  # the account's partner_sso_secret
    "HS256"
  )
  ```

  ```javascript Node theme={null}
  import jwt from "jsonwebtoken";
  import { randomUUID } from "node:crypto";

  // jsonwebtoken sets iat; expiresIn sets exp = iat + 60.
  const token = jwt.sign(
    {
      iss: "412",                 // the SpitShake account id, as a string
      aud: "spitshake-sso",
      jti: randomUUID(),
      sub: "user_8f3a",           // your id for the console user
      name: "Ada Lovelace",
      email: "ada@example.com",   // optional
      role: "editor",             // admin | editor
      return_to: "/templates",
      amr: ["pwd", "mfa"],        // optional
    },
    process.env.SPITSHAKE_SSO_SECRET, // the account's partner_sso_secret
    { algorithm: "HS256", expiresIn: 60 }
  );
  ```
</CodeGroup>

## Users and roles

* The first sign-in for a `sub` creates a SpitShake user in that account; later sign-ins with the
  same `sub` return to it. The same `sub` in two accounts is two separate users.
* SSO signs in only users it created itself. It never links to an existing SpitShake user, even
  one with the same email. Every SSO user's own SpitShake email is a non-routable placeholder
  (`sso-…@partner-sso.invalid`), whatever you send.
* **An SSO user signs in only through you.** SpitShake refuses their password sign-in and their
  "Forgot password" requests, answering exactly as it does for an address it does not know, and
  they cannot set a SpitShake email or password of their own, whatever address they have.
  [Disabling them](#disabling-a-user) is how you cut their access.
* The `email` you send is the user's contact: SpitShake sends it the mail it would send to the
  user's email (security alerts to the account's admins, and a document they sent being
  completed or declined). Each sign-in replaces it with the token's `email`, and a sign-in without
  one, or with one that is not a valid address, clears it; with none, that mail is not sent. It
  is never verified, and it claims nothing: the same address can be another SSO user's contact,
  or anyone's SpitShake email, in any account. SpitShake never finds, signs in or sends a reset
  link to a user by it.
* A new user is created confirmed, with a random password nobody knows. No welcome, confirmation
  or password-reset email is sent. Being confirmed gives them nothing beyond SSO: it only matters
  to password sign-in.
* A first sign-in counts against the plan's user limit, as an invitation does. When the account
  is full, the sign-in is refused with `403` and a page that says so; users who already exist
  still sign in. Size the plan for the users you will send.
* The role follows the token on every sign-in, demotion included, with one exception: the
  account's last active admin is never demoted, so the account always has an admin. They stay
  `admin`, and the sign-in's `partner.sso_login` event records `role_not_applied:
  "last_active_admin"`. `admin` and `editor` map to the same SpitShake roles.
* **There is no read-only access.** Every SpitShake role can create and send documents, so a
  token with `role: "viewer"`, with no `role`, or with any other value is refused rather than
  signed in with more access than you meant to grant. The refusal page says that read-only access
  is not available, so you can tell it apart from an expired link. Map your read-only users to no
  button, or to `editor` if they should be able to send.
* A user who is archived in SpitShake, or [disabled](#disabling-a-user) by you, cannot sign in by SSO.
* SpitShake MFA is not asked for: your own sign-in is the gate. Send `amr` so the audit trail
  shows how the user authenticated with you.
* Because an SSO user has no password they know, they cannot do what SpitShake confirms with the
  user's password: turning off MFA, rotating the identity-handoff secret, or deleting the account.
  The API answers those requests with `403` and a message that says so (never the `401` of a wrong
  password, which would send the app to the password sign-in page), and the account settings page
  shows that message in place of its delete button. An admin who signs in with a password does
  those.

## Disabling a user

When you remove a user on your side, disable them in SpitShake. Disabling archives the user,
which blocks SSO and password sign-in. It also ends their open sessions: a tab that is already
open is signed out on its next request. Their API and OAuth tokens are revoked. The `subject`
is the token's `sub`, URL-encoded. Disabling is a revocation, so it also works on a suspended
or archived account.

```bash theme={null}
# One account
curl -X DELETE https://spitshake.io/api/partner/v1/accounts/412/sso_users/user_8f3a \
  -H "Authorization: Bearer $SPITSHAKE_PARTNER_KEY"

# Every account you manage
curl -X DELETE https://spitshake.io/api/partner/v1/sso_users/user_8f3a \
  -H "Authorization: Bearer $SPITSHAKE_PARTNER_KEY"
```

```json 200 (one account) theme={null}
{
  "subject": "user_8f3a",
  "account_id": 412,
  "user_id": 991,
  "found": true,
  "disabled": true,
  "already": false,
  "sessions_ended": true,
  "sessions_purged": 1,
  "session_purge_failed": false,
  "tokens_revoked": 0,
  "oauth_tokens_revoked": 0
}
```

```json 200 (every account) theme={null}
{
  "subject": "user_8f3a",
  "accounts_affected": [412, 413],
  "accounts": [
    { "account_id": 412, "user_id": 991, "found": true, "disabled": true, "already": false, "...": "..." },
    { "account_id": 413, "user_id": 1007, "found": true, "disabled": true, "already": false, "...": "..." }
  ]
}
```

The user is archived, their tokens are revoked and their sessions are invalidated in one
database transaction, which is what refuses them. Deleting their server-side session records
happens afterwards and is best effort: `sessions_purged` counts the records deleted. If the
session store is unavailable, `session_purge_failed` is `true`, but the user is still disabled
and every one of their sessions is still refused on its next request.

Both calls are idempotent. A subject that is unknown or already disabled answers `200` with
`"already": true` and changes nothing, so retrying is always safe. The every-account form reaches
only your own accounts, and `accounts_affected` lists those where something changed. Each change
writes a `partner.sso_user_disabled` audit event with the subject and the counts of sessions and
tokens ended. Only users created by SSO are reached; other users of the account are never
touched. There is no re-enable through the Partner API: an account admin can restore an archived
user in SpitShake, and their tokens stay revoked.

## Refusals

Besides an invalid token, SSO is refused for an account that is not partner-managed, has no
partner SSO secret, belongs to a suspended partner, or is itself suspended or archived. Each of
these gets the same generic `401` page as an invalid token.

Each successful sign-in writes a `partner.sso_login` audit event with the user, `sub`, role,
`amr`, a SHA-256 hash of the `jti`, and the client IP. Tokens are never logged or stored.

## Rate limits

30 requests per minute per client IP (a `POST` launch takes two), and 60 sign-ins per minute per
account. Only fresh tokens correctly signed for that account count against the account limit:
forged tokens and replays of an already-used token are refused before it is touched, so neither
can lock your users out. Over either limit SpitShake answers `429` with `Retry-After`. A token
refused by the account limit is not used up, so the same token can be retried until it expires.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.