> The Markdown source of https://developer.suredocs.ie/authentication.html. The index of these files is [llms.txt](llms.txt), and the brief for coding agents building an integration is [agents.md](agents.md).

# Authentication

Product APIs take an OAuth 2 access token as a bearer token. You get access tokens from the token endpoint
with the refresh-token grant (RFC 6749 section 6). This page is the whole of it: the request, the response,
every error, how long things last, and how to store what we give you.

## The parts

- An **integration** is your software's identity. Its id, `client_id` in OAuth's terms, looks like
  `int_example_a1b2c3d4`. It is not a secret.
- A **refresh token** is the secret. It starts with `sd_rt_` and is opaque to you. We issue one per
  integration, and only one is ever live.
- An **access token** is what you send with each call. It lasts 1 hour, acts for one
  brokerage only, and is opaque: never parse it.
- A **grant** is one brokerage's permission for your integration to act for it. Without a live grant, an
  access token for that brokerage is useless.
- A **scope** names something a token may do. A call needs the scope on the token and in the grant.

## Backup developers

An integration has one owner: whoever created it in the [developer portal](/portal). You can appoint
colleagues as **backup developers** so the integration is not a single point of failure — from the
integration's detail screen, add them by the email address they sign in to the portal with. They must
already have signed in once: this never creates an account for them.

A backup developer can do everything the owner can — view and edit the integration, generate, rotate and
revoke its refresh token, and see which brokerages have granted it access — except delete the integration or
change who else is a backup developer. Those two stay the owner's own levers, so a brokerage's worth of
colleagues can never lock the owner out of an integration they created.

## Getting a grant

A refresh token lets your integration authenticate; it does nothing for a brokerage until that brokerage
grants it access. There is no API call for this — a person at the brokerage decides it, on our consent
screen, the same way Atlassian, Google and Microsoft ask.

Send the brokerage's user to:

```
https://suredocs.ie/authorize?client_id=YOUR_CLIENT_ID&broker_id=THEIR_BROKER_ID&scope=upload_file&state=YOUR_STATE&redirect_uri=YOUR_REDIRECT_URI
```

| Parameter | Meaning |
| --- | --- |
| `client_id` | Your integration id. |
| `broker_id` | The brokerage you are asking. You supply this — often from your own onboarding flow — we do not have a directory for you to browse. By default it is their domain name, to guarantee uniqueness. |
| `scope` | The scopes you are asking for, separated by spaces. Ask for only what you need: a grant carries exactly the scopes shown on the screen. |
| `state` | Opaque to us; sent back on the redirect unchanged, for you to correlate the answer with the request that sent it. |
| `redirect_uri` | Where we send the user back. Must be one you registered when we set your integration up, matched exactly: no prefix, no wildcard. Ask us to register a new one. |

We reply by redirecting to your `redirect_uri` with two query parameters, `state` (yours, unchanged) and
`result` (`granted` or `denied`). There is no code to exchange: the grant itself is the outcome, and the
next call to the token endpoint (above) is how you find out it is live. A denied or abandoned request
looks the same to you as one nobody ever opened — we do not tell you which.

`/authorise` (the Irish spelling) works identically, if you would rather use it.

## Exchange a refresh token

`POST https://api.suredocs.ie/oauth/token`

Send a form (`application/x-www-form-urlencoded`, the standard) or JSON.

| Parameter | Required | Meaning |
| --- | --- | --- |
| `grant_type` | Yes | Always `refresh_token`. |
| `client_id` | Yes | Your integration id. |
| `refresh_token` | Yes | The refresh token: the last one you received. |
| `broker_id` | Yes | The brokerage the access token is for. |
| `scope` | No | Scopes wanted, separated by spaces. Without it, you get every scope your refresh token and the brokerage's grant share. Asking for one that is not available is an error. It narrows the access token only. |

```bash
curl -sS https://api.suredocs.ie/oauth/token \
  --data-urlencode grant_type=refresh_token \
  --data-urlencode client_id="$CLIENT_ID" \
  --data-urlencode refresh_token="$REFRESH_TOKEN" \
  --data-urlencode broker_id="$BROKER_ID"
```

