# Authentication

The Partner API uses the OAuth 2.0 **client credentials** grant
([RFC 6749 §4.4](https://datatracker.ietf.org/doc/html/rfc6749#section-4.4)).
You exchange a client ID and secret for a short-lived bearer token, then send
that token on every other call.

## Getting a token

Credentials go either in the request body or in an HTTP Basic header - both are
accepted, and the body may be JSON or form-encoded.

```bash title="Credentials in the body"
curl -X POST https://stg-s2s.restake.gg/api/v1/s2s/auth/token \
  -H 'content-type: application/json' \
  -d '{
    "grant_type": "client_credentials",
    "client_id": "rsk_live_…",
    "client_secret": "…"
  }'
```

```bash title="Credentials in a Basic header"
curl -X POST https://stg-s2s.restake.gg/api/v1/s2s/auth/token \
  -u "$RESTAKE_CLIENT_ID:$RESTAKE_CLIENT_SECRET" \
  -H 'content-type: application/x-www-form-urlencoded' \
  -d 'grant_type=client_credentials'
```

```json title="Response"
{
  "access_token": "eyJhbGciOiJIUzI1NiIs…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "pools:read users:read users:write transactions:write"
}
```

Send it as a bearer token on every subsequent request:

```bash
curl https://stg-s2s.restake.gg/api/v1/s2s/me \
  -H "authorization: Bearer $ACCESS_TOKEN"
```

### Caching the token

Tokens are valid for one hour by default (`expires_in` is authoritative - read
it rather than hard-coding the number). Cache the token in your backend and
re-fetch it shortly before it expires; do not call the token endpoint on every
request.

The token response is served `Cache-Control: no-store`. A cached bearer token in
a shared proxy is a leaked one.

## Scopes

A credential is issued with a set of scopes, and each endpoint requires one:

| Scope | Grants |
| --- | --- |
| `pools:read` | Reading staking pools and their TVL/APY history |
| `stocks:read` | Reading the stock catalogue, prices, quotes and charts |
| `bridge:read` | Reading bridge routes, quotes and transfers. Building and submitting a bridge needs it **and** `transactions:write` |
| `users:read` | Reading the users you created |
| `users:write` | Creating, updating and closing users |
| `portfolio:read` | Reading a user's balances, value history and open positions |
| `transactions:read` | Reading a user's indexed transfers |
| `transactions:write` | Building and submitting staking transactions, stock trades and bridges |
| `rates:read` | Reserved for endpoints not yet exposed |
| `all:read` | Every scope above ending in `:read`, including ones added later |
| `all:write` | Every scope above ending in `:write`, including ones added later |

### Wildcard scopes

`all:read` and `all:write` are for integrations that use the whole API and do
not want a credential reissued each time we expose an endpoint. Ask for them
instead of a list, and they cover every current *and future* permission on their
side.

They are two separate grants: `all:read` never carries a write permission, so an
integration that only reads stays that way. A credential holding both can reach
every partner endpoint.

Everywhere the API reports what a token can do, it reports the individual
permissions rather than the wildcard - the `scope` of the token response, and
the `scopes` that `GET /s2s/me` echoes back:

```json title="Response for a credential granted all:read all:write"
{
  "access_token": "eyJhbGciOiJIUzI1NiIs…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "pools:read stocks:read bridge:read rates:read users:read portfolio:read transactions:read users:write transactions:write"
}
```

That expansion happens when the token is issued, so a token grants what the
wildcard covered at that moment. A permission added on our side reaches you with
the next token you fetch - no change to your credential, and nothing to redeploy.

### Asking for less

Ask for a narrower token by passing a space-delimited `scope` parameter - useful
when a component of your system only needs to read:

```bash
curl -X POST https://stg-s2s.restake.gg/api/v1/s2s/auth/token \
  -H 'content-type: application/json' \
  -d '{
    "grant_type": "client_credentials",
    "client_id": "rsk_live_…",
    "client_secret": "…",
    "scope": "pools:read"
  }'
```

Omitting `scope` grants everything the credential holds. Asking for a scope the
credential does not have is an `invalid_scope` error rather than a silent
downgrade.

`scope` takes the wildcards as well: `"scope": "all:read"` on a credential
granted both wildcards is how a reporting job gets a token that cannot move
funds, without listing the read permissions one by one. Asking for a wildcard
your credential does not fully hold is `invalid_scope` - a wildcard is never a
way around what was granted.

Your effective scopes are always the intersection of what the credential holds
and what your organisation holds - if a scope is withdrawn from the
organisation, tokens stop carrying it everywhere at once. That applies to a
wildcard too: it only ever resolves to permissions your organisation still has.

## IP allowlist

Credentials can be pinned to a set of IP addresses or CIDR ranges. If yours is,
a request from anywhere else is rejected even with a valid token. Tell us the
egress addresses of your backend when you ask for a credential, and again if
they change.

## Revocation and rotation

Revocation is **immediate**, not "when your token expires" - a revoked
credential stops working within seconds, and so does every token already issued
to it. The same holds if your organisation's account is suspended.

To rotate a secret without downtime, ask for a rotation with a grace period: the
new credential is issued and the old one keeps working for the overlap you
request, so you can roll out the new secret and only then let the old one lapse.

<Callout type="danger" title="The secret is shown once">
  A client secret is returned exactly once, at the moment the credential is
  created or rotated. Only a hash is stored, so it cannot be recovered or shown
  again - if it is lost, the credential has to be rotated.
</Callout>

## Errors

| Status | Meaning |
| --- | --- |
| `400` `invalid_request` | Missing or malformed parameters |
| `400` `unsupported_grant_type` | `grant_type` is not `client_credentials` |
| `400` `invalid_scope` | You asked for a scope the credential does not hold |
| `401` `invalid_client` | Unknown client ID, wrong secret, or the credential/organisation is not active |
| `401` on any other endpoint | Missing, malformed or expired access token |
| `403` on any other endpoint | Valid token, but it lacks the scope that endpoint requires |

The split between `401` and `403` is deliberate: `401` means *fix your token*,
`403` means *ask us for that permission*. A `401` carries a
`WWW-Authenticate: Bearer` challenge.
