Every failure comes back in the same shape:
{
"code": "missing_orgid",
"statusCode": 400,
"error": "Organization ID is required. Pass orgid as a header, query param, or body field.",
"path": "/",
"method": "GET",
"timeStamp": "2026-09-11T09:16:21.511Z"
}Branch on code. It is stable. error is written for a human and will be reworded.
try {
await appmint('client/affiliate/me', { customerToken });
} catch (err) {
if (err.code === 'missing_authorization_header') return redirectToSignIn();
if (err.status === 429) return retryAfterBackoff();
throw err;
}Codes worth handling
| Code | Status | Meaning | What to do |
|---|---|---|---|
missing_orgid | 400 | No tenant on the request | Send orgid. A configuration bug, not a user error |
missing_authorization_header | 401 | Customer route without a token | Sign the customer in |
| — | 401 | Token expired or rejected | Refresh once, replay once, then sign in |
| — | 403 | Authenticated but not permitted | Do not retry |
| — | 404 | No such record | Distinguish "gone" from "never existed" before deleting local state |
| — | 409 | Conflict — usually a uniqueness clash | Surface the field; a referral code taken mid-write lands here |
| — | 422 | Validation failed | Show the message; the body says which field |
| — | 429 | Rate limited | Back off, honour Retry-After |
| — | 5xx | Server fault | Retry idempotent reads; never blind-retry a payment |
Validation is strict
The global pipe runs with forbidNonWhitelisted: true, so an undeclared property rejects the whole request rather than being ignored.
This bites on read-modify-write: fetch a record, change one field, send it back, and the platform fields (sk, pk, createdate, version) plus computed ones (calculatedPrice) are not accepted by the write DTO.
// Fails with 400
const { data: product } = await appmint(`storefront/product/${sku}`);
product.data.price = 42;
await appmint('…', { method: 'POST', body: product });
// Works — send only what the write accepts
await appmint('…', { method: 'POST', body: { sku, price: 42 } });enableImplicitConversion does coerce "5" to 5 where the DTO says number, so numeric strings from form inputs are fine.
Failures that are not errors
Some outcomes return 200 with a verdict in the body. Check the field, not the status.
A bad coupon — the cart still prices, the total is unchanged:
{ "valid": false, "reason": "not_found", "message": "Discount code not found", "total": 1049.25 }An unservable destination — served: false is the honest answer, never a guessed rate:
{ "served": false, "options": [], "currency": "USD" }Priced but unquotable — somewhere is covered, but not right now:
{
"served": false,
"options": [],
"problems": [
{ "config": "live", "reason": "live: Cannot calculate shipping: \"SKU1\" is missing parcel dimensions (length/width/height). Set dimensions on the product or use a fixed shipping rate." }
]
}problems[] names the offending configuration and why. Show it — "we can't ship there" and "this product has no dimensions set" need different actions from different people.
Proxy your errors faithfully
If you put your own server in front (and you should — see The server proxy), forward the upstream status:
if (axiosError.response) {
return NextResponse.json(
{ error: axiosError.response.data, status: axiosError.response.status },
{ status: axiosError.response.status },
);
}Collapsing everything into 500 turns a missing record into an outage and hides the 422 message the user needed to read.
Fail loudly when you cannot reach AppEngine. A cached application token that goes bad in a way that is not a 401 leaves a process unable to call the API for its whole lifetime. If it keeps answering 200 with a degraded page, nothing alerts and the damage is measured in days. Return 503 so a health check pulls it out.