The response is `200` with a JSON body:

```json
{
  "access_token": "<the access token, a long opaque string>",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "<the NEXT refresh token>",
  "scope": "upload_file"
}
```

| Field | Meaning |
| --- | --- |
| `access_token` | The bearer token. |
| `token_type` | Always `Bearer`. |
| `expires_in` | Seconds the access token is valid for. |
| `refresh_token` | The next refresh token. It replaces the one you sent. |
| `scope` | The scopes granted, separated by spaces. Absent when there are none. |

The response is never to be cached, and carries `Cache-Control: no-store`, because it holds credentials.

## Every exchange returns a new refresh token

Each successful exchange returns a new refresh token beside the access token, and the one you presented
stops working (RFC 9700 section 4.14.2). A refresh token that is presented after it has been used is treated
as stolen: we cannot tell which of two holders is the thief, so we revoke the integration's live refresh
token, and it has to be reissued by us. Ordinary OAuth libraries already handle a new refresh token in a
response. These rules are what your code has to add:

- **Store the new refresh token durably before you use the access token** that came with it. A process that
  dies between receiving a response and storing its refresh token has lost the token, and the grace
  period below is the only recovery.
- **Exchange from one place at a time.** One refresh token covers every brokerage your integration acts for,
  so exchanges for different brokerages must be serialised too, not only exchanges for the same one. Two
  servers refreshing independently looks exactly like theft and revokes the token.
- **On a timeout or a lost response, retry with the same refresh token within 10 minutes.** Never
  retry with an older one. The retry issues a fresh successor, and the response you lost is not repeated.
- **Do not loop on `invalid_grant`.** It does not fix itself. Contact us.
- **Exchange when the access token is about to expire, not before every call.** An access token is good
  for 1 hour.

## How long things last

| What | Lifetime |
| --- | --- |
| Access token | 1 hour. |
| Refresh token, absolute | 365 days from the token we issued you. Rotation does not restart this: once a year we issue a new token, which is when somebody confirms the integration is still wanted. We email your integration's contact address 30 days before. |
| Refresh token, unused | 90 days. A token not exchanged for that long dies. Every exchange issues a new token, so this restarts at each one. |
| Retry of a lost response | 10 minutes from the first exchange, with the previous refresh token. |

When we revoke a refresh token or suspend an integration, the token endpoint refuses you at once. Access
tokens already issued keep working until they expire, which is at most 1 hour, apart from a
suspension or a revoked grant, which are refused within about a minute.

## Calling an API

Send the access token in the `Authorization` header, and nowhere else:

```
Authorization: Bearer <access token>
```

A token in a query string or a request body is treated as no token at all. Never send a token over plain
HTTP.

### Scopes

The scopes are a closed list, and no scope implies another.

| Scope | Lets a token call |
| --- | --- |
| `upload_file` | `POST /files/v1` |

## Errors from the token endpoint

The token endpoint answers errors in the shape RFC 6749 section 5.2 gives, because that is what OAuth
libraries parse. This is different from the product APIs' error shape below.

```json
{
  "error": "invalid_grant",
  "error_description": "The refresh token cannot be used."
}
```

Branch on `error` and the HTTP status. `error_description` is for a person, may be reworded at any time, and
must never be matched.

