Skip to main content

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 same Idempotency-Key (within 24 hours of the original request — see below).
  • payment_failed — Stripe declined the charge. details is either { declineCode } (may itself be null if Stripe didn't supply one) when Stripe raised a card error, or { status } (the PaymentIntent's terminal non-succeeded status — requires_payment_method or canceled — no declineCode) 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.

ResponseWhat it meansHow to retry
201Order 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.
402Stripe 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_progressAnother 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_conflictThis key was already used with a different request body.Use a new key if this is genuinely a different order.
409 payment_in_flightThe 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_errorThe 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_errorThe 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_pendingThe 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.