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

# Authentication

> How your platform authenticates with the Spritz API.

The Spritz API is primarily a backend-to-backend integration. In most cases your
servers talk directly to Spritz, acting on behalf of your [users](/guides/definitions#user),
and you authenticate those requests by signing them with HMAC.

There are also times when you want a user's own device (their browser or mobile app)
to call Spritz directly. For those cases you mint a short-lived token on your backend
and hand it to the device, which then uses it to authenticate its own requests.

## Two integration shapes

Both are fully supported, and neither is "the advanced one" — pick per use case:

* **Server-to-server**: your backend talks to Spritz with HMAC, stores what it needs,
  and serves your clients however you like. Webhooks keep your copy in sync. Choose this
  when you want to own the data model, decorate Spritz data with your own, or present one
  unified API to your clients. The trade-off: you're maintaining a mirror, and mirrors
  drift — webhooks plus periodic reconciliation against the API keep you honest.
* **Client-direct tokens**: your backend does the minimum — create Spritz users and keep
  the reference — and mints a short-lived token when a client needs data. Choose this
  when you don't want to be in the data business. The trade-off: clients handle token
  refresh, and you see less of what users are doing without querying the API.

They aren't mutually exclusive. Many integrators run both: server-to-server for the
money-moving and compliance-sensitive paths (creating users, adding bank accounts,
initiating payouts), client-direct tokens for read-heavy screens (transaction history,
status polling). Client-direct is not a lesser path — the Spritz app itself uses the
public API exactly this way, so it's a well-trodden, well-optimized route.

## Acting on behalf of your users

Almost everything in the Spritz API happens on behalf of one of your users. When you
create an off-ramp, connect a bank account, or fund a card, you're doing it for a
specific user, and your credentials tell Spritz which user that is.

The exception is the **integrator endpoints**. Those are about you, not your users.
They cover things like your integrator profile, your API keys, and your webhook
configuration. A simple way to hold the distinction: user endpoints act on your
users' resources, and integrator endpoints manage your own integrator account.

Because integrator endpoints aren't tied to a user, you **don't send a user API key**
with them. You sign them with your integrator credentials only (the three signing
headers below, without the `Authorization` header).

For a backend request to a user-scoped endpoint, HMAC and the user bearer are one
combined authentication mode: all four headers are required. A short-lived `spr_`
integrator token is a separate frontend authentication mode and does not use HMAC.

## Backend to backend with HMAC

This is the primary way to use the Spritz API. Your backend signs each request with
HMAC-SHA256, so Spritz can confirm it really came from you and that nothing was
changed in transit.

Signing produces three headers. Alongside them you send `Authorization` to identify
the user you're acting for (this one isn't part of the signature).

| Header             | Value                                                        |
| ------------------ | ------------------------------------------------------------ |
| `X-Integrator-Key` | Your integrator API key (`ik_…`)                             |
| `X-Timestamp`      | Unix timestamp in milliseconds                               |
| `X-Signature`      | `sha256=<hex>`, computed as below                            |
| `Authorization`    | `Bearer <user-api-key>`, the user you're acting on behalf of |

### How the signature is built

You sign a canonical string that pins together the timestamp, method, path, and a hash
of the body:

```
{timestamp}.{METHOD}.{path}.{bodyHash}
```

* `timestamp` is the same value you send in `X-Timestamp`
* `METHOD` is the HTTP method in uppercase (`GET`, `POST`, and so on)
* `path` is the request path including its query string, with query params sorted by
  key and URL-encoded (for example `/v1/off-ramps?limit=10&status=pending`)
* `bodyHash` is the SHA-256 hex digest of the raw request body, or an empty string when
  there's no body

Duplicate query keys are not allowed on HMAC-signed requests. The API rejects them
with `401`; use one value per key.

### Example

Here's the full signing algorithm. It uses the Web Crypto API, so it runs as-is on
Node 18+, Bun, Deno, Cloudflare Workers, and browsers.

