# REST API overview

The REST API is how your own code manages **access keys**: listing them, looking one up, creating
one and deleting one, without a person clicking through the dashboard. It suits the moments when
keys come and go with your automation - a CI pipeline that creates a key limited to one bucket for
a deploy and deletes it when the job is done, a scheduled job that rotates a service's key by
creating a new one and deleting the old, or a tool of yours that hands each new project a key of
its own. Access keys are all it manages; buckets and objects are the
[S3-compatible API](https://docs.strg.com/s3-api/overview)'s work.

## Operations

These are the operations the API answers, each with a page of its own:

| Operation | Request | |
| --- | --- | --- |
| List access keys | `GET /v1/access-keys` | [List access keys](https://docs.strg.com/rest-api/list-access-keys) |
| Get an access key | `GET /v1/access-keys/{id}` | [Get an access key](https://docs.strg.com/rest-api/get-access-key) |
| Create an access key | `POST /v1/access-keys` | [Create an access key](https://docs.strg.com/rest-api/create-access-key) |
| Delete an access key | `DELETE /v1/access-keys/{id}` | [Delete an access key](https://docs.strg.com/rest-api/delete-access-key) |
| The OpenAPI document | `GET /v1/openapi.json` | [OpenAPI document](https://docs.strg.com/rest-api/overview#openapi-document) |

## Endpoint and versioning

Every request goes to one base URL, and speaks JSON both ways:

| | |
| --- | --- |
| Base URL | `https://api.strg.com/v1/` |
| Request bodies | JSON, sent with `Content-Type: application/json`, at most 64 KiB. A field the operation does not define is answered `400 invalid_request`, so a misspelt field name shows up straight away rather than being silently dropped |
| Responses | JSON (`application/json`), errors as `application/problem+json` - [Errors](https://docs.strg.com/rest-api/overview#errors) has the details. Every response carries `Cache-Control: no-store` |
| Times | ISO 8601, in UTC - `2026-10-18T09:30:00.000Z` |

**Within `/v1`, fields are only ever added** - never removed, renamed or changed in type. A client
that ignores response fields it does not know keeps working, unchanged, each time we add one.

The API sends no CORS headers, so a web page in a browser cannot call it. It belongs on a server,
in a script or in a CI job, where the key's secret stays out of the page's reach.

## Authentication

Every request but the [OpenAPI document](https://docs.strg.com/rest-api/overview#openapi-document) is made with an access key that holds
**Manage access keys through the API** (`api:access-management`), sent as a bearer token of its id
and secret joined by a colon:

```http
Authorization: Bearer <access key id>:<secret>
```

Such a key is created on the **Access Keys** page of the dashboard (`https://app.strg.com/access-keys`).
The **Full Access** template already includes the permission; with any other template, it is the
**Manage access keys through the API** box under **REST API**. Like every key, it also carries at
least one storage permission - [Access keys](https://docs.strg.com/access-keys#permissions) explains them all.

A first request, listing one key, looks like this:

**curl**

```bash
curl -sS "https://api.strg.com/v1/access-keys?limit=1" \
  -H "Authorization: Bearer $AWS_ACCESS_KEY_ID:$AWS_SECRET_ACCESS_KEY"
```

**Python (requests)**

```python
import os

import requests

key_id = os.environ["AWS_ACCESS_KEY_ID"]
secret = os.environ["AWS_SECRET_ACCESS_KEY"]

response = requests.get(
    "https://api.strg.com/v1/access-keys",
    params={"limit": 1},
    headers={"Authorization": f"Bearer {key_id}:{secret}"},
)
response.raise_for_status()
print(response.json())
```

**JavaScript (fetch)**

```js title="first-request.mjs"
const response = await fetch("https://api.strg.com/v1/access-keys?limit=1", {
  headers: { Authorization: `Bearer ${process.env.AWS_ACCESS_KEY_ID}:${process.env.AWS_SECRET_ACCESS_KEY}` },
});
if (!response.ok) throw new Error(`${response.status}: ${(await response.json()).detail}`);
console.log(await response.json());
```

**Every authentication failure is the same `401`**, with code `unauthenticated` and
`WWW-Authenticate: Bearer realm="strg.com"`: a missing or malformed header, an unknown key id, a
wrong secret, a deleted key, a key without **Manage access keys through the API**, or a closed
account. Keeping them alike means the answer tells someone guessing at keys nothing about which
part they got right.

### What the key reaches

The calling key's own buckets, prefix and permissions matter differently for reading and deleting
keys than for creating them:

| Operation | Reaches |
| --- | --- |
| List, get, delete | **Every access key of your account**, whatever its buckets, prefix and permissions - the calling key included. The keys STRG manages for you stay out of it: [the dashboard's own key](https://docs.strg.com/access-keys#the-dashboards-own-key) and the assistant's are not listed, and answer `404` |
| Create | **A new key never has more access than the calling key**: permissions the calling key has, buckets it can reach - *all buckets* when it is for all buckets itself - and, when it has an object prefix, a prefix that starts with it. A request asking for more is answered `403 exceeds_parent` |

**Warning: This permission reaches every key**

A key with **Manage access keys through the API** can delete every other key in your account - even
a key limited to one bucket can delete a key for all buckets. A deleted key stops working within 60
seconds, everywhere, and cannot be restored. This permission belongs with a script or tool you trust
with all of your keys.

A key can delete itself, like any other: its requests stop being accepted within 60 seconds.

Creating a key needs a verified card on your account, just as in the dashboard. Adding a card is a
person's step, on the **Billing** page; [Billing](https://docs.strg.com/billing#a-card-is-required) explains why.

## Errors

Every error is an [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem, sent as
`application/problem+json`, so a client reads every failure the same way:

```json
{
  "type": "about:blank",
  "title": "Forbidden",
  "status": 403,
  "code": "exceeds_parent",
  "detail": "Delete buckets is not a permission of the key you are using - a new key cannot have more access than it."
}
```

Each field of the body has its own job:

| Field | |
| --- | --- |
| `type` | Always `about:blank` |
| `title` | The HTTP status's own phrase |
| `status` | The HTTP status code |
| `code` | **What your client branches on** - stable, from the table below |
| `detail` | A sentence for a person, naming the field or the rule. Its wording can change, so it is meant for reading rather than parsing |

These are every `code` the API answers, with its status:

| `code` | Status | Meaning |
| --- | --- | --- |
| `invalid_request` | `400` | The request is not valid: the body is not JSON, a field is missing, unknown or of the wrong type, the key asked for breaks a rule of [Access keys](https://docs.strg.com/access-keys) - no storage permission, a name over 64 characters, a permission that needs another or a different scope - `limit` is outside 1-100, or `Idempotency-Key` is malformed. `detail` says which |
| `invalid_cursor` | `400` | `cursor` is not one a listing returned |
| `unauthenticated` | `401` | [Authentication](https://docs.strg.com/rest-api/overview#authentication) covers every case |
| `payment_method_required` | `402` | Creating a key needs a verified card on your account, which a person adds on the **Billing** page - [Billing](https://docs.strg.com/billing#a-card-is-required) has the details |
| `exceeds_parent` | `403` | The new key would have more access than the calling key - [What the key reaches](https://docs.strg.com/rest-api/overview#what-the-key-reaches) explains the rule |
| `account_closed` | `403` | The account has been closed |
| `not_found` | `404` | No such endpoint, or no such access key: an unknown id, a key deleted already, or a key STRG manages for you |
| `method_not_allowed` | `405` | The endpoint does not answer this method. The `Allow` header lists those it does |
| `idempotent_key_revoked` | `409` | The access key an earlier request with this `Idempotency-Key` created has been deleted since - [Idempotency](https://docs.strg.com/rest-api/overview#idempotency) has the details |
| `payload_too_large` | `413` | The request body is larger than 64 KiB |
| `unsupported_media_type` | `415` | The request body was not sent as `application/json` |
| `unknown_bucket` | `422` | `bucketScope` names a bucket you do not have. A key is limited to buckets that exist |
| `idempotency_key_mismatch` | `422` | This `Idempotency-Key` was already used for a different request - [Idempotency](https://docs.strg.com/rest-api/overview#idempotency) has the details |
| `rate_limited` | `429` | Too many authentication attempts - [Rate limits](https://docs.strg.com/rest-api/overview#rate-limits) explains them |
| `internal_error` | `500` | Something went wrong on our side. A retry in a moment usually succeeds - with the same `Idempotency-Key` for a create |
| `buckets_unavailable` | `503` | Your buckets could not be checked just now, to confirm a `bucketScope`. A retry after the `Retry-After` header's seconds usually succeeds |

## Rate limits

Authentication is limited per access key and per client address, which keeps anyone from guessing
secrets at speed. Over either limit, a request is answered `429` with code `rate_limited` and a
`Retry-After` header: after that many seconds, requests are accepted again.

## Paging

`GET /v1/access-keys` answers one page at a time, **newest first**, steered by these values:

| | |
| --- | --- |
| `limit` | Query parameter: keys per page, 1 to 100. Default 100 |
| `cursor` | Query parameter: the previous page's `nextCursor`. Left out for the first page |
| `nextCursor` | In the answer: passed as `cursor`, it fetches the next page. `null` on the last page |

Paging is stable while keys are created or deleted: a key created or deleted between two requests
never makes another key appear twice or be skipped. A cursor is opaque - it works exactly as
returned, and is not something a client builds itself.

A complete listing walks the pages until `nextCursor` is `null`:

**curl**

This loop uses [`jq`](https://jqlang.org/) to read the answer.

```bash
cursor=""
while :; do
  page=$(curl -sS -G https://api.strg.com/v1/access-keys \
    -H "Authorization: Bearer $AWS_ACCESS_KEY_ID:$AWS_SECRET_ACCESS_KEY" \
    --data-urlencode "limit=100" \
    ${cursor:+--data-urlencode "cursor=$cursor"})
  echo "$page" | jq -r '.accessKeys[] | "\(.id)\t\(.label)"'
  cursor=$(echo "$page" | jq -r '.nextCursor // empty')
  [ -z "$cursor" ] && break
done
```

**Python (requests)**

```python
import os

import requests

key_id = os.environ["AWS_ACCESS_KEY_ID"]
secret = os.environ["AWS_SECRET_ACCESS_KEY"]
headers = {"Authorization": f"Bearer {key_id}:{secret}"}

params = {"limit": 100}
while True:
    response = requests.get("https://api.strg.com/v1/access-keys", params=params, headers=headers)
    response.raise_for_status()
    page = response.json()
    for key in page["accessKeys"]:
        print(key["id"], key["label"])
    if page["nextCursor"] is None:
        break
    params["cursor"] = page["nextCursor"]
```

**JavaScript (fetch)**

```js title="list-access-keys.mjs"
const headers = { Authorization: `Bearer ${process.env.AWS_ACCESS_KEY_ID}:${process.env.AWS_SECRET_ACCESS_KEY}` };

let cursor = null;
do {
  const url = new URL("https://api.strg.com/v1/access-keys");
  url.searchParams.set("limit", "100");
  if (cursor !== null) url.searchParams.set("cursor", cursor);
  const response = await fetch(url, { headers });
  if (!response.ok) throw new Error(`${response.status}: ${(await response.json()).detail}`);
  const page = await response.json();
  for (const key of page.accessKeys) console.log(key.id, key.label);
  cursor = page.nextCursor;
} while (cursor !== null);
```

## Idempotency

A create that ends without a clear answer - a timeout, a dropped connection, a `500` - may or may
not have made the key. An `Idempotency-Key` header takes the guesswork out: a retry carrying the
same value is sure to leave exactly one key, and hands it back.

```bash
idempotency_key=$(uuidgen)
curl -sS -i https://api.strg.com/v1/access-keys \
  -H "Authorization: Bearer $AWS_ACCESS_KEY_ID:$AWS_SECRET_ACCESS_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $idempotency_key" \
  -d '{"label": "ci-deploy", "permissions": ["objects:list", "objects:read", "objects:write"], "bucketScope": ["my-bucket"]}'
```

How a value is remembered, and what a second request with it gets:

| | |
| --- | --- |
| The value | 1 to 255 printable ASCII characters - a UUID works well. It can also be sent as a quoted string (`"…"`) |
| Window | **24 hours** from the first request. After that, the same value creates a new key |
| Bound to | The access key that sent it. The same value from another key is a different request |
| A replay | The same request within the window creates nothing and answers `201` with **the key the first request created, its secret included**, and `Idempotent-Replayed: true` |
| Two at once | A second request with the same value, sent while the first is still running, waits for it and answers the same key |
| A different body | `422 idempotency_key_mismatch`. A new request gets a new value |
| The key deleted since | `409 idempotent_key_revoked` - there is nothing left to hand back, and a new value creates another |
| An unsuccessful create | Not remembered. Once the cause is fixed - a card added, say - a retry with the same value creates the key |

Without the header, every create makes a new key.

## OpenAPI document

`https://api.strg.com/v1/openapi.json` describes every operation, parameter, body and error code as
OpenAPI 3.1. It needs no key, so a client generator or an API tool can read it directly.
