Conventions
Behavior that's the same everywhere, so the resource pages don't have to repeat it.
Pagination
List endpoints return a consistent envelope and page with an opaque cursor:
{
"object": "list",
"data": [ /* … */ ],
"next_cursor": "c2VlZC0x",
"has_more": true
}
Pass ?limit= and ?cursor=:
| Param | Default | Range | Notes |
|---|---|---|---|
limit | 25 | 1–100 | Values outside the range are clamped, not rejected — limit=5000 gives 100, limit=0 or limit=abc gives 25. |
cursor | — | — | Opaque. Pass back the previous response's next_cursor verbatim. |
# first page
curl "https://app.aglyn.com/api/v1/datasets/ds_1/records?limit=50" \
-H "Authorization: Bearer aglyn_sk_…"
# next page — pass the previous response's next_cursor
curl "https://app.aglyn.com/api/v1/datasets/ds_1/records?limit=50&cursor=c2VlZC0x" \
-H "Authorization: Bearer aglyn_sk_…"
When has_more is false, next_cursor is null and you've reached the end.
Cursors are opaque — don't construct or parse them. A cursor we can't decode isn't an
error either: you'll get an empty or arbitrary page rather than a 400, so check
has_more rather than assuming a non-empty page means you're paging correctly.
A page can be shorter than limit
data.length < limit never means you've reached the end. Only
has_more === false means that.
Some endpoints filter rows out after reading a page. A page of 100 can therefore come
back with 60 rows — or with none — and has_more: true. The full list:
| Endpoint | What is dropped after the read |
|---|---|
| Products, media | Rows deleted since they were written. |
Orders ?channel=online | online is the default rather than a stored value, so older orders carry no channel field to match. |
Contacts ?email= with ?tag= | email narrows the query; tag is checked on the result. |
Form submissions ?form= with ?read= | form narrows the query; read is checked on the result. |
The last two share one cause, and it is worth knowing because it predicts the next one: only one filter can narrow the query itself. Every list here is ordered by record id, so a second filter would need a composite index built specifically for that pair. Used alone, each of those filters narrows the query directly and pages come back full.
The loop that gets this right is the one that stops on the cursor, not on the count:
let cursor = null
do {
const page = await fetchPage(cursor)
handle(page.data) // may be short, may even be empty
cursor = page.next_cursor // the ONLY termination signal
} while (cursor)
A loop written as while (page.data.length === limit) will silently stop early on
these endpoints, and it will look like it worked.
Filters
Filters are per-resource — each resource page lists the params it accepts — but they behave the same way everywhere:
- An unknown param is ignored, not rejected.
?colour=redon any list is the unfiltered list. Check the resource page rather than assuming a filter took effect. - An empty value means the filter is absent.
?email=,?tag=and?read=all give the unfiltered list, so a client that serializes an unset field doesn't have to strip it from the query string first. - A malformed value for a param that IS supported is a
400, withcode: "validation_failed"naming the param — an unusable?email=on contacts, a?read=that is neithertruenorfalseon form submissions. These refuse rather than guess, because a guess returns a plausible page and a plausible page is the one you don't check. - A
400reads no data. The request still spends its rate-limit budget and still counts as a billed request — every authenticated call does — but the collection is never queried, so retrying with a corrected param costs no more than any other call. - Values are normalized the same way the write path normalizes them. A filter that matched raw input could disagree with a write about whether a record exists.
- Filters never change ordering or the cursor contract. A filtered list is still ordered by record id, and pages the same way.
Ordering
Every list is ordered by record id, ascending — not by creation or update time.
This is the single most surprising thing about the API, so plan for it:
- Paging start-to-finish gives you every item exactly once. That's what cursors guarantee, and it's what a full sync needs.
- Page 1 is not "the 25 newest". There is no
sortororderparam, and no filter oncreated/updated. - To find recent changes, page everything and compare
updatedyourself. To keep a copy in sync, store the ids you've seen.
Errors
Errors use a consistent envelope and standard HTTP status codes:
{ "error": { "type": "not_found", "message": "No such dataset" } }
type is the stable, machine-readable field — branch on it, not on message. Some
errors add a code with the specific detail.
| Status | type | When |
|---|---|---|
400 | bad_request | Failed validation (code: "validation_failed"). |
401 | unauthorized | Missing, malformed, revoked, or expired key. |
403 | plan_required | The organization's plan doesn't include what the call needs. code says which thing — see below. |
403 | insufficient_scope | The key lacks the required scope (code is the scope). |
404 | not_found | No such resource — or no such endpoint. |
405 | method_not_allowed | Method not supported on that path; the Allow header lists what is. |
409 | conflict | The request conflicts with current state. code: "idempotency_in_progress" — an earlier request with the same Idempotency-Key is still running. code: "dataset_not_empty" — the dataset you asked us to delete still holds records. code: "contact_exists" — that email is already a contact, and the message names its id. code: "order_transition" — the order cannot move to the status you asked for from the one it is in, and the message names that status. |
429 | rate_limited | Rate limit exceeded. |
500 | internal_error | Something went wrong on our side. Safe to retry. |
Validation errors
A validation_failed response names the fields that failed, so you don't have to
bisect a payload:
{
"error": {
"type": "bad_request",
"message": "Record failed validation",
"code": "validation_failed",
"fields": {
"email": "Required",
"headcount": "Must be a whole number"
}
}
}
fields is present only on validation failures — treat it as optional.
Two different plan_required failures
plan_required answers several distinct questions, and code is what tells them
apart:
code | Means | What fixes it |
|---|---|---|
| absent | The organization's plan doesn't include API access at all. Every endpoint answers this. | Move to a plan with the API. |
"commerce" | The API works, but the plan no longer includes commerce, so orders and products are closed. Every other resource keeps working. | Restore a plan with commerce. |
"data_store" | The API works, but the plan doesn't include datasets, so creating one is closed. | Move to a plan with the data store. |
"dataset_quota" | Datasets are included and every included slot is used. The message names the limit, and the add-on price when extra datasets are purchasable on this plan. | Buy extra datasets, or upgrade. |
"record_quota" | The dataset holds every record the plan includes. The message names the limit. | Upgrade. |
"data_storage_quota" | Dataset storage is exhausted, or the plan includes none. The message names the included size. | Upgrade. |
"contact_quota" | The audience band is full on a plan that doesn't meter contact overage. See contacts — on plans that do meter it, this never happens and the extra contacts bill instead. | Upgrade. |
Branch on code, not on the message. An integration that retries a plan failure
forever is the failure mode here — none of them is transient, so back off and alert
a human instead. The quota codes are the ones a human can clear in minutes, which is
why they are the ones that do not consume an
Idempotency-Key.
We answer plan_required rather than 404 for the commerce case on purpose. Hiding a
store that plainly exists behind a "no such thing" sends an integrator hunting a wrong
site id for an hour; naming the plan is the answer they can act on.
Idempotency
Ten operations accept an Idempotency-Key header:
| Operation | Key scoped to |
|---|---|
POST /v1/sites | the organization |
POST /v1/datasets | the organization |
DELETE /v1/datasets/{datasetId} | that dataset |
POST /v1/datasets/{datasetId}/records | that dataset |
DELETE /v1/datasets/{datasetId}/records/{recordId} | that dataset |
DELETE /v1/sites/{siteId}/form-submissions/{submissionId} | that site |
POST /v1/contacts | the organization |
DELETE /v1/contacts/{contactId} | the organization |
POST /v1/media | the organization |
POST /v1/sites/{siteId}/media | that site |
Five rows are organization-scoped. POST /v1/sites and POST /v1/datasets are
because neither has an object to scope to yet — the site or dataset they create is
the object. Both contact operations are because
contacts are organization-wide: one list is shared by every site, so there is no
narrower object to scope a key to. POST /v1/media is the same case — it writes the
organization library, which every site shares; its site-scoped twin
POST /v1/sites/{siteId}/media writes one site's own library and scopes to that site.
Every other row is scoped to the object named in its own path — including
DELETE /v1/datasets/{datasetId}, where the dataset being removed is still the scope.
Send the same key to retry safely — if the original succeeded, the same response
comes back instead of a duplicate or a 404:
curl -X POST https://app.aglyn.com/api/v1/datasets/ds_1/records \
-H "Authorization: Bearer aglyn_sk_…" \
-H "Idempotency-Key: 2b9f1c4e-…" \
-H "Content-Type: application/json" \
-d '{"values":{"name":"Avery"}}'
- A fresh create returns
201; a replay of a key we've already seen returns200with the original record. Use the status to tell them apart. - Keys are scoped to the object in the table above and to the operation, and are remembered for 30 days. Use a UUID per logical operation, and don't reuse one key across datasets: the second dataset treats it as a separate operation and creates its own record. Reusing one key for a create and a delete is likewise two separate operations, so a delete never replays a create's record — and that holds for the dataset operations too, so one key used to create and then delete a dataset does both, rather than replaying the create's body as a delete receipt.
- After 30 days a key is forgotten, and re-sending it is a new operation that creates a new record. This window is far longer than any retry — Stripe's equivalent is 24 hours — and it exists because a stored replay holds a copy of the record it created. Keeping that copy forever would mean a record you deleted lived on in our replay store indefinitely, which is not something an idempotency key should buy you. If you need a durable "only ever one of these" rule, that is a uniqueness constraint in your own data, not an idempotency key.
- The replay is the original response, replayed verbatim. Within the window it keeps working after the record has been edited or deleted — a retry never re-creates a record you have since removed.
- If a request with the same key is still in flight, the second one is refused
with a
409 conflict(code: "idempotency_in_progress") rather than served. That refusal is deliberate: letting it through is exactly the duplicate the key exists to prevent. Retry once the first request has answered. - A request that fails releases its key, so you can fix the cause and retry with
the same one. A
400never consumes a key at all, and neither does a refusal that a customer can clear: a403 plan_requiredonPOST /v1/datasetsorPOST /v1/contactsgoes away when someone upgrades, a409 dataset_not_emptygoes away when the records are deleted, and a409 contact_existsgoes away when the duplicate is removed. A key burned on any of them would mean the retry that should finally succeed replays the refusal forever. - The mirror of that rule matters just as much: a create that succeeds is remembered, so a retry replays it even when that create consumed the last slot in a plan's band. This holds on every create that takes a key — datasets, records, and contacts — and it is what makes a bulk import safe to resume. Without it, the retry after a lost response would be refused by the quota it had itself just filled, and you would have no way to tell whether the object exists.
Deletes
DELETE changes the same state twice over, but it doesn't answer the same way twice.
Without a key, deleting a record that's already gone returns 404 not_found — which
is correct for a wrong id and misleading for a retry, because you can't tell the two
apart. Send a key and the retry replays the original receipt instead:
curl -X DELETE https://app.aglyn.com/api/v1/datasets/ds_1/records/k3f9a1c7be \
-H "Authorization: Bearer aglyn_sk_…" \
-H "Idempotency-Key: 7c1e0a92-…"
{ "id": "k3f9a1c7be", "object": "record", "deleted": true }
- The first call deletes and returns
200. A retry with the same key returns the same200body, whether or not the record still exists — so a response lost to a timeout is safe to re-send. - A record that was never there still returns
404 not_found, even with a key. That's deliberate: a204-for-everything would hand you a success for a typo'd id and take away the only signal that you're asking about the wrong record. - A
404releases the key, so you can correct the id and retry with the same one.
DELETE /v1/sites/{siteId}/form-submissions/{submissionId} behaves the same way,
scoped to the site rather than to a dataset. It is the delete most likely to run on
a timer — a purge after a nightly export — which is exactly the case where a lost
response has to be distinguishable from a wrong id.
DELETE /v1/contacts/{contactId} behaves the same way, scoped to the organization.
Send a key on it as a matter of course: contact deletions are usually erasure requests
running from a script, and "already erased" and "wrong id" prescribe very different
next steps.
PATCH doesn't take the header and doesn't need it. It merges the supplied values
over the stored ones, so the same body twice lands the same state and returns the
same 200 record — idempotent in the response as well as the state. It answers 404
for a missing record, and that stays the right answer.