Skip to main content

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=:

ParamDefaultRangeNotes
limit251100Values outside the range are clamped, not rejectedlimit=5000 gives 100, limit=0 or limit=abc gives 25.
cursorOpaque. 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:

EndpointWhat is dropped after the read
Products, mediaRows deleted since they were written.
Orders ?channel=onlineonline 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=red on 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, with code: "validation_failed" naming the param — an unusable ?email= on contacts, a ?read= that is neither true nor false on 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 400 reads 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 sort or order param, and no filter on created/updated.
  • To find recent changes, page everything and compare updated yourself. 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.

StatustypeWhen
400bad_requestFailed validation (code: "validation_failed").
401unauthorizedMissing, malformed, revoked, or expired key.
403plan_requiredThe organization's plan doesn't include what the call needs. code says which thing — see below.
403insufficient_scopeThe key lacks the required scope (code is the scope).
404not_foundNo such resource — or no such endpoint.
405method_not_allowedMethod not supported on that path; the Allow header lists what is.
409conflictThe 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.
429rate_limitedRate limit exceeded.
500internal_errorSomething 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:

codeMeansWhat fixes it
absentThe 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:

OperationKey scoped to
POST /v1/sitesthe organization
POST /v1/datasetsthe organization
DELETE /v1/datasets/{datasetId}that dataset
POST /v1/datasets/{datasetId}/recordsthat dataset
DELETE /v1/datasets/{datasetId}/records/{recordId}that dataset
DELETE /v1/sites/{siteId}/form-submissions/{submissionId}that site
POST /v1/contactsthe organization
DELETE /v1/contacts/{contactId}the organization
POST /v1/mediathe organization
POST /v1/sites/{siteId}/mediathat 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 returns 200 with 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 400 never consumes a key at all, and neither does a refusal that a customer can clear: a 403 plan_required on POST /v1/datasets or POST /v1/contacts goes away when someone upgrades, a 409 dataset_not_empty goes away when the records are deleted, and a 409 contact_exists goes 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 same 200 body, 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: a 204-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 404 releases 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.