Recipes / Integration patterns

Foundations

Integration patterns

The cross-cutting habits every solid integration shares: token lifecycle, pagination drains, error handling, money and date conventions, id resolution.

POST/oauth/token GET/api/me

Distilled from nine production integrations.

Own the token lifecycle

Every call needs a bearer token, and there is no password endpoint to get one from — POST /api/auth/login was removed in 2026-09. There is one way, and it is the same one whether your integration runs nightly on a server or on somebody's desk.

An integration authenticates with an API key. Your Storekeeper administrator creates one for you in the Storekeeper app and hands you two values. The client_id is sk_ak_<account>.<key_id> — it names its own account, so you can discover that account's token endpoint rather than hard-coding it — and you exchange it for an access token with client_credentials (RFC 6749 §4.4). Any stock OAuth client library does this with no bespoke code.

# discover, then exchange. The exchange happens AT STOREKEEPER:
# this API never sees, holds or brokers your secret.
curl -s https://api-$ACCOUNT.storekeepercloud.com/.well-known/oauth-authorization-server \
  | jq -r .token_endpoint

curl -s -X POST https://api-$ACCOUNT.storekeepercloud.com/oauth/token \
  -u "$SK_CLIENT_ID:$SK_CLIENT_SECRET" \
  -d grant_type=client_credentials

The exchange is cheap and repeatable, so a client does not need to hoard tokens: get one at startup, and get another when it expires or when a call answers 401. One key is for one integration, not for a fleet — the per-key rate limit is there to keep it that way, so give each integration its own key rather than sharing one. Adding workers on one key does not buy you throughput; it splits one budget more ways.

Every authenticated response tells you where you stand: RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset (seconds until the window rolls). Read RateLimit-Remaining as you drain a list and slow down before it reaches zero — that is the number to pace against, because the ceiling is deployment configuration and a rate hard-coded from a documentation page goes stale the day it is tuned. Past the ceiling you get a 429 with Retry-After: sleep exactly that many seconds and carry on. Do not retry hot, or you spend the next window on refusals as well. Writes (POST, PUT, PATCH, DELETE) are charged against a second, tighter counter on top of the general one, so a sync that reads a lot and writes a little meets the write ceiling first.

Two further headers say the same thing with the ceiling named. RateLimit-Policy lists every ceiling that applies to your key — there are two, general and write — each with its quota (q) and its window in seconds (w). It is built from configuration rather than from your request, so even a GET carries the write ceiling: a client that has only read so far still learns what is waiting for its first write. RateLimit then reports the state of exactly one ceiling, the one closest to refusing you next, and names it — r is what is left and t the seconds until it resets, the same two numbers as RateLimit-Remaining and RateLimit-Reset. That name is what a mixed read/write loop needs: it is what tells you a jump from a small number to a large one was the counter changing, not the server changing its mind. On a 429 it names the ceiling that refused, with r=0. A response carrying none of the five was not counted at all — an unauthenticated call, or a minute we could not measure — so read absence as "no budget reported", never as zero.

{
  "access_token": "eyJ0eXAiOiJhdCtqd3Qi...",
  "token_type": "Bearer",
  "expires_in": 900
}

There is no renew endpoint on this API, and you do not need one. When expires_in runs out you exchange the key again — the same call, as cheap the second time as the first. Nothing is revoked, rotated or consumed by exchanging, so a client that simply asks for a token when it needs one is the correct client.

# the shape of a well-behaved client, in pseudocode
token, ttl = exchange_api_key()   # client_credentials, at Storekeeper
loop:
  if now > issued_at + ttl - 60:  # get another a minute before it dies
    token, ttl = exchange_api_key()
  resp = call(endpoint, token)
  if resp.status == 401:          # belt and braces: token died anyway
    token, ttl = exchange_api_key()# re-exchange ONCE
    resp = call(endpoint, token)  # retry ONCE, then fail loudly
  if resp.status == 429:          # over the ceiling for this minute
    sleep(resp.header["Retry-After"])  # exactly that long, then carry on
  if resp.header["RateLimit-Remaining"] < resp.header["RateLimit-Limit"] / 10:
    slow_down()                   # pace off the headers, not a hard-coded rate
                                  # resp.header["RateLimit"] names the ceiling those
                                  # numbers came from: "general" or "write"
