FOURBYSIX

Four by Six — Partner API

This page is generated from partner-api.md, which you can read or fetch as plain markdown. The machine-readable contract is order.schema.json, and there is an index for language models at /llms.txt.

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.


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

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" }
      }
    ]
  }'
{
  "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:

{
  "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:

{
  "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.

{
  "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, 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.

{
  "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. 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:

POST /your/hook HTTP/1.1
X-Fourbysix-Event: item.proof
X-Fourbysix-Signature: t=1758470400,v1=5f2c…
Content-Type: application/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=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256(secret, "<t>.<raw body>"). Verify against the raw bytes you received, before any JSON parsing — re-serializing the body changes it.

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:

{ "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:

{ "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:

{ "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). 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 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

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

const BASE = "https://api.fourbysix.co";

async function submit(orderRef: string, imageUrls: string[]): Promise<string> {
  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 [email protected]. 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.