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'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 |
| Get an access key | GET /v1/access-keys/{id} | Get an access key |
| Create an access key | POST /v1/access-keys | Create an access key |
| Delete an access key | DELETE /v1/access-keys/{id} | Delete an access key |
| The OpenAPI document | GET /v1/openapi.json | 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 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 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:
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 explains them all.
A first request, listing one key, looks like this:
- curl
- Python (requests)
- JavaScript (fetch)
curl -sS "https://api.strg.com/v1/access-keys?limit=1" \
-H "Authorization: Bearer $AWS_ACCESS_KEY_ID:$AWS_SECRET_ACCESS_KEY"
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())
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 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 |
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 explains why.
Errors
Every error is an RFC 9457 problem, sent as
application/problem+json, so a client reads every failure the same way:
{
"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 - 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 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 has the details |
exceeds_parent | 403 | The new key would have more access than the calling key - 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 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 has the details |
rate_limited | 429 | Too many authentication attempts - 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
- Python (requests)
- JavaScript (fetch)
This loop uses jq to read the answer.
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
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"]
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.
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.