Skip to content

Errors and edge cases

What we return when things go wrong, and what to do about it.


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.


StatusMeaningWhat to do
200Success, including a newly created member — but check is_ok on action responses
204Success, no body — stamp reservations, dependents
400Validation failure, or a business rule such as a phone number that is already registeredRead errors or detail
401Token missing, expired or invalidRefresh the token and retry once
403Authenticated, but not for this storeThe {storeId} in the path is not the id from GET /stores
404Path or resource does not existCheck the path
5xxFailure on our sideRetry with backoff — see the retry policy

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.


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.


Phone number is not valid
Email is not valid

Fix: Normalise to international format (+4799900001) before sending. Do not send an empty string for an optional email — omit the field.


'country_code' must be a valid alpha-2 country code

Cause: 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.


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.


Member with phone number ending in 0001 is already registered for this store

Cause: 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.


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.


These are worse than errors, because nothing tells you.

Silent failureSymptomPrevention
origin.channel missing or misspelled on a transactionOnline orders reported as in-storeSet "OnlineStore" exactly, everywhere
Reservation left behind by an abandoned cartCoupon held forever, never usable againRelease when the cart is abandoned or payment fails — reservations do not expire
receive_sms or receive_email left out of a PUTMember silently unsubscribedAlways send both flags — see Keeping profiles in sync
receive_sms or receive_email left out of a createMember silently opted in to marketingSend both flags, from the customer’s own answers
Naive created_atOrders recorded at the wrong timeAlways send an explicit offset
Search treated as found because status is 200Every customer looks like a memberCheck the array length
remaining_points shown as the balanceCustomer sees the wrong balanceShow points — see Core concepts
Two different orders posted with the same external_idThe second order earns nothing, yet returns 200One external_id per order, and check is_ok on every transaction response
Same coupon at order and line levelDouble-counted redemptionChoose 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.


OperationSafe to retry?Notes
GET anythingYesIdempotent
POST /connect/tokenYes
POST /membersNoA retry after a lost response gives 400 “already registered”. Search first
PUT /members/{id}YesThe same body gives the same result
POST /transactionsYesWe reject a repeat of the same order with 200 and is_ok: false — see duplicates
POST .../reserveYesSame order id reserves the same coupon
POST .../cancel-reservationYesReleasing again returns is_ok: false and changes nothing
DELETE /members/{id}YesAlready-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.


Loyalty is an enhancement. It must never stop a customer buying.

flowchart TD A["Checkout"] --> B{"Diller reachable?"} B -->|Yes| C["Show coupons and points"] B -->|No| D["Hide the loyalty panel"] C --> E["Take payment"] D --> E E --> F{"Payment OK?"} F -->|No| G["Fail the order"] F -->|Yes| H["Confirm the order to the customer"] H --> I{"Transaction posted?"} I -->|Yes| J["Done"] I -->|No| K["Queue for retry.<br/>Order still succeeded."]

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 dillerFetch helper 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_id and the response against the order. When a merchant asks why a member has no points, that log is the answer.

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.