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.
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
- Mint presigned URLs at send time, with a TTL of 15 minutes or more.
- POST the order. Expect
202.
- Retry on timeouts and 5xx with the same
order_ref. Idempotency makes this
free; not retrying loses orders.
- Treat
409 as a bug in your code, not a transient condition — it means you
reused an order_ref for different content.
- Store our
order_id against your own record.
- Poll
status_url until the order reaches a terminal state.
- Alert on
blocked. It almost always means your URLs are expiring before we
fetch them, and every affected order needs resubmitting.
- 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.