Integrations
API authentication
Use an API token or sign in, send a bearer token, and read the one error format, the rate limits and the pagination.
The AnswerStack API is the same API the admin app uses. Anything a person can do in the app, your systems can do too — with the same roles and the same limits.
This page is for a developer. Everything below has been checked against a running API.
For automation, use an API token#
For a script, a scheduled job or a partner's system, an API token is now the recommended way in. You make it in the app's Developer area (Personal settings → Developer), give it only the scopes the job needs, and send it as a bearer token. There is no password to store and no sign-in to refresh, and revoking it takes effect at once. Tokens work on the routes that list a scope; see API tokens and service identities.
Signing in as an AnswerStack user, below, still works on every route, and is what you need for a route that does not accept tokens.
Base URL and versioning#
https://api.<your-domain>/v1/...Versioning is in the path. There is no version header. Your account manager gives you the exact host.
Health checks (/healthz, /readyz) are the only unversioned routes and are not part of the customer
API.
Sign in#
AnswerStack uses Supabase Auth. You exchange an AnswerStack user's email and password for a short-lived access token, then send that token to the API.
Warning
Use a dedicated AnswerStack user for automation, with the smallest role that does the job. A token carries that user's role and community scope exactly as the app would.
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_PUBLISHABLE_KEY);
const { data, error } = await supabase.auth.signInWithPassword({
email: process.env.ANSWERSTACK_EMAIL,
password: process.env.ANSWERSTACK_PASSWORD,
});
if (error) throw error;
const accessToken = data.session.access_token;ACCESS_TOKEN=$(curl -sS \
-X POST "$SUPABASE_URL/auth/v1/token?grant_type=password" \
-H "apikey: $SUPABASE_PUBLISHABLE_KEY" \
-H "content-type: application/json" \
-d "{\"email\":\"$ANSWERSTACK_EMAIL\",\"password\":\"$ANSWERSTACK_PASSWORD\"}" \
| jq -r .access_token)SUPABASE_URL and SUPABASE_PUBLISHABLE_KEY are the two values your account manager gives you
alongside the API host. The publishable key is not a secret and is safe in a browser; the user's
password is, so keep it in your secret store.
Access tokens last about an hour. The Supabase client refreshes them for you; if you are using curl,
sign in again when a call returns 401.
Send the token#
curl -sS "$ANSWERSTACK_API_URL/v1/me" \
-H "Authorization: Bearer $ACCESS_TOKEN"{
"user": { "id": "8d873670-…", "email": "[email protected]" },
"account": {
"id": "0b9c…",
"name": "Sunrise Senior Living",
"slug": "sunrise",
"status": "active",
"vocabulary": {
"group": "community",
"groupPlural": "communities",
"meeting": "tour",
"meetingPlural": "tours",
"contact": "prospect",
"contactPlural": "prospects"
}
},
"role": "owner",
"memberStatus": "active",
"groups": [{ "id": "…", "name": "Maple Grove", "status": "active" }],
"permissions": ["members.manage", "playbooks.publish", "…"]
}GET /v1/me is the right first call: it tells you the account, the role, which communities the user
can see, and exactly which permissions they hold.
Info
The only public endpoint is POST /v1/public/demo-requests, which takes no token. Everything else
needs one.
Roles and permissions#
The API checks the caller's role on every route, and the database checks it again underneath, so a token can never read another account's data.
| Permission | Owner | Admin | Editor | Group manager | Viewer |
|---|---|---|---|---|---|
members.manage | Yes | ||||
billing.manage | Yes | ||||
playbooks.publish | Yes | Yes | |||
playbooks.locks | Yes | Yes | |||
playbooks.edit | Yes | Yes | Yes | ||
groups.manage | Yes | Yes | |||
groups.customize | Yes | Yes | Yes | ||
knowledge.manage | Yes | Yes | |||
connections.manage | Yes | Yes | |||
numbers.assign | Yes | Yes | |||
testcall.run | Yes | Yes | Yes | ||
usage.read | Yes | Yes | Yes | ||
calls.read | Yes | Yes | Yes | Yes | Yes |
analytics.read | Yes | Yes | Yes | Yes | Yes |
account.read | Yes | Yes | Yes | Yes | Yes |
A group manager's token is scoped to named communities. Anything outside that scope answers 404,
not 403, so a token cannot be used to discover which communities exist.
One error format#
Every error — validation, permissions, rate limits, server problems — has the same shape.
{
"error": {
"code": "not_found",
"message": "We could not find that call.",
"requestId": "1c98f870-1216-4d1c-ac0d-22d927b04b9c"
}
}| Field | Notes |
|---|---|
code | A stable machine-readable string. Branch on this, never on message. |
message | One sentence, safe to show a person. |
details | Optional, and its shape depends on the code. |
requestId | Also returned as the x-request-id header on every response. Quote it when you contact support. |
Status codes#
| Status | Common codes | Meaning |
|---|---|---|
400 | validation_error | The request body or query is wrong. details.fields lists each problem. |
401 | unauthorized, token_stale | The token is missing, invalid, expired, or the user's access changed. Sign in again. |
403 | forbidden, locked | The user's role does not allow this. |
403 | insufficient_scope | An API token lacks a scope this route needs. The message names it. |
404 | not_found | It does not exist, or is outside this user's community scope. |
409 | conflict | Something changed under you, for example a draft edited elsewhere. Reload and retry. |
422 | publish_blocked, connection_failed | The request was understood but refused. details says why. |
429 | rate_limited | Too many requests. See below. |
5xx | internal_error, bad_gateway | Our problem. Retry with backoff and quote the requestId. |
A validation error in full:
{
"error": {
"code": "validation_error",
"message": "Check body.name: too small: expected string to have >=1 characters.",
"details": {
"fields": [
{ "path": "body.name", "message": "Too small: expected string to have >=1 characters" },
{ "path": "body.email", "message": "Invalid email address" }
]
},
"requestId": "0f00b153-1449-401e-8528-25187d5b9d78"
}
}Rate limits#
| Scope | Limit |
|---|---|
| Everything, per IP address | 600 requests per minute |
POST /v1/public/demo-requests, per IP address | 5 requests per 10 minutes |
| Each API token | 60 requests per minute |
Every response carries the current state:
x-ratelimit-limit: 600
x-ratelimit-remaining: 597
x-ratelimit-reset: 48
x-request-id: 78fd7845-6d4f-42f5-a669-2e725b5e7487A 429 adds retry-after in seconds and says how long to wait:
{
"error": {
"code": "rate_limited",
"message": "Too many requests. Try again in 42 seconds.",
"requestId": "…"
}
}Honour retry-after. Backing off on x-ratelimit-remaining before you hit zero is kinder still.
Pagination#
Two endpoints paginate, with an opaque cursor: GET /v1/account/calls and
GET /v1/account/playbooks/{playbookId}/changes.
| Parameter | Calls | Changes |
|---|---|---|
limit | default 50, maximum 200 | default 50, maximum 100 |
cursor | The nextCursor from the previous page | Same |
{
"calls": [ … ],
"nextCursor": "eyJ0IjoiMjAyNi0wOS0yMlQxNjowMDowMC4wMDBa…"
}nextCursor is null on the last page. Treat the value as opaque: do not parse it or build one.
Every other list endpoint returns the whole set under a named key — { "groups": [...] },
{ "members": [...] }, { "numbers": [...] }, { "invoices": [...] } — with no cursor.
A worked example: last week's booked calls#
#!/usr/bin/env bash
set -euo pipefail
export ANSWERSTACK_API_URL="https://api.<your-domain>"
ACCESS_TOKEN=$(curl -sS \
-X POST "$SUPABASE_URL/auth/v1/token?grant_type=password" \
-H "apikey: $SUPABASE_PUBLISHABLE_KEY" \
-H "content-type: application/json" \
-d "{\"email\":\"$ANSWERSTACK_EMAIL\",\"password\":\"$ANSWERSTACK_PASSWORD\"}" \
| jq -r .access_token)
FROM=$(date -u -v-7d +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -d '7 days ago' +%Y-%m-%dT%H:%M:%SZ)
CURSOR=""
while :; do
RESPONSE=$(curl -sS -G "$ANSWERSTACK_API_URL/v1/account/calls" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
--data-urlencode "outcome=booked" \
--data-urlencode "from=$FROM" \
--data-urlencode "limit=200" \
${CURSOR:+--data-urlencode "cursor=$CURSOR"})
echo "$RESPONSE" | jq -r '.calls[] | [.startedAt, .groupName, .ruleFired] | @tsv'
CURSOR=$(echo "$RESPONSE" | jq -r '.nextCursor // empty')
[ -z "$CURSOR" ] && break
doneimport { createClient } from '@supabase/supabase-js';
const API = process.env.ANSWERSTACK_API_URL; // https://api.<your-domain>
const supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_PUBLISHABLE_KEY);
const { data, error } = await supabase.auth.signInWithPassword({
email: process.env.ANSWERSTACK_EMAIL,
password: process.env.ANSWERSTACK_PASSWORD,
});
if (error) throw error;
const token = data.session.access_token;
async function get(path, params = {}) {
const url = new URL(`${API}${path}`);
for (const [k, v] of Object.entries(params)) if (v != null) url.searchParams.set(k, String(v));
const res = await fetch(url, { headers: { authorization: `Bearer ${token}` } });
if (res.status === 429) {
const wait = Number(res.headers.get('retry-after') ?? 5);
await new Promise((r) => setTimeout(r, wait * 1000));
return get(path, params);
}
if (!res.ok) {
const { error } = await res.json();
throw new Error(`${error.code}: ${error.message} (request ${error.requestId})`);
}
return res.json();
}
const from = new Date(Date.now() - 7 * 864e5).toISOString();
let cursor;
const booked = [];
do {
const page = await get('/v1/account/calls', { outcome: 'booked', from, limit: 200, cursor });
booked.push(...page.calls);
cursor = page.nextCursor ?? undefined;
} while (cursor);
console.log(`${booked.length} tours booked in the last seven days`);
for (const call of booked) {
console.log(`${call.startedAt} ${call.groupName} rule: ${call.ruleFired ?? '—'}`);
}Good manners#
- Send
Authorizationandcontent-typeonly. Those, plusx-request-id, are the headers the API accepts from a browser. - Log
x-request-idfor every failed call. It is the fastest way for us to find what happened. - Retry
429and5xxwith backoff. Never retry400,403,404or422: the answer will not change. - Poll politely. For call volume, once a minute is plenty; the cursor makes catching up cheap.
Every endpoint#
The API reference is generated from the API itself, so it is never out of date.
It covers /v1/me, everything under /v1/account, and the public demo-request endpoint, with the
parameters, the request and response fields and a ready-to-run curl example for each one.