| `error` | Status | What it means | What to do |
| --- | --- | --- | --- |
| `invalid_request` | 400 | A parameter is missing, repeated or malformed, or the body could not be read. | Fix the request. Retrying it unchanged fails again. |
| `unsupported_grant_type` | 400 | `grant_type` is not `refresh_token`. | Send `refresh_token`. It is the only grant. |
| `invalid_client` | 401 | The `client_id` is unknown, the integration is suspended, or it does not own that refresh token. One answer covers all three. | Check the id and token belong together. If they do, contact us. |
| `invalid_grant` | 400 | The refresh token is unknown, expired, revoked, already used (outside the retry window), or replaced by a newer one we issued. | Stop. Do not retry, and do not loop. If only one server should be exchanging, look for a second. Contact us and we will issue a new token. |
| `invalid_target` | 400 | The brokerage does not exist, or has not granted your integration access. We do not say which, so the endpoint cannot be used to discover our brokerages. | Check `broker_id`. Ask the brokerage to confirm its grant. |
| `invalid_scope` | 400 | A scope you asked for is not available for this refresh token and brokerage. | Ask for fewer scopes, or omit `scope`. |
| `server_error` | 500 | Our fault. | Retry with the same refresh token after a short pause, backing off. Quote the `Request-Id` header if it persists. |
| `rate_limited_token` | 429 | Too many exchanges for your integration in a minute. | Wait `Retry-After` seconds. A correct integration exchanges about once an hour per brokerage. |
| `rate_limited_token_daily` | 429 | Too many exchanges for your integration today. | Wait `Retry-After` seconds, and look for a loop. |
| `rate_limited_token_ip` | 429 | Too many exchanges from your IP address in a minute. | Wait `Retry-After` seconds. |

## Errors from a product API

A product API answers every error with one shape:

```json
{
  "status": 403,
  "reason": "insufficient_scope",
  "message": "This token does not carry the upload_file scope.",
  "requestId": "2f4a5f8e-9d3c-4b6e-8f4a-0c5b1e7d2a90"
}
```

`reason` is what your code branches on. `message` is for a person and is never matched. `requestId` is the
`Request-Id` header again: quote it when you report a problem.

| `reason` | Status | What it means | What to do |
| --- | --- | --- | --- |
| `invalid_token` | 401 | There is no access token, or it is malformed, expired, wrongly signed or not ours. The `WWW-Authenticate` header says `Bearer`, with `error="invalid_token"` when a token was sent. | Exchange your refresh token for a new access token once, and repeat the call once. If that fails too, stop. |
| `insufficient_scope` | 403 | The token does not carry a scope the operation needs. The `WWW-Authenticate` header's `scope` attribute names the scopes the operation needs. | Get a token that carries them. That needs the scope on your refresh token and in the brokerage's grant. A retry with the same token fails again. |
| `not_granted` | 403 | Your integration is suspended, or the brokerage has no live grant for it, has revoked it, or the grant lacks a scope. One wording covers every cause, so it cannot be used to discover which brokerages exist. | Do not retry, and a new access token will not help. Ask the brokerage, or contact us. |
| `rate_limited_<policy>` | 429 | A rate limit refused the call. See [Rate limits](rate-limits.md). | Wait `Retry-After` seconds. |

Operations also have their own reasons, listed in the [API reference](reference/index.html).

### Do not retry a 401 in a loop

A 401 is not a reason to send the same request again. It means the access token is not usable, so nothing
changes until you have a different one. The right response is one exchange for a new access token, and
one repeat of the call. If the repeat is a 401 as well, something else is wrong: stop, and report the
`Request-Id` of the failing call. A loop that exchanges on every 401 will exhaust the token endpoint's rate
limits, and a loop that sends several exchanges at once looks like theft and revokes your refresh token.

## Storing a refresh token safely

A refresh token is a credential for every brokerage that has granted your integration access. Treat it as
you would a database password.

- Keep it in a secret store or an encrypted parameter, and never in source control, a container image, a
  ticket or a chat message.
- Never write it to a log, an error report or a URL. The `sd_rt_` prefix is there so that a leaked token can
  be found by a secret scanner; make sure one is looking.
- Give it to as few processes as you can. Exactly one of them exchanges it, holding a lock, so that two
  exchanges are never in flight.
- Write the new token to the store before you use the access token that arrived with it, and make the write
  atomic, so a crash leaves you with the old token or the new one and never neither.
- If you think it has leaked, tell us at once at support@suredocs.ie. We revoke it and issue a new one.
  Access tokens already minted from it live out their 1 hour.
