Guide · 15 min

Connect to the Data API

The Data API serves business entities as versioned REST endpoints under /api/data/v{version}/{route}, for example /api/data/v1/customers. An admin decides which entities are exposed, which fields each version speaks, and which roles may list, read, create, update or delete. An endpoint can also call a script module: it takes parameters and answers with what the module returns. This guide is for whoever writes the integration: how to connect, as a service with an API key or as a user who signs in, and how to call the endpoints.

Before you start

Ask the admin of the workspace for four things:

  1. The API address. The same host the app itself talks to. In the API Endpoints screen, the Try panel shows it in its curl line.
  2. The endpoint and version, e.g. customers version 1. Only a version with status Active or Deprecated answers; a Draft does not.
  3. A role granted on that endpoint for the operations you need, set in the endpoint's Who can call it grid. Admin and owner can always call everything.
  4. A way in holding that role: an API key for a service, or a user account for a person or a quick script.

For anything that runs unattended, such as a nightly sync, a webshop or a reporting tool, use an API key. It needs no password, no sign-in and no refreshing, and it opens the Data API and nothing else.

Connect a service with an API key

An admin creates an API client on the API Clients screen: a name, the roles it holds, and optionally a date it expires. Admin and owner cannot be given to a client, so it reaches only what its roles are granted on each endpoint. The key is shown exactly once, when the client is created; only a fingerprint of it is stored, so nobody can look it up later, not even an admin.

Send the key as a bearer token on every call:

API=https://api.example.com
KEY=ask_api_acme.q7Vt0r2Lk_dP9sXw3eNfH1mA6yZc4uBgJ8oTiR5vWnE

curl "$API/api/data/v1/customers" -H "Authorization: Bearer $KEY"
  • The key only works under /api/data. Anywhere else in the product it is refused, so a leaked key cannot reach the app's other data or its settings.
  • It stays valid until it expires, the client is disabled or deleted, or an admin rotates it for a new one. Each of those takes effect on the next call, which then answers 401.
  • Changes the service makes carry the client's name as their author, and business events see it as the user.
  • The key names its workspace (ask_api_{workspace}.{secret}), so it needs no workspace choice. Keep it in a secret store, never in the code.

Carry on with Call an endpoint.

Connect as a user: sign in

Use a dedicated account, not a person's own. A signed-in token can do everything that user can do in the app, not only call the Data API, so give the account the granted role and nothing else, and keep its password in a secret store. For a service, an API key is the better choice.

Post the account's email and password to /api/v1/auth/login:

API=https://api.example.com

curl -X POST "$API/api/v1/auth/login" \
  -H "Content-Type: application/json" \
  -d '{"email": "webshop-sync@example.com", "password": "..."}'

The answer holds the tokens:

{
  "token": "eyJhbGciOiJSUzI1NiIsImtpZCI6...",
  "refreshToken": "q8Zr0dW3...",
  "idToken": "eyJhbGciOiJSUzI1NiIsImtpZCI6...",
  "expiresIn": 900,
  "redirectHost": null
}
  • token is the access token. Send it on every call. It expires after expiresIn seconds, 15 minutes by default.
  • refreshToken gets you a new pair without the password (see Keep a user session alive). It lasts 30 days by default and works exactly once.
  • idToken and redirectHost are for the browser app; ignore them.

A refused sign-in answers 401 with a code: invalid_credentials, disabled (the account is switched off) or no_tenant (the account belongs to no active workspace). Too many failed attempts answer 429 with locked_out. Sign-in is also limited to 20 attempts a minute per IP address, so sign in once and keep the session, rather than signing in for every call.

Call an endpoint

Put the API key or the access token in the Authorization header; below, $TOKEN stands for either. A list call answers one page of items and the total:

curl "$API/api/data/v1/customers?pageSize=2&sort=name" \
  -H "Authorization: Bearer $TOKEN"
{
  "items": [
    { "id": 1, "name": "Acme", "email": "info@acme.test", "creditLimit": 500 },
    { "id": 2, "name": "Globex", "email": "hello@globex.test", "creditLimit": 50 }
  ],
  "page": 1,
  "pageSize": 2,
  "totalCount": 3
}

The field names are the ones the admin gave this version, which need not match the columns in the database. Reading one item, creating, changing and deleting look like this:

# One item
curl "$API/api/data/v1/customers/1" -H "Authorization: Bearer $TOKEN"

# Create: answers 201 with the stored item and a Location header
curl -X POST "$API/api/data/v1/customers" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "Hooli", "email": "hi@hooli.test"}'

# Change some fields
curl -X PATCH "$API/api/data/v1/customers/4" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"email": "sales@hooli.test"}'

# Delete: answers 204
curl -X DELETE "$API/api/data/v1/customers/4" -H "Authorization: Bearer $TOKEN"
  • PATCH changes only the fields in the body. PUT does the same but refuses a body that leaves out a required field.
  • The id always comes from the URL. An id in a create body is ignored, so a create never overwrites an existing item.
  • Read-only fields in a body are ignored, so you can send back an item exactly as you fetched it. A field the version does not know is refused with 400, which catches typos.
  • Writes behave exactly as saves in the app: business events fire, validation runs, and open screens refresh.

