Recipes / Integration patterns
FoundationsThe cross-cutting habits every solid integration shares: token lifecycle, pagination drains, error handling, money and date conventions, id resolution.
Distilled from nine production integrations.
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
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.
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:
| Status | Meaning | What to do |
|---|---|---|
400 | Your input is malformed (bad filter, bad body) | Fix the request. Do not retry as-is; it will fail identically forever. |
401 | Token missing, invalid, or expired | Re-authenticate once, retry once. |
404 | The id does not exist on this account | Check which account you logged in to; ids are per-account. |
502 | Upstream Storekeeper error | Safe 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.
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.
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 field | Resolve via | Note |
|---|---|---|
shop_id | GET /api/shops | A sales channel (webshop, POS). |
location_id | GET /api/locations | A physical site. |
tax_rate_id | GET /api/tax-rates?country_iso2=NL | Filter, or you get the whole EU registry. |
product_group_id | GET /api/turnover-groups | Turnover groups double as revenue-ledger keys. |
provider_method_type_id | GET /api/payment-methods | Ids are per-account; key your mapping on the stable type_alias. |
location_id will not accept a shop id, and vice versa; keep the two mappings separate in your code.Recepten / Integratiepatronen
FoundationsDe overkoepelende gewoonten van elke degelijke integratie: token-levenscyclus, paginering, foutafhandeling, geld- en datumconventies, id-vertaling.
Gedistilleerd uit negen productie-integraties.
Elke aanroep behalve login zelf heeft een bearer-token nodig. POST /api/auth/login neemt je account, e-mail en wachtwoord en geeft een token met een TTL van 30 minuten. Stuur hem als Authorization: Bearer mee op elk verzoek.
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
}
Ververs proactief, niet reactief. POST /api/auth/refresh neemt dezelfde body en geeft een vers token; roep hem op een timer aan rond de 25 minuten, ruim vóór expires_at. Een langlopende sync die alleen op 401's reageert, strooit halverwege de run fouten door zijn logs. Vang een 401 wel defensief op: log één keer opnieuw in, herhaal de mislukte aanroep één keer, en faal luid als dat ook misgaat. GET /api/me vertelt je van wie het token is; roep hem één keer bij het opstarten aan als controle dat je op het juiste account zit.
# de vorm van een nette client, in pseudocode
login() # bewaar token + expires_at
loop:
if now > expires_at - 300: # 5 min marge = verversen rond 25 min
refresh()
resp = call(endpoint, token)
if resp.status == 401: # dubbel gedekt: token toch gestorven
login() # EEN keer opnieuw inloggen
resp = call(endpoint, token) # EEN keer herhalen, daarna luid falen
Elk lijst-endpoint antwoordt met dezelfde envelop: count (regels op deze pagina), total (regels in totaal), data (de regels), en echoot je start en limit. De leegloop-lus is altijd hetzelfde: opvragen, data toevoegen, count bij start optellen, herhalen zolang start < total. Ga er nooit vanuit dat "minder regels dan de limiet" het enige stopsignaal is; total is leidend.
start=0; limit=200
while start < total:
GET /api/products?start=$start&limit=$limit
rows += data
start += count
Houd limit verstandig: 100 tot 250 is de gulden middenweg. Grotere pagina's leveren weinig op en maken elke retry duurder; piepkleine pagina's vermenigvuldigen het aantal aanroepen. Datumbereik-parameters (from, to) zijn aan beide kanten inclusief en worden geïnterpreteerd in Europe/Amsterdam, dus from=2026-06-01&to=2026-06-30 is precies de maand juni.
Fouten komen terug als JSON met een stabiele vorm: error (het statuslabel), message (wat er echt misging) en status (de HTTP-code nog een keer, voor logs die alleen bodies bewaren). Wat je moet doen hangt af van de klasse, niet van het endpoint:
| Status | Betekenis | Wat te doen |
|---|---|---|
400 | Je input klopt niet (fout filter, foute body) | Herstel het verzoek. Niet ongewijzigd herhalen; dat faalt eeuwig identiek. |
401 | Token ontbreekt, is ongeldig of verlopen | Eén keer opnieuw inloggen, één keer herhalen. |
404 | Het id bestaat niet op dit account | Controleer op welk account je bent ingelogd; ids zijn per account. |
502 | Fout in de Storekeeper-backend | Veilig om te herhalen met backoff (bijv. 2s, 8s, 30s); meestal tijdelijk. |
Log de volledige foutbody, niet alleen de statuscode. De message is geschreven om bruikbaar te zijn, en een 401 draagt bovendien een hint-veld dat je vertelt opnieuw in te loggen. Een logregel die alleen "400" zegt kost een debugsessie; de body had de parameter genoemd.
Al het geld is in decimale euro's. De suffix-conventie is overal in de API gelijk: _wt betekent "with tax" (inclusief btw), een kaal veld of een _ex-suffix betekent exclusief btw. Waar een prijs of totaal ertoe doet, geeft de API je beide kanten plus de btw zelf; gebruik ze zoals ze zijn en reken nooit zelf de één uit de ander. Jouw afronding komt niet overeen met die van Storekeeper, en een prijslijst die één cent naast de kassa zit is erger dan geen prijslijst.
Datums zijn YYYY-MM-DD, geïnterpreteerd in Europe/Amsterdam, en bereiken zijn aan beide kanten inclusief. Een daggrens is Nederlandse middernacht, niet UTC; een verkoop om 00:30 Amsterdamse tijd op 1 juli hoort bij juli, ook al is het in UTC nog 30 juni.
De API spreekt in numerieke ids. Elke id-ruimte heeft één referentie-endpoint; roep elk daarvan één keer aan bij de start van een sync, cache de mapping in het geheugen, en vertaal aan de randen van je systeem. Hardcode nooit ids over accounts heen.
| Id-veld | Vertaal via | Opmerking |
|---|---|---|
shop_id | GET /api/shops | Een verkoopkanaal (webshop, kassa). |
location_id | GET /api/locations | Een fysieke vestiging. |
tax_rate_id | GET /api/tax-rates?country_iso2=NL | Filter, anders krijg je het hele EU-register. |
product_group_id | GET /api/turnover-groups | Omzetgroepen zijn tegelijk je omzetgrootboek-sleutels. |
provider_method_type_id | GET /api/payment-methods | Ids zijn per account; sleutel je mapping op de stabiele type_alias. |
location_id nemen, accepteren geen winkel-id en andersom; houd de twee mappings gescheiden in je code.