FramesOnDemand API
Turn our shop floor into an endpoint. Send a paid order as JSON, hand us a print file, and we produce and ship the framed piece under your brand. This is the full reference for the REST API.
Introduction
The FramesOnDemand API is a REST interface for custom-framed print fulfillment. You send us an order and a print file. We print it, frame it, and ship it from our shop in New Jersey. Nothing in the box points back to us, so the package arrives under your brand. This is blind, white-label fulfillment, run as an API.
The flow is simple. You post an order, we produce it, and we call your endpoint back with the tracking number when the label prints. You never manage carriers, labels, or production yourself.
Who this is for
- Platforms. Storefront builders, headless carts, and marketplaces that want to offer real framed art without running a frame shop.
- Institutions. Museum gift shops, galleries, and university stores that connect their own storefront to our endpoints and ship finished, gallery-grade pieces to buyers.
- Direct sellers. Sellers on a custom stack who want framed prints in their own checkout, from Magento to a bespoke internal tool.
Access is by requestThe API is scoped to partners and institutions. Request credentials at partners@framesondemand.app and our team issues a key for your account. This is standard B2B access control for a fulfillment API.
Base URL and versioning
All API requests go to a single host over HTTPS. The version is part of the path, so a new version never breaks a running integration. Every path in this reference is relative to the base URL below.
The current version is v1. A request to create an order is a POST to https://api.framesondemand.app/v1/orders. When we ship a new major version, it lives under a new prefix such as /v2, and /v1 keeps working. All requests and responses use JSON with UTF-8 encoding.
Authentication
The API uses bearer tokens. You get an API key for your account when access is granted. Send it in the Authorization header on every request. Keep the key on your server. Never ship it in a browser, a mobile app, or a public repo.
# every request carries your key in the Authorization header curl https://api.framesondemand.app/v1/orders \ -H "Authorization: Bearer $FOD_API_KEY" \ -H "Content-Type: application/json"
A missing or bad key returns 401 Unauthorized. Keys are scoped to one account, so orders you create are only visible to you. If a key is ever exposed, email us and we roll it for a new one.
Orders: create an order
Create an order to send us a piece to produce and ship. You pass the buyer ship-to address and one or more line items. Each line item picks a size and a frame and points at a print file. We validate the file, produce the piece, and ship it.
Paid orders onlySend us orders the buyer has already paid for. We produce paid work. This keeps you from being billed for a cart that was never checked out or a sale that was canceled.
Request body
| Field | Type | Description |
|---|---|---|
external_id required | string | Your own order id. We echo it back and use it to keep requests idempotent. |
ship_to required | object | The buyer address. Holds name, address1, address2, city, state, zip, and country. |
items required | array | One or more line items. Each maps to a size, a frame, an optional mat, and a print file. |
items[].print_file_id | string | The id of a print file you uploaded first. See Print files. |
items[].print_file_url | string | A signed URL to the source file, used in place of print_file_id. |
items[].width_in required | number | Finished print width in inches. |
items[].height_in required | number | Finished print height in inches. |
items[].frame | string | Frame slug from the catalog, such as museum-black. Leave it out for an unframed print. |
items[].mat | string | Mat slug, such as antique-white. Optional. |
items[].quantity | integer | How many of this item to make. Defaults to 1. |
callback_url | string | Your endpoint. We POST tracking to it when the label prints. |
Example request
# create a framed print order curl -X POST https://api.framesondemand.app/v1/orders \ -H "Authorization: Bearer $FOD_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "external_id": "order-10482", "ship_to": { "name": "A. Buyer", "address1": "12 Main St", "city": "Austin", "state": "TX", "zip": "78701", "country": "US" }, "items": [{ "print_file_id": "pf_9m2k1a7", "width_in": 18, "height_in": 24, "frame": "museum-black", "mat": "antique-white", "quantity": 1 }], "callback_url": "https://you.com/fod/hook" }'
Example response
// 201 Created { "id": "ord_7Qf3xB2k", "external_id": "order-10482", "status": "received", "created_at": "2026-07-09T14:22:05Z", "items": [{ "id": "itm_1a2b3c", "width_in": 18, "height_in": 24, "frame": "museum-black", "mat": "antique-white", "quantity": 1 }], "tracking": null }
The id is our order id. Save it. You can poll it for status or wait for the tracking callback. Because external_id is idempotent, sending the same one twice returns the first order instead of making a duplicate.
Order status
Read an order at any time to see where it is in production and shipping. You can look it up by our order id or by your own external_id.
# read one order curl https://api.framesondemand.app/v1/orders/ord_7Qf3xB2k \ -H "Authorization: Bearer $FOD_API_KEY"
// 200 OK { "id": "ord_7Qf3xB2k", "external_id": "order-10482", "status": "shipped", "tracking": { "carrier": "usps", "number": "9400100000000000000000", "url": "https://tools.usps.com/go/TrackConfirmAction?tLabels=9400100000000000000000" }, "shipped_at": "2026-07-12T17:04:00Z" }
Status values
| Status | Meaning |
|---|---|
received | We have the order and are checking it. |
validating | We are checking the print file resolution and mapping the line items to real catalog parts. |
in_production | The piece is on our shop floor being printed and framed. |
shipped | The label printed and the piece is with the carrier. Tracking is set. |
delivered | The carrier marked the package delivered. |
on_hold | We paused the order and need input, such as a better file. See the hold_reason field. |
canceled | The order was canceled before it shipped. |
Tracking and webhooks
You do not have to poll. When a label prints, we POST a webhook to the callback_url you set on the order. Your system reads it and sends the shipping email to your buyer, or writes tracking back to your own marketplace. This is the write-back that closes the loop.
Webhook payload
// POST to your callback_url { "event": "order.shipped", "order_id": "ord_7Qf3xB2k", "external_id": "order-10482", "status": "shipped", "tracking": { "carrier": "usps", "number": "9400100000000000000000", "url": "https://tools.usps.com/go/TrackConfirmAction?tLabels=9400100000000000000000" }, "shipped_at": "2026-07-12T17:04:00Z" }
Verify the signature
Every webhook carries a FOD-Signature header. It is an HMAC of the raw request body, signed with your webhook secret. Recompute the HMAC on your side and compare it to the header before you trust the payload. Reject the request if they do not match.
# header on every webhook FOD-Signature: t=1752338640,v1=6a3f0c...b2 # verify (Node) const expected = crypto .createHmac("sha256", process.env.FOD_WEBHOOK_SECRET) .update(rawBody) .digest("hex"); if (!crypto.timingSafeEqual(sig, expected)) return reject();
Respond with a 2xx status to confirm you got the webhook. If your endpoint is down or returns an error, we retry with backoff for up to 24 hours, so build your handler to be idempotent on order_id.
Print files
A print file is the high-resolution source art for a piece. Large files never ride through your own servers. Instead you ask us for a signed upload URL, PUT the file straight to storage, and reference the file id on your order line item.
Get a signed upload URL
// 201 Created { "id": "pf_9m2k1a7", "upload_url": "https://uploads.framesondemand.app/pf_9m2k1a7?sig=...", "expires_at": "2026-07-09T15:22:05Z" }
PUT your file to upload_url before it expires, then pass the id as print_file_id on the order. We accept TIFF, PNG, and JPEG. TIFF is best for framed art.
Resolution requirements
- Aim for 300 DPI at the finished print size. A 18 by 24 inch print wants roughly 5400 by 7200 pixels.
- We accept down to 150 DPI at size and flag anything lower so you can swap in a better file.
- Use the full color source. Avoid upscaled or heavily compressed art.
Faithful render, no guessingWe render your file as sent and we never blind-fulfill an item we cannot map. If a line item points at a size or a frame that is not in the catalog, or the file fails validation, the order moves to on_hold and we tell you why instead of shipping a wrong piece.
Products and catalog
Line items map to real parts in our catalog. That catalog holds more than 40 mouldings, 46 mats, and custom sizing, all cut and joined in our own shop. You reference a frame or mat by its slug and a size by width and height in inches.
// 200 OK { "frames": [ { "slug": "museum-black", "name": "Museum Black", "width_in": 0.875 }, { "slug": "antique-gold", "name": "Antique Gold", "width_in": 1.25 } ], "mats": [ { "slug": "antique-white", "name": "Antique White" }, { "slug": "black", "name": "Black" } ], "sizes": { "min_in": 4, "max_in": 40, "custom": true } }
Sizing is custom to the inch inside the supported range, so you are not stuck with a handful of stock sizes. Pull the catalog once at build time and cache it, then refresh it when you want the newest frames and mats.
Errors
The API uses standard HTTP status codes. A 2xx means success. A 4xx means the request had a problem you can fix. A 5xx means something failed on our side. Every error returns the same JSON shape.
// 422 Unprocessable Entity { "error": { "type": "validation_error", "message": "print file resolution is below the minimum for this size", "field": "items[0].print_file_id" } }
| Status | Type | When it happens |
|---|---|---|
400 | bad_request | The JSON was malformed or a required field is missing. |
401 | unauthorized | The API key is missing or wrong. |
403 | forbidden | The key is valid but not allowed to do this. |
404 | not_found | No order or resource matches that id. |
409 | conflict | An order with that external_id already exists. |
422 | validation_error | A field failed a rule, such as a file below the resolution minimum. |
429 | rate_limited | Too many requests. Slow down and retry. |
500 | server_error | Something failed on our side. Retry the request. |
Rate limits
Each API key can make up to 120 requests per minute. Order creation has its own steadier limit so a large batch stays smooth. Every response carries headers that tell you where you stand.
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Requests allowed in the current window. |
X-RateLimit-Remaining | Requests you have left in this window. |
X-RateLimit-Reset | Unix time when the window resets. |
Retry-After | Seconds to wait, sent with a 429. |
If you hit the limit you get a 429. Back off for the number of seconds in Retry-After and try again. If you run high-volume batches, tell us and we raise the limit for your account.
Support and access
To get credentials, email partners@framesondemand.app and tell us what you are building. We issue an API key and a webhook secret for your account and share the sandbox details so you can test before you go live.
White-glove for institutions
Museums, galleries, universities, and larger catalogs do not have to self-serve. Our team scopes the integration with you, maps your catalog to our frames and mats, and stands up the connection alongside your developers. You get museum-grade materials and one shop accountable for every piece. Read more on the institutions page.