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 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.
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.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 heeft een bearer-token nodig, en er is geen wachtwoord-endpoint meer om er een te halen — POST /api/auth/login is in 2026-09 verwijderd. Er is één manier, en die is dezelfde of je koppeling nu 's nachts op een server draait of op iemands bureau.
Een koppeling authenticeert met een API-sleutel. Je Storekeeper-beheerder maakt er een voor je aan in de Storekeeper-app en geeft je twee waarden. De client_id is sk_ak_<account>.<key_id> — hij noemt zijn eigen account, dus je kunt het token-endpoint van dat account opzoeken in plaats van het hard te coderen — en je wisselt hem in voor een access token met client_credentials (RFC 6749 §4.4). Elke standaard OAuth-clientbibliotheek doet dit zonder maatwerk.
# opzoeken, dan inwisselen. Het inwisselen gebeurt BIJ STOREKEEPER:
# deze API ziet, bewaart en bemiddelt je secret nooit.
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
Het inwisselen is goedkoop en herhaalbaar, dus een client hoeft geen tokens te hamsteren: haal er een bij het opstarten, en nog een zodra hij verloopt of een aanroep 401 antwoordt. Eén sleutel is voor één koppeling, niet voor een vloot — de limiet per sleutel houdt dat zo, dus geef elke koppeling een eigen sleutel in plaats van er een te delen. Workers bijzetten op één sleutel levert geen doorvoer op; het verdeelt één budget over meer monden.
Elk geauthenticeerd antwoord vertelt je waar je staat: RateLimit-Limit, RateLimit-Remaining en RateLimit-Reset (seconden tot het venster rolt). Lees RateLimit-Remaining terwijl je een lijst leegloopt en schakel terug voordat hij nul bereikt — dat is het getal om je tempo op te bepalen, want het plafond is deployment-configuratie en een tempo dat je uit een documentatiepagina hard hebt gecodeerd is achterhaald op de dag dat het wordt bijgesteld. Boven het plafond krijg je een 429 met Retry-After: wacht precies zoveel seconden en ga daarna verder. Niet heet herhalen, anders verbruik je ook het volgende venster aan weigeringen. Schrijfacties (POST, PUT, PATCH, DELETE) worden op een tweede, strengere teller afgeschreven bovenop de algemene, dus een sync die veel leest en weinig schrijft loopt eerst tegen de schrijflimiet aan.
Twee extra headers zeggen hetzelfde, maar met het plafond erbij genoemd. RateLimit-Policy somt elk plafond op dat voor jouw sleutel geldt — het zijn er twee, general en write — elk met een quotum (q) en een venster in seconden (w). Hij wordt uit de configuratie opgebouwd en niet uit je verzoek, dus ook een GET draagt het schrijfplafond: een client die tot nu toe alleen las, weet zo al wat er op zijn eerste schrijfactie wacht. RateLimit geeft vervolgens de stand van precies één plafond, het plafond dat je als eerste gaat weigeren, en noemt het erbij — r is wat er over is en t het aantal seconden tot het opnieuw begint, dezelfde twee getallen als RateLimit-Remaining en RateLimit-Reset. Die naam is precies wat een lus die leest én schrijft nodig heeft: daaraan zie je dat een sprong van een klein naar een groot getal een andere teller was, en niet een server die zich bedenkt. Bij een 429 noemt hij het plafond dat weigerde, met r=0. Een antwoord dat geen van de vijf draagt, is helemaal niet geteld — een niet-geauthenticeerde aanroep, of een minuut die we niet konden meten — dus lees het ontbreken als "geen budget gemeld", nooit als nul.
{
"access_token": "eyJ0eXAiOiJhdCtqd3Qi...",
"token_type": "Bearer",
"expires_in": 900
}
Deze API heeft geen ververs-endpoint, en je hebt er ook geen nodig. Is
expires_in op, dan wissel je de sleutel gewoon opnieuw in — dezelfde aanroep,
de tweede keer net zo goedkoop als de eerste. Inwisselen trekt niets in, roteert niets en
verbruikt niets, dus een client die simpelweg om een token vraagt wanneer hij er een nodig
heeft, is de juiste client.
# de vorm van een nette client, in pseudocode
token, ttl = wissel_api_sleutel() # client_credentials, bij Storekeeper
loop:
if now > issued_at + ttl - 60: # haal er een minuut voor hij sterft een nieuwe
token, ttl = wissel_api_sleutel()
resp = call(endpoint, token)
if resp.status == 401: # dubbel gedekt: token toch gestorven
token, ttl = wissel_api_sleutel() # EEN keer opnieuw inwisselen
resp = call(endpoint, token) # EEN keer herhalen, daarna luid falen
if resp.status == 429: # over het plafond van deze minuut
sleep(resp.header["Retry-After"]) # precies zo lang, dan verder
if resp.header["RateLimit-Remaining"] < resp.header["RateLimit-Limit"] / 10:
schakel_terug() # tempo uit de headers, niet hard gecodeerd
# resp.header["RateLimit"] noemt van welk plafond
# die getallen kwamen: "general" of "write"
# een 401 op het inwisselen zelf is een dode sleutel, geen retry:
# hij is ingetrokken of vervangen, en een mens moet een nieuwe uitgeven.
GET /api/me vertelt je van wie het token is, en welke capabilities de rol van de
sleutel echt heeft; roep hem één keer bij het opstarten aan als controle dat je op het
juiste account zit, met de rechten die je denkt te hebben.
expires_in is deployment-configuratie, geen constante — hardcode geen 900 of 1800. Parse het token niet, bewaar het niet langer dan zijn TTL, en deel er geen tussen twee workers — geef elke worker zijn eigen sessie.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.