Call a script module

Some endpoints run a script module instead of serving an entity's rows, for work that is more than a read or a write: pricing an order, booking a slot, a report. Each version of such an endpoint answers one method on its path, and the OpenAPI document says which: POST takes the parameters as a JSON object in the body, GET takes them in the query string.

# A version called with POST: the parameters as a JSON object
curl -X POST "$API/api/data/v1/quote" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"quantity": 3, "express": true}'

# A version called with GET: the parameters in the query string
curl "$API/api/data/v1/planner-week?weekStart=2026-09-21" \
  -H "Authorization: Bearer $TOKEN"

The answer is what the module returns, as JSON:

{
  "quantity": 3,
  "total": 172.5,
  "deliveryDate": "2026-09-28"
}
  • The parameter names are the ones the admin gave the version. Each value is checked against its type before the module runs: whole number, number, true or false, a date with or without a time (2026-09-25, 2026-09-25T14:30:00Z), or text. A value of the wrong type, a parameter the version does not know and a missing required one are a 400; a null counts as left out.
  • A module that returns nothing answers 204 No Content. One that fails answers 500, with the module's own message in detail.
  • Calling needs a role that was granted Call on the endpoint. A module the admin marked admin only answers admins and owners alone, so an API key never reaches it.
  • The module runs for real, exactly as when the app runs it: what it writes fires business events, and mail it sends goes out.
  • Calling it with the other method, or on an item path such as /quote/1, is a 405 whose Allow header names the method to use.

Keep a user session alive

This applies to a signed-in user only; an API key needs none of it. Before the access token expires, or when a call answers 401, trade the refresh token for a new pair:

curl -X POST "$API/api/v1/auth/refresh" \
  -H "Content-Type: application/json" \
  -d '{"refreshToken": "q8Zr0dW3..."}'

The answer has the same shape as the sign-in, with a new refresh token. Store it and throw the old one away: a refresh token works once. Presenting a spent one again after a minute is treated as theft and ends the whole session, after which you have to sign in again. So give every process or worker its own session, never share one refresh token between them.

A refresh answers 401 when the session has ended: it expired, the account was disabled or removed from the workspace, or it was signed out. Sign in again when that happens. Role changes reach the token at the next refresh, so a revoked role stops working within 15 minutes. To end a session yourself:

curl -X POST "$API/api/v1/auth/logout" \
  -H "Content-Type: application/json" \
  -d '{"refreshToken": "q8Zr0dW3..."}'

An account in more than one workspace

Sign-in picks one workspace for the session: the one the address you call belongs to if the account is a member there, otherwise the one it used last. That is why a dedicated account should belong to one workspace only. If it belongs to several, list them and switch with the tenantId of the one you want:

# The workspaces this account belongs to
curl "$API/api/v1/auth/tenants" -H "Authorization: Bearer $TOKEN"

# A new token pair for one of them
curl -X POST "$API/api/v1/auth/switch-tenant" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"tenantId": "3f1c2a9e-..."}'

The switch answers a new token pair for that workspace; carry on with it as after a sign-in.

Filtering, sorting and paging

# Customers whose name contains "acme", highest credit limit first
curl -G "$API/api/data/v1/customers" \
  -H "Authorization: Bearer $TOKEN" \
  --data-urlencode "filter[name][like]=acme" \
  --data-urlencode "filter[creditLimit][gte]=100" \
  --data-urlencode "sort=-creditLimit" \
  --data-urlencode "page=2" \
  --data-urlencode "pageSize=50"
ParameterMeaning
pagePage number, starting at 1.
pageSizeItems per page, 50 by default. Each version sets a maximum (100 unless the admin changed it); asking for more is a 400.
sortOne field, with - in front for descending. Without it, newest first.
searchText matched against every field the version returns.
filter[field]Equal to the value.
filter[field][op]Compared with an operator, see below. Several filters all have to match.
OperatorMatches
eq, neEqual, not equal.
gt, gte, lt, lteGreater or less than. Numbers, dates and text.
like, startswith, endswithText containing, starting or ending with the value, ignoring case. Text fields only.
inAny of a comma separated list: filter[id][in]=1,2,3.
nulltrue for empty values, false for filled ones.

Values are checked against the field's type, so filter[creditLimit]=lots is a 400 naming the field. Only fields the version returns can be filtered, sorted or searched on, and an unknown parameter is refused rather than ignored, so a typo never quietly returns everything. Parameters starting with _ are ignored, for cache busting.

Versions

The version is part of the path, and several versions of one endpoint can be live side by side. A version that is published never changes in a way that breaks you: fields are never removed or renamed, and nothing becomes required. A script module endpoint's version keeps its method and its parameters the same way, and only gains optional ones. Changes like that come as a new version.

  • Active and Deprecated versions answer.
  • A version that does not exist, or is still a Draft, answers 404 and lists the versions that do.
  • A Retired version answers 410 Gone and names the one to move to.