# a 401 on the exchange itself is a dead key, not a retry:
# it has been revoked or rolled, and a human has to issue a new one.

GET /api/me tells you who the token belongs to, and which capabilities the key's role actually holds; call it once at startup as a sanity check that you are on the account you think you are, with the authority you think you have.

Gotcha: the token is opaque, and expires_in is deployment configuration, not a constant — do not hard-code 900 or 1800. Do not parse the token, do not persist it beyond its TTL, and do not share one between two workers — give each worker its own session.

Drain lists, don't guess pages

Every list endpoint answers with the same envelope: count (rows on this page), total (rows overall), data (the rows), and echoes your start and limit. The drain loop is always the same: request, append data, add count to start, repeat while start < total. Never assume "fewer rows than limit means done" is the only signal; total is authoritative.

start=0; limit=200
while start < total:
  GET /api/products?start=$start&limit=$limit
  rows += data
  start += count

Keep limit sane: 100 to 250 is the sweet spot. Bigger pages save little and make each retry more expensive; tiny pages multiply round trips. Date-range parameters (from, to) are inclusive on both ends and interpreted in Europe/Amsterdam, so from=2026-06-01&to=2026-06-30 is exactly the month of June.

Handle errors by class

Errors come back as JSON with a stable shape: error (the status label), message (what actually went wrong), and status (the HTTP code again, for logs that only keep bodies). What to do depends on the class, not the endpoint:

StatusMeaningWhat to do
400Your input is malformed (bad filter, bad body)Fix the request. Do not retry as-is; it will fail identically forever.
401Token missing, invalid, or expiredRe-authenticate once, retry once.
404The id does not exist on this accountCheck which account you logged in to; ids are per-account.
502Upstream Storekeeper errorSafe to retry with backoff (e.g. 2s, 8s, 30s); it is transient more often than not.
{
  "error": "Bad Request",
  "message": "`product_ids` contained no valid numeric ids",
  "status": 400
}

Log the full error body, not just the status code. The message is written to be actionable, and a 401 additionally carries a hint field telling you to re-login. A log line that says only "400" costs a debugging session; the body would have named the parameter.

Money and dates without surprises

All money is decimal euros. The suffix convention is consistent across the whole API: _wt means "with tax" (including VAT), a plain field or an _ex suffix means excluding VAT. Where a price or a total matters, the API gives you both sides plus the VAT itself; use them as given and never compute one from the other. Your rounding will not match Storekeeper's, and a price list that is one cent off the till is worse than no price list.

Dates are YYYY-MM-DD, interpreted in Europe/Amsterdam, and ranges are inclusive on both ends. A day boundary is a Dutch midnight, not UTC; a sale at 00:30 Amsterdam time on July 1st belongs to July, even though it is still June 30th in UTC.

Gotcha: if your own database stores UTC timestamps, convert before comparing against API day totals. The classic symptom is a daily report that is a few late-evening orders off; that is a timezone bug, not an API bug.

Resolve ids once, cache them

The API speaks in numeric ids. Each id space has one reference endpoint; call each once at the start of a sync, cache the mapping in memory, and translate at the edges of your system. Do not hardcode ids across accounts.

Id fieldResolve viaNote
shop_idGET /api/shopsA sales channel (webshop, POS).
location_idGET /api/locationsA physical site.
tax_rate_idGET /api/tax-rates?country_iso2=NLFilter, or you get the whole EU registry.
product_group_idGET /api/turnover-groupsTurnover groups double as revenue-ledger keys.
provider_method_type_idGET /api/payment-methodsIds are per-account; key your mapping on the stable type_alias.
Gotcha: locations and shops are different id spaces that happen to look alike. A location is a physical site (a branch you can walk into); a shop is a sales channel. Location id 14 and shop id 14 are unrelated. Filters that take location_id will not accept a shop id, and vice versa; keep the two mappings separate in your code.