# Four by Six — Partner API Four by Six prints photos. You send us an order over one REST endpoint; we pull the images out of your storage and print them. That is the entire integration — there is no SDK to embed, no payment flow to wire up, and no library to install. - **Base URL:** `https://api.fourbysix.co` - **Spec version:** `1.0` - **Machine-readable schema:** [`order.schema.json`](order.schema.json) - **Example payloads:** [`examples/photo-print.json`](examples/photo-print.json), [`examples/postcard.json`](examples/postcard.json) --- ## Read this part first Every item in an order carries a `source.url` pointing at an image in **your** storage, normally a presigned URL. We start downloading within seconds of your request. **If a URL has already expired when we reach it, that item is dead and so is the order.** There is no retry, no callback asking you for a fresh URL, and no endpoint to push one to. The order stops in `blocked` and your only option is to submit a new order under a new `order_ref`. This is the one thing integrations get wrong, so: - Mint presigned URLs **immediately before** you POST, not when the customer starts their session. A URL minted at checkout and submitted after a twenty-minute review step is the classic failure. - Give them a TTL of at least **15 minutes**. There is no upper bound we care about. - If you queue orders internally, mint the URLs at the point of sending, not the point of queueing. - Make sure the URLs are reachable from the public internet. VPC-only endpoints, IP allowlists and URLs that require your own auth headers will all fail. Everything else in this document is ordinary REST. --- ## Quickstart ```bash curl -X POST https://api.fourbysix.co/v1/orders \ -H "Authorization: Bearer sk_fourbysix_…" \ -H "Content-Type: application/json" \ -d '{ "spec_version": "1.0", "order_ref": "YOUR-ORDER-1", "order_type": "photo_print", "items": [ { "item_ref": "p1", "role": "print", "source": { "url": "https://your-bucket.example/photo.jpg?X-Amz-Signature=…" }, "print": { "size": "4x6", "finish": "matte" } } ] }' ``` ```json { "order_id": "01m2qt7q87qrm9axn9gjp3xf2h", "order_ref": "YOUR-ORDER-1", "state": "received", "items": [ { "item_id": "01m2qt7q879m7p7bqqbxzf27y3", "item_ref": "p1", "role": "print", "state": "pending_fetch" } ], "status_url": "https://api.fourbysix.co/v1/orders/01m2qt7q87qrm9axn9gjp3xf2h" } ``` `202` means we have durably recorded the order and queued its images for download. It does **not** mean the images arrived — poll `status_url` for that. --- ## Authentication ``` Authorization: Bearer sk_fourbysix_… ``` We issue your key and show it once; we store only a hash, so we cannot recover it for you. Keep it server-side — it is a secret key, and there is no browser-safe variant. | Situation | Status | |---|---| | Missing, unknown, revoked or expired key | `401 unauthorized` | | Valid key, suspended account | `403 partner_suspended` | You can hold several live keys at once, which is how you rotate without downtime: ask us for a new one, deploy it, then ask us to revoke the old one. --- ## `POST /v1/orders` ### Envelope | Field | Required | Notes | |---|---|---| | `spec_version` | yes | `"1.0"`. We accept any `1.x` and reject everything else. | | `order_ref` | yes | Your own order id. **This is the idempotency key** — see below. Unique per partner, max 200 chars. | | `order_type` | yes | `photo_print` or `postcard` | | `items` | yes | 1–200 items (your account limit is in `GET /v1/me`) | | `submitted_at` | no | ISO 8601. Defaults to when we received it. | | `priority` | no | `standard` (default) or `rush` | | `customer_ref` | no | Opaque. Stored and echoed back, never interpreted. | | `shipping` | no | Where the package goes. Stored verbatim. | | `metadata` | no | Free-form object, 4096 bytes max serialized. | **Unknown keys are rejected, not ignored.** If you send `customerRef` instead of `customer_ref` you get a `400` naming the field. This is deliberate: silently dropping a field you thought we were reading is a much worse outcome than a loud failure. ### Item | Field | Required | Notes | |---|---|---| | `item_ref` | yes | Your id for this image. Unique within the order. | | `role` | yes | `print`, `postcard_front` or `postcard_back` | | `source` | yes | See below | | `print` | yes | See below | | `quantity` | no | Copies to print. Defaults to `1`, max `1000`. | | `enhance` | no | `{"profile": "default" | "none"}` — `none` skips image enhancement | ### `source` | Field | Required | Notes | |---|---|---| | `url` | yes | **https only.** Up to 8192 characters, so long presigned URLs are fine. | | `content_type` | no | e.g. `image/jpeg`. Helps when the URL path has no file extension. | | `filename` | no | Cosmetic, and a useful extension hint. | | `sha256` | no | Hex digest. If you send it we verify it and fail the item on a mismatch. | Supported formats: **JPEG, PNG, TIFF, WebP, HEIC/HEIF**, and **DNG, CR2, CR3, NEF, ARW, RAF, ORF, RW2** raw. We identify the format from the file's own bytes, so an image served as `application/octet-stream` from a URL with no extension works fine — that is the normal shape of a presigned URL and we handle it. Equally, an image served under the *wrong* extension is handled by what it actually is. But something that is not an image at all is refused rather than stored, even if the URL claims otherwise. #### Send the original **Send the file the camera produced, unmodified.** This matters more than anything else in this document, because when it is wrong the order still succeeds and the print is simply worse. Do not resize to the print dimensions for us, and do not re-encode. We enhance first and fit to the print afterwards, and enhancement at full resolution followed by a downsample is measurably better than enhancement at 4x6 — sharper and cleaner, because the downsample averages away noise and compression artifacts. Resizing first also puts a generation of JPEG loss in front of the enhancement, and it throws away the room a customer needs to zoom in (see **Framing** below). Resizing tends to cost the colour profile too. A camera original carries one; most resize and export tooling drops it unless told not to, and a photo whose profile has gone missing is read as sRGB — which, for the Display P3 an iPhone actually produces, means every colour is slightly wrong with nothing reporting an error. Sending the original avoids the whole question. ### `print` | Field | Required | Values | |---|---|---| | `size` | yes | `"4x6"` — the only size today | | `finish` | yes | `"matte"` — the only finish today | | `zoom` | no | `1.0` (default) fits as much of the photo as the print allows; `2.0` is twice the magnification. | | `center` | no | `{"x": 0.5, "y": 0.5}` (default) — where the middle of the print sits on the photo. | #### Framing By default we **centre-crop**: we take the largest part of the photo that fits the print's shape, from the middle. If your app lets someone pinch and drag a photo, send us where they left it instead — otherwise that work is discarded and the print is our guess. Send `zoom`, `center`, or both: ```json { "size": "4x6", "finish": "matte", "zoom": 1.8, "center": { "x": 0.42, "y": 0.35 } } ``` - `zoom` is `1.0` or more. **`1.0` is the default centre crop** — the most of the photo the print's shape can hold — so sending `1.0` changes nothing. `2.0` halves how much of the photo you see in each direction. Below `1.0` is clamped to `1.0`: there is no more photograph to show, and the frame has to be filled. - **There is no upper limit, and that is not the same as anything going.** A 4x6 at 300dpi is 1800x1200, so zoom until the window is smaller than that and the print comes out soft — we upscale rather than refuse, because refusing an order at the printer helps nobody. On a 12MP phone photo the window starts at 4032px wide, so anything up to about **2.2x** is free; past that you are spending resolution. Work the limit out from the photo you actually have rather than picking a number. - `center` is where the middle of the print lands, as fractions of the image. `{"x": 0.5, "y": 0.5}` is the middle; `{"x": 0.0}` is hard against the left edge. If the requested centre would put the frame off the edge of the photo we slide it back on, so you can send a gesture's raw value without clamping it yourself. - Either may be sent without the other. Fractions rather than pixels throughout, so the framing still means the same thing after anyone resizes anything, on your side or ours. And no aspect ratio to compute: we derive the window from the print, so a second print size would not change your code. Three things to know: - **`center` is measured on the image as displayed**, after any EXIF orientation is applied — see **Orientation** below. A phone photo is usually stored sideways with a tag saying so; computing against the stored pixels puts the frame somewhere else entirely. This is the single most common way to get this wrong. - **We frame after enhancement, not before**, so the enhancement still sees the whole photograph. - **Framing cannot change the orientation.** The window always has the print's shape in the photo's own orientation — again, see below. Anything we cannot use — a zoom that is not a number, values outside `0`–`1` — falls back to the centre crop and the order still prints. We will not fail an item over framing. #### Orientation **The print's orientation is taken from the photo, and there is no field for it.** A portrait photo prints portrait; a landscape photo prints landscape. You do not send anything, and there is nothing to get wrong. **EXIF orientation is honoured.** We auto-orient before doing anything else, so a phone photo whose pixels are stored sideways with a tag saying so prints the right way up. It is the *displayed* shape that decides, not the stored one — a file that is 4032x3024 on disk with a rotation tag is a portrait photo to us, and prints portrait. That is the other reason to send the camera original rather than a re-encode: rotating a photo by rewriting its pixels, or stripping EXIF while doing something else, is how a picture arrives claiming to be a shape it is not. **Framing does not affect it.** `zoom` and `center` choose a region that already has the print's shape in the photo's own orientation, so no amount of zooming or panning turns a landscape print portrait. **Other unknown keys inside `print` are accepted and stored verbatim** — the one place in the API where that is true. This is the forward-compatibility hatch: when we add more print geometry (bleed, rotation) you will be able to send it without waiting for a new spec version, and anything we do not understand today is preserved rather than dropped. ### Postcards A postcard order is **exactly two items**: one `postcard_front` and one `postcard_back`, both ordinary images. You render the back yourself. We never compose text, addresses or layout — your customer chose the fonts and wording on your side, so you send us a finished image and we print it, byte for byte. That keeps both faces on the same path and means postcards need no special handling from you beyond the two-item shape. **Your `postcard_back` image must carry the delivery address.** We print it exactly as you render it and add nothing — so if the address is not on the image you sent, it is not on the card that goes in the post. The `shipping` block on the order is what we mail it to and what appears on the outside; it is never drawn onto your artwork. `postcard_back` defaults to `enhance.profile: "none"`, because running a rendered address card through photo enhancement makes it worse, not better. Override it explicitly if you genuinely want the back enhanced. You cannot mix postcards and prints in one order. Send two orders. --- ## Idempotency `order_ref` is unique per partner, which makes retrying a request that timed out safe. | You send | We do | |---|---| | Same `order_ref`, byte-identical intent | `200` with the original response. Nothing is queued twice. | | Same `order_ref`, different body | `409 order_ref_conflict`. Nothing changes. | Comparison is on a canonical form, so key order and whitespace do not matter — reserializing your payload will not trip a conflict. It does distinguish `1` from `1.0`, since those are genuinely different JSON values. We will never silently modify an order you already submitted. If you need to change one, submit a new order under a new `order_ref`; the conflict response tells you the `order_id` of the one that already exists: ```json { "error": { "code": "order_ref_conflict", "message": "order_ref 'DOC-1' was already submitted with a different body. Use a new order_ref.", "details": { "order_id": "01m2qt7q87qrm9axn9gjp3xf2h" } } } ``` The practical consequence: **retry aggressively on timeouts.** A retried POST is free. --- ## `GET /v1/orders/{order_id}` Everything we know about an order. This is how you find out what happened. ```json { "order_id": "01m2qt7q87qrm9axn9gjp3xf2h", "order_ref": "DOC-1", "order_type": "photo_print", "spec_version": "1.0", "state": "blocked", "priority": 100, "received_at": "2026-09-17T13:52:26.887181Z", "completed_at": "2026-09-17T13:52:27.003327Z", "updated_at": "2026-09-17T13:52:27.003363Z", "mail_state": "unmailed", "mailed_at": null, "items": [ { "item_id": "01m2qt7q879m7p7bqqbxzf27y3", "item_ref": "p1", "seq": 0, "role": "print", "state": "source_expired", "quantity": 2, "enhance_profile": "default", "source_filename": "IMG_9243.jpeg", "original_ext": "", "original_bytes": null, "original_sha256": "", "stored_at": null, "spec_json": { "size": "4x6", "finish": "matte", "bleed_in": 0.125 }, "attempts": 1, "last_error_code": "source_expired", "last_error": "Source returned 403; the URL is no longer valid.", "processed_at": null, "artifacts": [], "created_at": "2026-09-17T13:52:26.887619Z", "updated_at": "2026-09-17T13:52:26.998590Z" } ] } ``` `mail_state` is `unmailed` or `mailed`, and `mailed_at` is the timestamp when it flipped. **This is a separate axis from `state`, and it is the one to watch if you want to tell a customer their prints have shipped.** `complete` means every image has been processed and stored — it is true well before anything is printed, and often days before anything is posted. An order can sit at `complete` / `unmailed` for some time; that is normal and not a fault. `spec_json` is your `print` block as submitted, including any keys we do not yet interpret. `original_key` is our internal storage reference — useful to quote in a support conversation, but not a URL you can fetch. Another partner's order returns `404`, not `403`. There is no polling rate limit today, but once a minute is plenty; nothing here moves faster than the download takes. ### Order states | State | Meaning | Terminal | |---|---|---| | `received` | Accepted, nothing downloaded yet | | | `fetching` | Downloads in progress | | | `ready` | Every image stored; queued for printing | | | `processing` | Partly through production | | | `complete` | Done | yes | | `partial` | Some items succeeded, the rest are dead | yes | | `blocked` | **Source URLs expired. Resubmit under a new `order_ref`.** | yes | | `failed` | Dead for other reasons | yes | Note what `complete` does **not** mean: it is not "printed" and not "posted". Those live on `mail_state`, above, which moves independently and after it. ### Mail states | State | Meaning | |---|---| | `unmailed` | Not yet in the post. The default, including while `complete`. | | `mailed` | Handed to the carrier; `mailed_at` says when. | There is no tracking number — nothing in this system knows a carrier. Poll for this like everything else; the one thing we will push to you is described under [Callbacks](#callbacks), and mailing is not it. ### Item states ``` pending_fetch ──▶ fetching ──▶ stored ──▶ processed │ ├──▶ fetch_failed (up to 3 attempts in total, then terminal) └──▶ source_expired TERMINAL — never retried ``` `source_expired` means the URL answered `401`, `403`, `404` or `410`. That is a dead URL rather than a flaky one, so retrying would only waste time; we stop immediately. --- ## `GET /v1/me` Confirms a key works and reports your account limits. Useful as a deploy-time smoke test. ```json { "partner": { "uid": "295c40bf-a692-4f37-89c8-bc425c8b2cdb", "slug": "acme", "name": "Acme Photos", "max_items_per_order": 200, "max_source_bytes": 104857600 }, "key": { "last4": "SKqE", "label": "production", "expires_at": null }, "callbacks": { "enabled": false, "url": "" } } ``` `callbacks.enabled` is how you confirm we have actually switched your endpoint on — both the URL and the signing secret are set by hand at our end, so "I sent you a URL" and "it is live" are different states. See [Callbacks](#callbacks). The signing secret is never returned by any endpoint. --- ## Callbacks **Opt-in, and off unless you asked.** Almost everything in this API is polled — you call `GET /v1/orders/{order_id}` whenever you like and we never call you. There is one exception. ### `item.proof` — photographs of a posted postcard If you send postcards, we photograph each finished card on a copy stand before it goes in the envelope: the front, and the written back with the address on it. Those two photographs are the only thing in this system you cannot usefully poll for, because by the time you thought to ask, the card is in a postbox. So we push them. To turn it on, send us an `https://` endpoint. We will send you a **signing secret** in return. Both of those come from us by hand — there is no self-serve dashboard. ### What arrives `POST` to your URL, `Content-Type: application/json`: ```http POST /your/hook HTTP/1.1 X-Fourbysix-Event: item.proof X-Fourbysix-Signature: t=1758470400,v1=5f2c… Content-Type: application/json ``` ```json { "event": "item.proof", "event_id": "01k5rj8v4h0000000000000000", "created_at": "2026-09-21T16:20:00+00:00", "order_id": "01k4t8z9m3q7r2v6w1x5y8a0b2", "order_ref": "YOUR-REF-1", "item_id": "01k4t8z9m4b1c2d3e4f5g6h7j8", "item_ref": "your-item-ref", "proof": { "front": { "url": "https://…signed…", "expires_at": "2026-09-22T16:20:00+00:00", "bytes": 4821004, "sha256": "9f86d0…", "content_type": "image/jpeg" }, "back": { "url": "https://…signed…", "…": "…" } } } ``` `order_ref` and `item_ref` are **yours** — the values you sent us — so you do not need a mapping table. The links are signed and time-limited; **download the bytes, do not store the URL.** `expires_at` tells you how long you have, and it is measured in hours rather than minutes precisely so the payload can sit in your own queue. The payload carries nothing else on purpose. No order state, no shipping, no other items. `GET /v1/orders/{order_id}` is still where you learn everything else, and a callback that grew those fields would quietly become a status feed we have not promised to keep accurate. ### Verifying the signature **Do this.** The URL is the only thing an attacker needs to post you a convincing fake, and the photographs are of a real person's address. `X-Fourbysix-Signature` is `t=,v1=`, where `v1` is `HMAC-SHA256(secret, ".")`. Verify against the **raw bytes** you received, before any JSON parsing — re-serializing the body changes it. ```python import hashlib, hmac, time def verify(secret: str, body: bytes, header: str, tolerance: int = 300) -> bool: parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p) try: timestamp = int(parts["t"]) except (KeyError, ValueError): return False if abs(time.time() - timestamp) > tolerance: # replay window return False expected = hmac.new( secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256 ).hexdigest() return hmac.compare_digest(expected, parts.get("v1", "")) ``` The timestamp is inside the signed string, not merely beside it, so rejecting an old one actually means something. ### Delivery | | | |---|---| | Success | Any `2xx`. Respond quickly; we do not read your body. | | Retries | `5xx`, `429` and connection failures are retried with exponential backoff — twelve attempts spread over about three and a half hours, starting 10 seconds after the first failure. | | Given up | Other `4xx` are not retried — they mean the request was wrong, and sending it again would loop. | | `410 Gone` | Stops retries immediately. Return this if you have decommissioned the endpoint. | | Redirects | **Not followed.** A `3xx` is a failed delivery. Give us the final URL. | | Ordering | None. Do not assume it. | | At-least-once | You may get the same `event_id` twice. Dedupe on it. | Two things that will not happen, so you can design around them: - **A failed callback never changes your order.** If your endpoint is down for a day, the cards still print, still post, and the order still reaches `complete`. You have only missed a notification. - **We will never call you to ask for anything**, in particular not for a fresh source URL. See the top of this document. If you do miss one, `GET /v1/orders/{order_id}` will show `proof_front` and `proof_back` in that item's `artifacts` — so you can always tell *whether* a card was photographed. What polling cannot give you is the bytes: `r2_key` is an internal reference, not a URL, and the signed links exist only inside a callback. Ask us and we will re-send it. ### Re-shooting If a photograph was bad we re-take it, and you get a **second callback for the same `item_ref`** with a new `event_id`. The later one wins. This is not an error and it is not a duplicate — treat the most recent `created_at` for an item as current. ### Rotating the secret Ask and we will issue a new one. **There is no overlap window**: the old secret stops working the moment the new one is issued. Callbacks signed during the swap fail your check, get retried, and land once you have deployed the new value — which is survivable rather than elegant, and is the reason the retry window is hours rather than minutes. ## Errors Every error has the same three fields, on every endpoint: ```json { "error": { "code": "…", "message": "…", "details": {} } } ``` Branch on `code`. Treat `message` as human-readable only — we may reword it. | Code | Status | Meaning | |---|---|---| | `invalid_request` | 400 | Validation failed. `details` locates the problem. | | `unauthorized` | 401 | Missing, unknown, revoked or expired key | | `partner_suspended` | 403 | Valid key, inactive account | | `not_found` | 404 | No such order for this account | | `order_ref_conflict` | 409 | `order_ref` reused with a different body | For `invalid_request`, `details` mirrors the shape of your request. Order-level problems map a field name to a list of messages: ```json { "error": { "code": "invalid_request", "message": "The request body failed validation.", "details": { "spec_version": ["Unsupported spec_version '2.0'; this API speaks 1.x."] } } } ``` Item-level problems are keyed by the item's **index** in your `items` array: ```json { "error": { "code": "invalid_request", "message": "The request body failed validation.", "details": { "items": { "0": { "print": { "size": ["Unsupported size '5x7'."], "finish": ["Unsupported finish 'velvet'."] } } } } } } ``` Note that `details.items` is an **object keyed by index** when individual items are at fault, and a **list of strings** when the problem is with the collection as a whole ("A postcard order needs exactly one postcard_front and one postcard_back item."). Handle both. ### Per-item failures are not request errors If an image fails to download, the request already succeeded. The failure shows up on the item as `state` and `last_error_code`: | `last_error_code` | Meaning | Retried? | |---|---|---| | `source_expired` | URL returned 401/403/404/410 | no — terminal | | `upstream_error` | 5xx or timeout from your storage | yes, 3 attempts total | | `rate_limited` | 429 from your storage (we honour `Retry-After`) | yes, 3 attempts total | | `bad_source_response` | Another 4xx — retrying cannot help | no | | `unsupported_type` | The bytes are not a supported image | no | | `source_too_large` | Over your per-file limit (see `GET /v1/me`) | no | | `sha256_mismatch` | Did not match the `source.sha256` you declared | yes, 3 attempts total | --- ## Building a correct integration 1. **Mint presigned URLs at send time**, with a TTL of 15 minutes or more. 2. **POST the order.** Expect `202`. 3. **Retry on timeouts and 5xx** with the same `order_ref`. Idempotency makes this free; not retrying loses orders. 4. **Treat `409` as a bug in your code**, not a transient condition — it means you reused an `order_ref` for different content. 5. **Store our `order_id`** against your own record. 6. **Poll `status_url`** until the order reaches a terminal state. 7. **Alert on `blocked`.** It almost always means your URLs are expiring before we fetch them, and every affected order needs resubmitting. 8. Do not send a `429`-worthy burst; there is no enforced rate limit today, but that may change. ## Mistakes worth avoiding - **Minting URLs too early.** The single most common cause of `blocked` orders. - **Assuming `202` means the images arrived.** It means the order is recorded. - **Not retrying a timed-out POST.** The order may well have been created; retrying tells you, and costs nothing. - **Reusing an `order_ref` across a content change.** You get `409` and no order. - **Sending a camelCase field name.** Unknown keys are rejected, by design. - **Mixing postcards and prints in one order.** Send two. - **Expecting a callback for order status.** There is exactly one callback and it is about postcard proof photographs ([Callbacks](#callbacks)). Everything else is polled, including `complete`, `blocked` and `mailed`. - **Treating a callback as the record.** It is a notification. If you did not get one, ask; if you got one twice, dedupe on `event_id`. ## Rules the JSON Schema cannot express [`order.schema.json`](order.schema.json) covers almost everything, and both example files validate against it. Two rules it cannot state, which we still enforce: - `item_ref` must be unique within an order. - `items` is capped at your account's `max_items_per_order`, not a fixed 200. --- ## Code ### Python ```python import requests BASE = "https://api.fourbysix.co" SESSION = requests.Session() SESSION.headers["Authorization"] = f"Bearer {API_KEY}" def submit(order_ref, image_urls): body = { "spec_version": "1.0", "order_ref": order_ref, "order_type": "photo_print", "items": [ { "item_ref": f"p{i}", "role": "print", # Presign here, not earlier — the URL has to outlive only this request. "source": {"url": url}, "print": {"size": "4x6", "finish": "matte"}, } for i, url in enumerate(image_urls) ], } response = SESSION.post(f"{BASE}/v1/orders", json=body, timeout=30) if response.status_code == 409: raise ValueError(f"order_ref {order_ref} already used for different content") response.raise_for_status() return response.json()["order_id"] # 200 and 202 are both success def check(order_id): order = SESSION.get(f"{BASE}/v1/orders/{order_id}", timeout=30).json() if order["state"] == "blocked": expired = [i["item_ref"] for i in order["items"] if i["last_error_code"] == "source_expired"] raise RuntimeError(f"source URLs expired for {expired}; resubmit with a new order_ref") return order["state"] ``` Retrying on a timeout is just calling `submit` again with the same `order_ref`. ### TypeScript ```ts const BASE = "https://api.fourbysix.co"; async function submit(orderRef: string, imageUrls: string[]): Promise { const response = await fetch(`${BASE}/v1/orders`, { method: "POST", headers: { Authorization: `Bearer ${process.env.FOURBYSIX_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ spec_version: "1.0", order_ref: orderRef, order_type: "photo_print", items: imageUrls.map((url, i) => ({ item_ref: `p${i}`, role: "print", source: { url }, print: { size: "4x6", finish: "matte" }, })), }), }); const body = await response.json(); if (!response.ok) { // 409 means this order_ref was used for different content — a bug, not a retry. throw new Error(`${body.error.code}: ${body.error.message}`); } return body.order_id; // 200 (replay) and 202 (new) are both success } ``` --- ## Getting set up Email **hello@fourbysix.co**. Tell us the account name you want and whether you need limits above the defaults (200 items per order, 100 MB per image), and we will send back a key and confirm your account slug. Keys are issued by a person. There is no self-service signup and no dashboard.