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/api/auth/login GET/api/me

Distilled from nine production integrations.

Own the token lifecycle

Every call except login itself needs a bearer token. POST /api/auth/login takes your account, email and password and returns a token with a 30 minute TTL. Send it as Authorization: Bearer on every request.

curl -s -X POST https://api.storekeeper.me/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{ "account": "bakkerijjanssen", "email": "koppeling@bakkerijjanssen.nl", "password": "..." }'
{
  "token": "eyJpdiI6IlZzT3F3...",
  "expires_at": 1782816000,
  "expires_in": 1800
}

Refresh proactively, not reactively. POST /api/auth/refresh takes the same body and returns a fresh token; call it on a timer at around 25 minutes, well before expires_at. A long-running sync that only reacts to 401s will scatter mid-run failures through its logs. Still handle a 401 defensively: re-login once, retry the failed call once, and give up loudly if that fails too. GET /api/me tells you who the token belongs to; call it once at startup as a sanity check that you are on the account you think you are.

# the shape of a well-behaved client, in pseudocode
login()                          # store token + expires_at
loop:
  if now > expires_at - 300:     # 5 min margin = refresh at ~25 min
    refresh()
  resp = call(endpoint, token)
  if resp.status == 401:         # belt and braces: token died anyway
    login()                      # re-auth ONCE
    resp = call(endpoint, token) # retry ONCE, then fail loudly
Gotcha: the token is opaque. Do not parse it, do not persist it beyond its TTL, and do not share one token between two workers that refresh independently; give each worker its own login.

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.