```ts theme={null}
const SIGNATURE_PREFIX = "sha256="

function hexEncode(buffer: ArrayBuffer): string {
  return Array.from(new Uint8Array(buffer))
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("")
}

async function sha256Hex(data: string): Promise<string> {
  const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(data))
  return hexEncode(hash)
}

async function hmacSha256Hex(secret: string, data: string): Promise<string> {
  const enc = new TextEncoder()
  const key = await crypto.subtle.importKey(
    "raw",
    enc.encode(secret),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"],
  )
  return hexEncode(await crypto.subtle.sign("HMAC", key, enc.encode(data)))
}

// Query params are sorted by key and URL-encoded before signing.
function buildPathWithQuery(url: URL): string {
  const entries = [...url.searchParams.entries()]
  if (entries.length === 0) return url.pathname

  const seen = new Set<string>()
  for (const [key] of entries) {
    if (seen.has(key)) throw new Error(`Duplicate query parameter: ${key}`)
    seen.add(key)
  }

  const query = entries
    .sort(([a], [b]) => a.localeCompare(b))
    .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
    .join("&")
  return `${url.pathname}?${query}`
}

export async function stampRequest(
  integratorKey: string,
  integratorSecret: string,
  method: string,
  url: string,
  body?: string | null,
) {
  const path = buildPathWithQuery(new URL(url))
  const timestamp = Date.now()
  const bodyHash = body ? await sha256Hex(body) : ""
  const payload = `${timestamp}.${method.toUpperCase()}.${path}.${bodyHash}`
  const signature = `${SIGNATURE_PREFIX}${await hmacSha256Hex(integratorSecret, payload)}`

  return {
    "X-Integrator-Key": integratorKey,
    "X-Signature": signature,
    "X-Timestamp": String(timestamp),
  }
}
```

Then send the signed headers along with the `Authorization` header for the user:

```ts theme={null}
const url = "https://platform.spritz.finance/v1/off-ramps"
const body = JSON.stringify({
  /* request fields */
})

const signed = await stampRequest(
  process.env.SPRITZ_INTEGRATOR_KEY!,
  process.env.SPRITZ_INTEGRATOR_SECRET!,
  "POST",
  url,
  body,
)

const res = await fetch(url, {
  method: "POST",
  headers: {
    ...signed,
    "Content-Type": "application/json",
    Authorization: `Bearer ${userApiKey}`,
  },
  body,
})
```

<Warning>
  Your timestamp has to be within 5 minutes of Spritz's server time, otherwise the
  request is rejected. This keeps a captured request from being replayed later.
</Warning>

## From a user's device with an integrator token

Sometimes you'd rather have the user's device call Spritz directly instead of routing
everything through your backend. For that, your backend mints a short-lived integrator
token (it starts with `spr_`) and passes it to the device. The device then sends it in
the `Authorization` header — no signing headers, no user API key:

```bash theme={null}
curl https://platform.spritz.finance/v1/users/me \
  -H "Authorization: Bearer spr_…"
```

Because the token is short-lived and minted per user, you can safely hand it to a
browser or mobile app without exposing your backend credentials. It works on every
user-scoped endpoint — including quote creation and transaction parameters — but prefer
server-to-server for money-moving paths: a token expiring mid-flow is a recovery you
don't have to have.

### Minting a token

Exchange HMAC-signed integrator credentials (no user bearer — this is an integrator
endpoint) for a token scoped to one user:

```bash theme={null}
curl -X POST https://platform.spritz.finance/v1/integrator/tokens \
  -H "Content-Type: application/json" \
  # plus your integrator signing headers
  -d '{ "userApiKey": "ak_…", "expiresIn": 3600 }'
```

```json theme={null}
{
  "accessToken": "spr_eyJ…",
  "userId": "63d12d3b577fab6c6382136e",
  "tokenType": "Bearer",
  "expiresIn": 3600,
  "expiresAt": "2026-09-03T17:00:00.000Z"
}
```

`expiresIn` is optional, in seconds, default and maximum `3600` (one hour). An unknown
`userApiKey` — or one for a user that isn't yours — gets a `400`, not a `401`. Minting
is rate-limited to 100 requests per minute per integrator, so mint per session, not per
request.

## When something's wrong

A missing or invalid credential returns a `401`. A valid credential that isn't allowed
to touch a particular resource returns a `403`. Both come back as
[problem responses](/guides/errors) you can parse the same way as any other error.

Every credential failure — bad signature, unknown key, expired or stale timestamp,
tampered body — returns the same generic `401` with `detail: "Authentication failed"`.
That's deliberate: the response never reveals which part failed. When you hit a mystery
`401`, check in this order:

1. **Clock drift.** `X-Timestamp` must be within 5 minutes of Spritz server time, in
   milliseconds. A drifting container clock is the most common cause.
2. **Body serialization.** Sign the exact bytes you send. If your JSON serializer
   reorders keys or adds whitespace between signing and sending, the body hash won't
   match.
3. **Query canonicalization.** Params sorted by key, URL-encoded, no duplicate keys —
   duplicates are a `401`, not a last-wins.
4. **The user bearer.** User-scoped endpoints need all four headers; integrator
   endpoints are signed with the three signing headers only — no `Authorization`.