Every answer says which versions exist. A deprecated one says so too, and when it will go:

HTTP/1.1 200 OK
api-supported-versions: 1, 2
api-deprecated-versions: 1
Deprecation: @1788220800
Sunset: Thu, 31 Dec 2026 00:00:00 GMT
Link: <https://api.example.com/api/data/v2/customers>; rel="successor-version"

Deprecation is the moment it was deprecated (a Unix time), Sunset the date it stops answering, if the admin set one, and Link points at the next version. Logging a warning whenever Deprecation appears is the easiest way to hear about it in time.

Errors

A refusal from the Data API is a standard problem document (RFC 7807) whose detail says what was wrong:

HTTP/1.1 403 Forbidden
Content-Type: application/problem+json

{
  "title": "Forbidden",
  "status": 403,
  "detail": "None of your roles may Delete on 'customers'."
}

One exception: when a business event's validation refuses a write, the 422 body is { "message": "...", "errorColumns": [...] }, naming the fields at fault in the database's own column names.

StatusMeans
400The request is malformed: an unknown field or parameter, a value of the wrong type, a missing required field.
401No token or key, or it is no longer valid. A user refreshes and retries; an API key has expired, been rotated, or its client was disabled or deleted.
403None of the account's roles may do this on this endpoint.
404No such endpoint, version or item.
405This version does not offer the operation, e.g. delete, or a script module endpoint was called with the other method.
409The data conflicts: a unique value already exists, or other records still refer to this one.
410The version has been retired.
422A business rule or a table rule refused the values.
429Too many requests. Wait a little and retry.
500A script module endpoint's module failed; detail carries its message.

OpenAPI description

GET /api/data/openapi.json, called with your token, answers an OpenAPI 3 document of exactly the versions and operations your account may use, with every field and its type. Import it into Postman or Insomnia, or generate a typed client from it. Fetch it again after the admin publishes a new version.

Examples in Python

With an API key, all it takes is the header:

import os
import requests

API = os.environ["ARCHESTACK_API"]
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['ARCHESTACK_API_KEY']}"

r = session.get(f"{API}/api/data/v1/customers", params={"pageSize": 100}, timeout=30)
r.raise_for_status()
for customer in r.json()["items"]:
    print(customer["id"], customer["name"])

Signed in as a user, the session needs looking after. This one signs in, refreshes a minute before the token expires, recovers from a 401, reads every page, and creates an item. Put the address and the credentials in environment variables.

import os
import time
import requests

API = os.environ["ARCHESTACK_API"]            # e.g. https://api.example.com
EMAIL = os.environ["ARCHESTACK_EMAIL"]
PASSWORD = os.environ["ARCHESTACK_PASSWORD"]


class DataApi:
    """One signed-in session. Give every process or worker its own."""

    def __init__(self):
        self.token = None
        self.refresh_token = None
        self.expires_at = 0.0

    def _keep(self, body):
        self.token = body["token"]
        self.refresh_token = body["refreshToken"]  # always replace it: each one works once
        self.expires_at = time.time() + body["expiresIn"] - 60

    def sign_in(self):
        r = requests.post(f"{API}/api/v1/auth/login",
                          json={"email": EMAIL, "password": PASSWORD}, timeout=30)
        r.raise_for_status()
        self._keep(r.json())

    def refresh(self):
        r = requests.post(f"{API}/api/v1/auth/refresh",
                          json={"refreshToken": self.refresh_token}, timeout=30)
        if r.status_code == 401:  # the session ended: start a new one
            return self.sign_in()
        r.raise_for_status()
        self._keep(r.json())

    def call(self, method, path, **kwargs):
        if self.token is None:
            self.sign_in()
        elif time.time() >= self.expires_at:
            self.refresh()
        r = self._send(method, path, **kwargs)
        if r.status_code == 401:  # expired early or revoked: refresh once and retry
            self.refresh()
            r = self._send(method, path, **kwargs)
        return r

    def _send(self, method, path, **kwargs):
        headers = {"Authorization": f"Bearer {self.token}"}
        return requests.request(method, f"{API}/api/data{path}",
                                headers=headers, timeout=30, **kwargs)


api = DataApi()

# Every page of customers
page = 1
while True:
    r = api.call("GET", "/v1/customers", params={"page": page, "pageSize": 100})
    r.raise_for_status()
    body = r.json()
    for customer in body["items"]:
        print(customer["id"], customer["name"])
    if page * body["pageSize"] >= body["totalCount"]:
        break
    page += 1

# Create one
r = api.call("POST", "/v1/customers", json={"name": "Hooli", "email": "hi@hooli.test"})
if r.status_code == 201:
    print("created", r.json()["id"])
else:
    print("refused:", r.json().get("detail"))
Trial environments: only the trial's owner account, and API clients the owner creates, can call the API there.

Where to go next

  • Business Entities - what an endpoint reads from, and why an entity that joins a one-to-many table returns one item per joined row.
  • User Management - create the integration account and its role.