Errors and edge cases
What we return when things go wrong, and what to do about it.
The error shapes
Section titled “The error shapes”Errors come in three shapes. Handle all three; one parser that assumes a single shape will throw on the other two.
Validation errors — a field failed its rules. The messages are in
errors, a map of field name to an array of messages:
{ "type": null, "title": "One or more validation errors occurred.", "status": 400, "detail": null, "instance": null, "errors": { "phone": ["Phone number is not valid"], "email": ["Email is not valid"] }}Other errors — a problem document whose message is in detail, with
no errors map:
{ "status": 400, "detail": "The message describing what went wrong"}Token endpoint errors — POST /connect/token returns a plain-text
body, not JSON.
async function readError(res) { const text = await res.text(); try { const body = JSON.parse(text); if (body.errors) return Object.values(body.errors).flat().join('; '); return body.detail ?? body.title ?? text; } catch { return text; // the token endpoint }}Surface these messages — they are specific and usually actionable.
Successful action-style calls return:
{ "is_ok": true, "details": "" }is_ok: false with a populated details is a business failure, not a
transport failure — a coupon that is expired, for instance. The HTTP status
is still 200. Check is_ok, not just the status.
Status codes
Section titled “Status codes”| Status | Meaning | What to do |
|---|---|---|
200 | Success, including a newly created member — but check is_ok on action responses | |
204 | Success, no body — stamp reservations, dependents | |
400 | Validation failure, or a business rule such as a phone number that is already registered | Read errors or detail |
401 | Token missing, expired or invalid | Refresh the token and retry once |
403 | Authenticated, but not for this store | The {storeId} in the path is not the id from GET /stores |
404 | Path or resource does not exist | Check the path |
5xx | Failure on our side | Retry with backoff — see the retry policy |
Errors you will actually hit
Section titled “Errors you will actually hit”400 — consent not accepted
Section titled “400 — consent not accepted”The 'gdpr_accepted' property on the 'Consent' object must be set to true.On update, the message continues:
If your goal is to delete the member, please use the DELETE endpoint.Cause: consent.gdpr_accepted is false or missing.
Fix: Only create or update a member when they have actually consented.
To remove consent entirely, use DELETE.
400 — no search parameter
Section titled “400 — no search parameter”The message asks you to provide one of the search parameters: phone,
email, identifierValue, cardIdentifier or activationToken.
Cause: An empty search — usually a variable that was undefined.
Fix: Guard before you call. An empty phone field should not become an
API request.
400 — invalid phone or email
Section titled “400 — invalid phone or email”Phone number is not validEmail is not validFix: Normalise to international format (+4799900001) before sending.
Do not send an empty string for an optional email — omit the field.
400 — invalid country code
Section titled “400 — invalid country code”'country_code' must be a valid alpha-2 country codeCause: Sending "Norway" or "NOR" in address.country_code.
Fix: ISO alpha-2: NO, SE, DK.
Remember the two different country_code fields: address takes NO, phone
takes +47.
400 — invalid grant type
Section titled “400 — invalid grant type”Invalid grant type. Please ensure the grant_type is set to 'client_credentials'Fix: The token request must be application/x-www-form-urlencoded,
with grant_type=client_credentials. Not JSON. The body is plain text,
like every error from the token endpoint.
400 — member already registered
Section titled “400 — member already registered”Member with phone number ending in 0001 is already registered for this storeCause: Creating a member whose phone number is already registered at
this store. This is a 400, not a 409 — branch on the message, or better,
avoid it.
Fix: Search first. If found, PUT instead of POST.
const existing = await findMemberByPhone('+4799900001');if (existing) { await updateMember(existing.id, changes);} else { await createMember(payload);}Races are still possible — two tabs, two submissions. If the create comes
back 400 with this message, search again and continue with the member you
find rather than surfacing an error. The
deduplication recipe has the full handler.
403 on every call
Section titled “403 on every call”Cause: The {storeId} in your path does not match the store your
credentials belong to.
Fix: Use the id from GET /api/v2.0/stores. Check for case mangling
too — store ids are case-sensitive and a toLowerCase() somewhere in your
config layer will break every request.
The failures that do not raise an error
Section titled “The failures that do not raise an error”These are worse than errors, because nothing tells you.
| Silent failure | Symptom | Prevention |
|---|---|---|
origin.channel missing or misspelled on a transaction | Online orders reported as in-store | Set "OnlineStore" exactly, everywhere |
| Reservation left behind by an abandoned cart | Coupon held forever, never usable again | Release when the cart is abandoned or payment fails — reservations do not expire |
receive_sms or receive_email left out of a PUT | Member silently unsubscribed | Always send both flags — see Keeping profiles in sync |
receive_sms or receive_email left out of a create | Member silently opted in to marketing | Send both flags, from the customer’s own answers |
Naive created_at | Orders recorded at the wrong time | Always send an explicit offset |
Search treated as found because status is 200 | Every customer looks like a member | Check the array length |
remaining_points shown as the balance | Customer sees the wrong balance | Show points — see Core concepts |
Two different orders posted with the same external_id | The second order earns nothing, yet returns 200 | One external_id per order, and check is_ok on every transaction response |
| Same coupon at order and line level | Double-counted redemption | Choose one |
GET /members/search always returns a JSON array. A miss is 200 with
[], never 404 — so the array length is the only reliable test.
Add assertions for these in your integration tests. Each is one line to check and a support ticket you will not have to reproduce.
Retry policy
Section titled “Retry policy”| Operation | Safe to retry? | Notes |
|---|---|---|
GET anything | Yes | Idempotent |
POST /connect/token | Yes | |
POST /members | No | A retry after a lost response gives 400 “already registered”. Search first |
PUT /members/{id} | Yes | The same body gives the same result |
POST /transactions | Yes | We reject a repeat of the same order with 200 and is_ok: false — see duplicates |
POST .../reserve | Yes | Same order id reserves the same coupon |
POST .../cancel-reservation | Yes | Releasing again returns is_ok: false and changes nothing |
DELETE /members/{id} | Yes | Already-marked stays marked |
For 5xx and network failures, exponential backoff with jitter — three
attempts, then queue for later rather than dropping.
async function withRetry(fn, { attempts = 3 } = {}) { let lastErr; for (let i = 0; i < attempts; i++) { try { const res = await fn(); if (res.status < 500) return res; // 4xx is ours to fix, not retryable lastErr = new Error(`Diller ${res.status}`); } catch (err) { lastErr = err; } await new Promise(r => setTimeout(r, (2 ** i) * 250 + Math.random() * 250)); } throw lastErr;}Note the res.status < 500 guard: retrying a 400 just sends the same bad
payload three times.
Degrading gracefully
Section titled “Degrading gracefully”Loyalty is an enhancement. It must never stop a customer buying.
Concretely:
- Timeouts. Set a short one on cart-path calls — two seconds or so. A
slow coupon lookup must not hold up a checkout page. The
dillerFetchhelper does this by default. - Never block payment on loyalty. If the transaction POST fails, the order still went through. Queue it and retry out of band.
- Fail closed on discounts. If validation cannot be reached, do not apply the discount. Giving away a discount you cannot verify is worse than not showing it.
- Log the correlation. Record your
external_idand the response against the order. When a merchant asks why a member has no points, that log is the answer.
Rate limits
Section titled “Rate limits”We publish no rate limit. Behave anyway:
- Cache the token. One per hour per store, not one per request.
- Cache departments and segments — they change rarely. Refresh daily.
- Do not poll for member changes. Read on login and after your own writes.
- Backfilling historical orders? Batch it, throttle it, and tell us first.
Watch the SMS-triggering endpoints in particular — enroll, otp and
reset-phone each send a real message the merchant pays for.
Rate-limit your own endpoints in front of them.
