Creating an order
curl -X POST https://labify-staging-3hnx3fg7ca-uc.a.run.app/api/v1/orders \
-H "Authorization: Bearer $JUSTLABS_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-4711-attempt-1" \
-d '{
"externalId": "ext-4711",
"patient": {
"firstName": "Jane",
"lastName": "Doe",
"dateOfBirth": "1990-04-12",
"sex": "female",
"email": "jane@example.com",
"phone": "8165551234",
"address": {
"line1": "123 Main St",
"city": "Kansas City",
"state": "MO",
"zip": "64105"
}
},
"items": [{ "id": "tsh" }, { "id": "vitamin-d" }],
"metadata": { "cohort": "q3-pilot" }
}'
import { randomUUID } from 'node:crypto';
// Mint the Idempotency-Key ONCE per logical order, outside any retry loop —
// generating it inside the retry (e.g. `import { randomUUID } from 'node:crypto'`
// called fresh on each attempt) defeats the whole point: a retry with a NEW
// key is a brand new request as far as the server is concerned, and can
// charge the partner's card a second time for the same order.
const idempotencyKey = randomUUID();
async function createOrder(orderRequest, attempt = 0) {
const res = await fetch(`${BASE_URL}/orders`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify(orderRequest),
});
// See "Idempotency & retry contract" below for which statuses are safe to
// retry with this SAME key/body, and which require a fresh key instead.
return res.json();
}
const order = await createOrder(orderRequest);
Idempotency-Key
Required on every POST /orders — 1–100 chars, [A-Za-z0-9_.-] (a
UUID is fine; any other character is rejected with
400 idempotency_key_required). It exists so a network timeout or retry never
places (or charges for) the same order twice. Keys are held for 24
hours; see "Idempotency & retry contract" below — read it before writing
retry logic, the correct behavior differs by status code, and generate the
key exactly once per logical order (see the Node sample above).
402 handling
A 402 means the charge did not go through:
no_payment_method— no card on file for your account. Fix it in the partner portal, then retry with the sameIdempotency-Key(within 24 hours of the original request — see below).payment_failed— Stripe declined the charge.detailsis either{ declineCode }(may itself benullif Stripe didn't supply one) when Stripe raised a card error, or{ status }(the PaymentIntent's terminal non-succeededstatus —requires_payment_methodorcanceled— nodeclineCode) when it resolved without an error but didn't complete. Retry with the same key once the issue is resolved (e.g. a different card is added on file), or treat it as terminal and surface it to your own user.
A 402 is a safe, expected outcome to retry — it does not mean "unknown
whether we were charged" (that's 409 payment_in_flight / 502, see
below). A PaymentIntent that is still live (processing, requires_action,
requires_confirmation, requires_capture) is never reported as a 402:
it comes back as 502 payment_pending, because the charge may still
complete and a fresh attempt could charge twice.
Minors and guardian
The patient's age is computed from dateOfBirth as of the request time. If
under 18, the request must include guardian (firstName, lastName,
email, relationship: parent | legal_guardian | other) or it is
rejected with 400 validation_error (details.fields.guardian). Some items
are not available to minors at all — those requests fail with
400 minor_not_eligible and details.ids listing the offending item ids;
retry with those items removed rather than resubmitting the same body.
Idempotency & retry contract
Every rule below assumes the retry happens within the key's 24-hour
lifetime.
Once a key is older than 24 hours it is treated as if it had never been
used — reusing it does not replay anything and mints a genuinely new
order and a new charge. If you're not sure whether an order from more than
a day ago actually went through, do not just retry the key: reconcile first
with GET /orders?externalId=<your externalId> (or GET /orders/{id} if
you have the id) to see whether it already exists before deciding to
resubmit.
| Response | What it means | How to retry |
|---|---|---|
201 | Order created (or this is a replay of a prior success for the same key). | N/A — done. |
400 (any validation code) | The request was rejected by validation, which runs before the Idempotency-Key is ever consumed. | The key was NOT consumed. Fix the body and reuse the same key, or mint a new one — either works, since nothing was recorded against that key. |
402 | Stripe declined the charge — definitively no charge occurred. | Retry with the SAME key, within 24 hours; a fresh attempt (and a fresh Stripe charge) is safe. |
409 idempotency_in_progress | Another request with this key is still being processed — this also covers retrying within 5 minutes of a 500 on the same key (see below). | Wait and retry the same key later; do not mint a new one. |
409 idempotency_conflict | This key was already used with a different request body. | Use a new key if this is genuinely a different order. |
409 payment_in_flight | The previous attempt's payment outcome is unknown. | Retry with the SAME key and the SAME body, within 24 hours. A new key could charge the card a second time. |
500 internal_error | The order may or may not have been recorded after a successful-looking charge. The key is left in_progress (not completed), so an immediate retry within 5 minutes gets 409 idempotency_in_progress — that's expected, not a bug. | Retry with the SAME key, within 24 hours; never mint a new one. |
502 payment_provider_error | The payment provider failed or did not respond (covers both a Stripe outage and other non-network Stripe errors, not just a timeout). | Retry with the SAME key and the SAME body, within 24 hours; a new key could charge twice. |
502 payment_pending | The PaymentIntent was created but is still live (details.status: processing, requires_action, requires_confirmation or requires_capture) — whether the charge completes is not yet known. | Retry with the SAME key and the SAME body, within 24 hours. The server remembers that intent and the retry checks its current status (it does not create a new one): once it has succeeded the order is written and you get 201; if it ended up declined you get 402; while still pending you get 502 payment_pending again. |
The rule of thumb: a new Idempotency-Key is only safe after a 400, a
definitive 402, or once the original key has passed its 24-hour window
(and you've reconciled via GET /orders). Every other non-201 outcome
must be retried with the exact same key (and, where noted, the exact same
body) within that window — the server tracks in-flight and unresolved
payment attempts by key, and a new key bypasses that protection.