Pagination and idempotency

How to read a long list one page at a time, and how to retry a write without applying it twice.

Pagination

Every route that returns a list returns one page of it, in the same envelope:

{
  "total_items": 132,
  "total_pages": 3,
  "page_number": 2,
  "page_size": 50,
  "items": [],
  "next_page_token": "eyJwIjozLCJzIjo1MCwibCI6Ij..."
}
Field Meaning
total_items How many items match, across every page.
total_pages How many pages of page_size items that makes.
page_number The page this answer holds, starting at 1.
page_size The most items a page holds.
items The items on this page.
next_page_token Send it as page_token to get the next page; null on the last page.

You choose the page with these parameters. A search (POST .../search) takes them in the JSON body, next to its filters; a list read with GET takes them in the query string.

Parameter Rules
page_token The next_page_token of the page before, unchanged. It names the page and its size, so page_number and page_size are ignored when you send it.
page_number 1 or more. Default 1.
page_size 1 to 250. Default 50 on every list.

A value outside these bounds is refused with 422 and hint.validation_errors. A page past the last one is not an error: it answers 200 with an empty items. A page_token the list did not return, or one that was edited, or one from another list, is refused with 400 invalid_request and hint.field set to page_token. Treat the token as opaque: its content may change without notice.

curl https://api.bind.co/v1/operations/expenses/search \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"status": ["active"], "sort_by": ["-expense_date"], "page_number": 2, "page_size": 50}'
curl "https://api.bind.co/v1/drafts/goals?page_token=$NEXT_PAGE_TOKEN" \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Reading a whole list

Send the first request without page_token, then send each answer's next_page_token as page_token, with the same filters and sort_by, until next_page_token is null. The token names the next page by its position: each call counts the matching items afresh and returns that slice. If items are added or change while you page, an item can move to a neighbouring page, so you may see one twice or miss one; de-duplicate by id (operation_id for records), and read again if you need an exact snapshot. Increasing page_number yourself still works and gives the same pages.

To learn about new and changed records, subscribe to webhooks such as <operation_type>.completed rather than paging through everything again; see Webhooks.

Idempotency

A network failure can leave you not knowing whether a write happened. Send an Idempotency-Key header, and you can repeat the request safely: bind.co applies it once and gives every retry the first answer.

curl https://api.bind.co/v1/operations/expenses/op_5b1f0c7e-8a2d-4c3b-9e61-2f7d4a9c0b13/confirm \
  -X POST \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Idempotency-Key: 3f0e8a52-7c1d-4b9e-a6f2-91d4c0b7e235"

Which routes take it

Every route that takes it lists Idempotency-Key among its parameters in the API reference. They are the POST actions that create or decide something: creating, confirming, rejecting, resetting, restoring, merging and unmerging records, contacts and roles, confirming and resetting profiles, and the chaser decisions; the draft actions, including POST /v1/drafts/{draft_id}/send, PATCH /v1/drafts/{draft_id} and the draft goals; and the PATCH edits of records, profiles, companies, contacts and roles. Searches take it too, but a search changes nothing: the key is not kept and every search answers with the current results. The webhook routes, called as your app rather than for a user, take it too without storing it, so a repeated request runs again. Reads and DELETE do not take it, and ignore it if you send it.

The header is optional. Its value is any string you choose of up to 255 characters; a UUID works. Use a new key for every new request, and the same key only to retry the same request.

How a key is matched

A key belongs to your application, the user the access token names, the method and the route, so the same key on another route, for another user or from another application is a separate key. bind.co also remembers the request it was first used with: the path, the query string and the body.

You send You get
A new key The request runs, and its answer is kept for 7 days.
The same key and the same request, after it finished The kept answer: the same status code, body and Location, with Idempotent-Replayed: true. The request does not run again.
The same key and the same request, while the first is still running 409 idempotency_in_progress. Retry with the same key once it finishes.
The same key with a different request 422 idempotency_key_reused. Nothing runs.
A key older than 7 days A new key: the request runs again.

Only answers a retry would get again are kept: every 2xx, and 400, 404, 409 and 422. An answer saying the action may already have happened is kept too, so a retry cannot repeat it: a failed send whose hint.may_have_sent is true is replayed rather than sent twice. Any other answer, such as 401, 403, 429 or a 5xx, is not kept, so a retry with the same key runs the request again. If the first attempt never finished (its connection dropped and the server stopped), the same key runs the request again once that attempt can no longer be running, a few minutes later at most; until then you get 409 idempotency_in_progress.

Changing a record only if it is unchanged

A single record's GET answer carries an ETag header, a tag of exactly what you read: profiles, companies, contacts, roles, drafts, operation records and webhook endpoints. So does the answer to its PATCH. To make sure nobody changed the record since you read it, send that tag as If-Match on its PATCH, its DELETE, or any POST action on it (/confirm, /reject, /send and the others):

curl https://api.bind.co/v1/operations/expenses/op_5b1f0c7e-8a2d-4c3b-9e61-2f7d4a9c0b13/confirm \
  -X POST \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H 'If-Match: "9a0c4e1b7d2f8a6c3e5b1d0f7a9c2e4b"'

If the record changed, nothing runs and you get 412 precondition_failed, with the record's current tag in hint.current_etag: read it again and reapply your change. If-Match: * matches any current record. Without If-Match a change applies whatever the record's state, as before.

Two changes sent at once with the same tag never both apply: the second waits for the first to finish, then gets 412 if the first changed the record. If it waits too long it gets 503 temporarily_unavailable with Retry-After; nothing ran, so read the record again and retry. If-Match only orders changes that send it. A change sent without it, or one bind.co makes itself, can still land between the check and your change.

If you retry a conditional change that already succeeded, with the same Idempotency-Key and the same If-Match, you get 412, not the stored answer: the record now has a new tag. Read it again to see your change.

Other HTTP conventions

Retrying safely

Answer What to do
429 rate_limited Wait the Retry-After seconds, then retry; back off exponentially if it repeats. See Rate limits.
409 idempotency_in_progress Wait a few seconds and retry with the same key.
503 temporarily_unavailable Wait the Retry-After seconds, then retry with the same key.
5xx, timeout, dropped connection Retry with the same key, backing off exponentially.
422 idempotency_key_reused A bug in your client: you reused a key for a different request. Send a new key.
Other 4xx Do not retry unchanged; fix the request. See Errors.

Each response from a limited route carries X-RateLimit-Remaining and X-RateLimit-Reset, so you can slow down before you reach a 429.