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

# Authentication

> Session tokens for browsers, HMAC-signed API keys for programmatic trading

Every endpoint accepts one of two credentials. The server picks the scheme by the
presence of the `APIKEY` header — a request carrying it is verified as HMAC and is
never retried as a session, even if the signature is bad.

| Scheme             | Wire format                                                | For                                                  |
| ------------------ | ---------------------------------------------------------- | ---------------------------------------------------- |
| **Session token**  | `Authorization: Bearer <token>`                            | Dashboards, short-lived tooling, the docs playground |
| **API key (HMAC)** | `APIKEY` + `X-Hmac-Timestamp` + `X-Hmac-Signature` headers | Trading systems                                      |

<Tip>
  In the API playground on this site, authenticate with a **session token** from
  [`POST /auth/login`](/api-reference/auth). The HMAC scheme signs the exact
  request bytes and cannot be computed by the playground.
</Tip>

## Session tokens

Exchange an email and password for a JWT:

```bash theme={null}
curl -X POST https://api.symbiosis.markets/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email": "you@example.com", "password": "..."}'
```

Send it as a bearer token. Sessions carry the `read` and `trade` scopes — never
`withdraw`.

## API keys

Mint a key from a session:

```bash theme={null}
curl -X POST https://api.symbiosis.markets/auth/api-keys \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"label": "trading-bot", "scopes": ["read", "trade"]}'
```

The response is the **only** time the raw `secret` is returned; store it then.
Keys minted from a session are capped at the session's own scopes (`read`,
`trade`), so a session can never mint a `withdraw`-scoped key.

### Scopes

| Scope      | Grants                                                |
| ---------- | ----------------------------------------------------- |
| `read`     | Balances, deposit addresses, open requests and quotes |
| `trade`    | Creating, quoting, cancelling, and accepting RFQs     |
| `withdraw` | Queueing withdrawals                                  |

## Signing requests

Each HMAC request sends three headers:

| Header             | Value                                                                    |
| ------------------ | ------------------------------------------------------------------------ |
| `APIKEY`           | The API key id                                                           |
| `X-Hmac-Timestamp` | Current Unix time in **milliseconds**                                    |
| `X-Hmac-Signature` | Base64 HMAC-SHA256 of the canonical message, keyed with the key's secret |

The canonical message is:

```
{timestamp_ms}\n{METHOD}\n{PATH_AND_QUERY}\n{BODY}
```

Three details matter:

1. **`PATH_AND_QUERY` is the full request target including the query string** —
   `/rfq/quote?request_id=...`, not `/rfq/quote`. Every GET endpoint takes its
   parameters from the query string, so they are inside the signature.
2. **`BODY` is the exact raw bytes you transmit.** Sign the serialized string you
   send, not a re-serialization. For bodyless requests (GETs, DELETEs without a
   payload) the body segment is empty — the message still ends with the third `\n`.
3. **The timestamp must be within ±30 seconds of server time** (the deployment's
   `AUTH_HMAC_TOLERANCE_SECS`), which bounds the replay window.

<CodeGroup>
  ```python signing.py theme={null}
  import base64, hashlib, hmac, json, time
  import requests

  BASE = "https://api.symbiosis.markets"
  API_KEY_ID = "..."
  API_SECRET = b"..."

  def signed_request(method: str, path_and_query: str, body: dict | None = None):
      raw = json.dumps(body).encode() if body is not None else b""
      ts = str(int(time.time() * 1000))

      message = ts.encode() + b"\n" + method.encode() + b"\n" \
          + path_and_query.encode() + b"\n" + raw
      signature = base64.b64encode(
          hmac.new(API_SECRET, message, hashlib.sha256).digest()
      ).decode()

      return requests.request(
          method,
          BASE + path_and_query,
          data=raw if body is not None else None,
          headers={
              "APIKEY": API_KEY_ID,
              "X-Hmac-Timestamp": ts,
              "X-Hmac-Signature": signature,
              **({"Content-Type": "application/json"} if body is not None else {}),
          },
      )

  # GET with query params — the query string is part of the signature.
  r = signed_request("GET", "/custody/get-usdc-balance")

  # POST with a body — sign the exact bytes sent.
  r = signed_request("POST", "/rfq/request", {
      "asset_id": "0x...",
      "venue_id": "polymarket",
      "amount": "1000000",
      "side": "Bid",
  })
  ```

  ```typescript signing.ts theme={null}
  import { createHmac } from "node:crypto";

  const BASE = "https://api.symbiosis.markets";
  const API_KEY_ID = "...";
  const API_SECRET = "...";

  async function signedRequest(
    method: string,
    pathAndQuery: string,
    body?: unknown,
  ): Promise<Response> {
    const raw = body === undefined ? "" : JSON.stringify(body);
    const ts = Date.now().toString();

    const message = `${ts}\n${method}\n${pathAndQuery}\n${raw}`;
    const signature = createHmac("sha256", API_SECRET)
      .update(message)
      .digest("base64");

    return fetch(BASE + pathAndQuery, {
      method,
      body: body === undefined ? undefined : raw,
      headers: {
        APIKEY: API_KEY_ID,
        "X-Hmac-Timestamp": ts,
        "X-Hmac-Signature": signature,
        ...(body === undefined ? {} : { "Content-Type": "application/json" }),
      },
    });
  }
  ```
</CodeGroup>

### Failure modes

| Status | Meaning                                                                                                                      |
| ------ | ---------------------------------------------------------------------------------------------------------------------------- |
| `401`  | Unknown key, timestamp outside the ±30s window, or the signature does not verify — check the canonical message byte-for-byte |
| `403`  | Credentials verified, but the key lacks the scope the endpoint requires                                                      |

A common cause of `401` is signing `path` while sending `path?query`, or letting
an HTTP client re-serialize the JSON body after signing.

## Websocket tickets

Browsers cannot set headers on websocket upgrades, so socket connections
authenticate with a short-lived ticket passed as a `?ticket=` query parameter:

* `POST /auth/ws-ticket` — from a session; ticket carries `read`.
* `POST /auth/ws-ticket/signed` — HMAC-signed; ticket inherits the key's scopes.

Both are listed under [Auth](/api-reference/auth).

Tickets expire quickly — mint one immediately before connecting.
