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

# Authentication

> Sign every request with your service account's RSA key.

Every call to the StableMint API is signed. There are no bearer tokens, no OAuth
client-credentials flow, and no plain API-key header. You send four headers and an RSA signature
computed over a canonical string.

<Warning>
  **Never send an `Authorization` header on a signed request.** Any `Bearer` token is rejected with
  `401` `jwt_not_accepted` before the request reaches a handler — whether or not the token is valid,
  and whether or not you also sent correct signature headers. The token is never parsed; its mere
  presence is the rejection. If your HTTP client sets an `Authorization` header globally, remove it
  for StableMint calls.
</Warning>

## The four headers

| Header      | Value                                                        |
| ----------- | ------------------------------------------------------------ |
| `ApiKey`    | Your service account's API key.                              |
| `Timestamp` | Unix epoch **seconds**. Rejected outside a 60-second window. |
| `Nonce`     | A UUID, unique per request. Replays are rejected.            |
| `Signature` | Base64 RSA-SHA256 (PKCS#1 v1.5) over the canonical string.   |

All four are required. Sending some but not all returns `401` with `code: signature_required`.
Sending a dashboard session token returns `401` with `code: jwt_not_accepted`.

<Note>
  Some operations additionally require a permission on your service account. A **valid signature
  without the required permission returns `403`, not `401`** — if you are getting `403`, your
  signing is fine and the fix is a permission grant, not a code change. See
  [Service accounts](/guides/service-accounts).
</Note>

## The canonical string

Seven fields, joined with a single line feed (`\n`, U+000A), in exactly this order. **No trailing
newline.**

```
{HTTP_METHOD}\n
{PATH}\n
{CANONICAL_QUERY}\n
{TIMESTAMP}\n
{NONCE}\n
{API_SECRET}\n
{BODY_SHA256_HEX}
```

<ParamField path="HTTP_METHOD" type="string">
  Uppercase verb — `GET`, `POST`, `PUT`, `DELETE`.
</ParamField>

<ParamField path="PATH" type="string">
  The request path only — no scheme, host or query — with its leading slash, and byte-identical to
  the path you actually send, including casing. Sign the **decoded** form: if your URL contains
  percent-encoded segments, `/x/a%20b` is signed as `/x/a b`.
</ParamField>

<ParamField path="CANONICAL_QUERY" type="string">
  Empty string when there is no query. Otherwise: split into key/value pairs; sort by key in **byte
  order**, ties broken by value; re-encode keys and values with RFC 3986 percent-encoding
  (uppercase hex, space as `%20`, never `+`); join as `key=value` with `&`.

  Byte order puts uppercase before lowercase — `Z` sorts before `a`. A locale-aware or
  case-insensitive sort will produce a different string and fail verification.
</ParamField>

<ParamField path="TIMESTAMP" type="string">
  Byte-identical to the `Timestamp` header. Seconds, not milliseconds.
</ParamField>

<ParamField path="NONCE" type="string">
  Byte-identical to the `Nonce` header. Must parse as a UUID; use lowercase `8-4-4-4-12`.
</ParamField>

<ParamField path="API_SECRET" type="string">
  Your service account's API secret. It is part of the signed content — it is **never** sent as a
  header.
</ParamField>

<ParamField path="BODY_SHA256_HEX" type="string">
  Lowercase hex SHA-256 of the raw request body bytes. An empty body hashes to
  `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`.
</ParamField>

Sign the UTF-8 bytes of that string with `RSASSA-PKCS1-v1_5` over SHA-256, then base64-encode the
result.

<Tip>
  **Sign the path you send.** The gateway rewrites paths internally before forwarding, and it
  carries the path you signed across that rewrite for you. So you always sign the public `/v1/...`
  path — never an internal one.
</Tip>

## A worked example

<CodeGroup>
  ```javascript Node.js theme={null}
  import crypto from 'node:crypto';

  const API_KEY = process.env.STABLEMINT_API_KEY;
  const API_SECRET = process.env.STABLEMINT_API_SECRET;
  const PRIVATE_KEY = process.env.STABLEMINT_PRIVATE_KEY; // PEM, matches the uploaded public key
  const BASE_URL = 'https://api.stablemint.net';

  // encodeURIComponent leaves !'()* unescaped; RFC 3986 requires them escaped.
  function encodeRfc3986(value) {
    return encodeURIComponent(value).replace(
      /[!'()*]/g,
      (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase(),
    );
  }

  // Plain < / > on strings compares by code unit, which is the byte order the
  // server expects. Do not use localeCompare — it is case-insensitive by default
  // and will sort 'a' before 'Z'.
  function canonicalQuery(searchParams) {
    const pairs = [...searchParams.entries()].sort(
      (a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0),
    );
    return pairs.map(([k, v]) => `${encodeRfc3986(k)}=${encodeRfc3986(v)}`).join('&');
  }

  export async function call(method, path, body) {
    const url = new URL(path, BASE_URL);
    const rawBody = body === undefined ? '' : JSON.stringify(body);

    const timestamp = Math.floor(Date.now() / 1000).toString();
    const nonce = crypto.randomUUID();
    const bodyHash = crypto.createHash('sha256').update(rawBody, 'utf8').digest('hex');

    const canonical = [
      method.toUpperCase(),
      url.pathname,
      canonicalQuery(url.searchParams),
      timestamp,
      nonce,
      API_SECRET,
      bodyHash,
    ].join('\n');

    const signature = crypto
      .sign('sha256', Buffer.from(canonical, 'utf8'), {
        key: PRIVATE_KEY,
        padding: crypto.constants.RSA_PKCS1_PADDING,
      })
      .toString('base64');

    const response = await fetch(url, {
      method,
      headers: {
        'Content-Type': 'application/json',
        ApiKey: API_KEY,
        Timestamp: timestamp,
        Nonce: nonce,
        Signature: signature,
        // Do not set Authorization.
      },
      // Send exactly the bytes you hashed — do not re-serialise.
      body: rawBody === '' ? undefined : rawBody,
    });

    if (!response.ok) {
      throw new Error(`${response.status} ${await response.text()}`);
    }
    return response.json();
  }
  ```

  ```python Python theme={null}
  import base64, hashlib, json, os, time, uuid
  from urllib.parse import quote, urlsplit, parse_qsl

  import requests
  from cryptography.hazmat.primitives import hashes, serialization
  from cryptography.hazmat.primitives.asymmetric import padding

  API_KEY = os.environ["STABLEMINT_API_KEY"]
  API_SECRET = os.environ["STABLEMINT_API_SECRET"]
  BASE_URL = "https://api.stablemint.net"

  private_key = serialization.load_pem_private_key(
      os.environ["STABLEMINT_PRIVATE_KEY"].encode(), password=None
  )

  def canonical_query(query: str) -> str:
      pairs = sorted(parse_qsl(query, keep_blank_values=True))
      return "&".join(f"{quote(k, safe='')}={quote(v, safe='')}" for k, v in pairs)

  def call(method: str, path: str, body=None):
      parts = urlsplit(path)
      raw_body = "" if body is None else json.dumps(body, separators=(",", ":"))

      timestamp = str(int(time.time()))
      nonce = str(uuid.uuid4())
      body_hash = hashlib.sha256(raw_body.encode()).hexdigest()

      canonical = "\n".join(
          [
              method.upper(),
              parts.path,
              canonical_query(parts.query),
              timestamp,
              nonce,
              API_SECRET,
              body_hash,
          ]
      )

      signature = base64.b64encode(
          private_key.sign(canonical.encode(), padding.PKCS1v15(), hashes.SHA256())
      ).decode()

      response = requests.request(
          method,
          BASE_URL + path,
          headers={
              "Content-Type": "application/json",
              "ApiKey": API_KEY,
              "Timestamp": timestamp,
              "Nonce": nonce,
              "Signature": signature,
          },
          data=raw_body or None,
      )
      response.raise_for_status()
      return response.json()
  ```
</CodeGroup>

## What a signed request looks like on the wire

```http theme={null}
GET /v1/accounts HTTP/1.1
Host: api.stablemint.net
ApiKey: your-api-key
Timestamp: 1789000000
Nonce: 7c1a2f4e-9c1b-4d5e-8b0a-1f2c3d4e5f60
Signature: TWlu…base64…dA==
```

## Troubleshooting

Every rejection carries a `code` in its [problem+json body](/guides/errors). Branch on that, never
on `detail`.

<AccordionGroup>
  <Accordion title="401 signature_required">
    One or more of the four headers is missing. Partial header sets are treated the same as no
    credentials at all.
  </Accordion>

  <Accordion title="401 jwt_not_accepted">
    You sent an `Authorization: Bearer …` header. The public API does not accept dashboard session
    tokens, and sending one alongside correct signature headers is rejected just the same — the
    token is never parsed, so whether it is valid makes no difference. Remove the header and sign
    the request.
  </Accordion>

  <Accordion title="401 signature_invalid">
    All four headers were present, but the signature did not verify. In order of likelihood: the
    body you hashed is not byte-identical to the body you sent (re-serialising JSON between
    hashing and sending is the usual cause); the query string was not canonicalised; you signed a
    different path than you sent; or your clock is more than 60 seconds out.
  </Accordion>

  <Accordion title="401 signature_invalid on a retry">
    Nonces are single-use, so a replayed one fails exactly like a bad signature. Generate a fresh
    UUID for every attempt, including retries.
  </Accordion>

  <Accordion title="403 permission_denied">
    The signature verified — this is never a signing problem. Your service account lacks the
    permission the operation requires; grant it in the
    [Developer Hub](https://dashboard.stablemint.net/) and retry. See
    [Service accounts](/guides/service-accounts).
  </Accordion>

  <Accordion title="403 feature_not_enabled">
    The signature and the permissions are both fine. The operation belongs to a feature that is not
    provisioned for your account, so no permission grant will open it — ask your StableMint contact
    to enable it.
  </Accordion>
</AccordionGroup>

## Next

<CardGroup cols={2}>
  <Card title="Service accounts" icon="id-card" href="/guides/service-accounts">
    Create credentials and grant permissions.
  </Card>

  <Card title="Quickstart" icon="rocket" href="/quickstart">
    Make your first call.
  </Card>
</CardGroup>
