BlotPrints API
All our products can be ordered through our API. Made for developers and for AI agents.
Introduction
The base URL is:
https://www.blotprints.com/api/v1
- Requests and responses are JSON (
Content-Type: application/json). - No API key is needed. Calls are rate limited per IP address (see Rate limits).
- Dates and times are in ISO 8601, in UTC, like
2026-10-25T12:00:00Z. - There’s no test mode: a quote is free and changes nothing, and an order only becomes real once it’s paid.
Example order
Ordering a hardcover book from PDFs that are online.
1. Get a quote
curl -X POST https://www.blotprints.com/api/v1/quotes \
-H "Content-Type: application/json" \
-d '{
"items": [{
"product": "hardcover",
"size": "a5-portrait",
"options": { "cover": "matte", "paper": "matte_standard" },
"quantity": 1,
"files": {
"interior": { "url": "https://example.com/book-interior.pdf" },
"cover": { "url": "https://example.com/book-cover.pdf" }
}
}],
"shipping": { "country": "NL", "method": "standard" }
}'
2. Place the order, with the quoteId from the response. If the quote has warnings, add "acceptWarnings": true (see 3. Get a quote).
curl -X POST https://www.blotprints.com/api/v1/orders \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 5f1c2d3e-my-unique-key" \
-d '{
"quoteId": "quo_8hQ2kV7xLm3pR9tY",
"acceptTerms": true,
"email": "[email protected]",
"shippingAddress": {
"name": "Jane Doe",
"line1": "Keizersgracht 123",
"postalCode": "1015 CJ",
"city": "Amsterdam",
"country": "NL"
}
}'
3. Complete payment: open the paymentUrl from the response in a browser.
Placing an order
1. Choose a product
The products tell you what you can order: per product its sizes, options and prices, and the files we need. Use the names from here (product, size, the option values) in your quote.
All products
GET https://www.blotprints.com/api/v1/products
Everything you can order, with its sizes, options and prices.
{
"products": [
{
"product": "hardcover",
"name": "Hardcover book",
"sizes": [
{
"size": "a5-portrait",
"width": 148,
"height": 210,
"pages": { "min": 24, "max": 400, "step": 2 },
"files": {
"interior": { "fileTypes": ["pdf"], "width": 154, "height": 216, "bleed": 3 },
"cover": { "fileTypes": ["pdf", "jpg", "png"], "width": 354, "height": 255, "bleed": 19 }
},
"price": { "base": 15.99, "perExtraPage": 0.40 },
"options": {
"cover": {
"values": [
{ "value": "matte", "name": "Hardcover matte", "price": 5.99 },
{ "value": "glossy", "name": "Hardcover glossy", "price": 0 }
],
"default": "matte"
},
"paper": {
"values": [
{ "value": "matte_standard", "name": "Standard matte", "price": 0, "perExtraPage": 0 },
{ "value": "matte_heavy", "name": "Heavy matte", "price": 1.44, "perExtraPage": 0.07 }
],
"default": "matte_standard"
}
}
},
{
"size": "a5-landscape",
"width": 210,
"height": 148,
"pages": { "min": 24, "max": 400, "step": 2 },
"files": {
"interior": { "fileTypes": ["pdf"], "width": 216, "height": 154, "bleed": 3 },
"cover": { "fileTypes": ["pdf", "jpg", "png"], "width": 470, "height": 193, "bleed": 19 }
},
"price": { "base": 15.99, "perExtraPage": 0.40 },
"options": {
"cover": {
"values": [
{ "value": "matte", "name": "Hardcover matte", "price": 5.99 },
{ "value": "glossy", "name": "Hardcover glossy", "price": 0 }
],
"default": "matte"
},
"paper": {
"values": [
{ "value": "matte_standard", "name": "Standard matte", "price": 0, "perExtraPage": 0 },
{ "value": "matte_heavy", "name": "Heavy matte", "price": 1.44, "perExtraPage": 0.07 }
],
"default": "matte_standard"
}
}
}
]
}
]
}
| Field | Description |
|---|---|
product |
The name to order with, like hardcover. |
name |
The product’s name, to show to a person. |
sizes[].size |
The name to order with. Portrait and landscape are separate sizes: a5-portrait and a5-landscape, 30x40 and 40x30. A square size has one, like 21x21. |
sizes[].width |
The finished width, in millimetres. |
sizes[].height |
The finished height, in millimetres. |
sizes[].pages |
Only for products with pages (books, magazines, calendars): the fewest and most pages, and the step (2 means an even number). |
sizes[].files |
The files to send, by name, with their types and their size in millimetres including the bleed. A book has an interior and usually a cover, a poster or panel has one design. |
sizes[].price.base |
The price of the size, without options. For products with pages, with the fewest pages. |
sizes[].price.perExtraPage |
Only for products with pages: what each page above the fewest adds. |
sizes[].options |
The options of the size (like cover, paper or hanging): its values, and which one is the default. Sizes can differ: an A0 poster has no frame, and prices depend on the size. |
sizes[].options.{option}.values[].value |
The value to order with, like matte. |
sizes[].options.{option}.values[].name |
The value’s name, to show to a person. |
sizes[].options.{option}.values[].price |
What the value adds to the price; 0 when it’s included. |
sizes[].options.{option}.values[].perExtraPage |
Only for values that cost more per page (a book’s paper): what they add for each page above the fewest. |
One product
GET https://www.blotprints.com/api/v1/products/{product}
The same as one product in the list.
2. Add your files
Your files are what we print: the design of a poster or panel, or a book’s pages and cover, as PDF, JPG or PNG. Which files a size needs is in its files (see 1. Choose a product). You can send each file in two ways: as a link we download it from, or uploaded to us.
Download link
Give a URL we can download, with https. Handy for files that are already online.
{ "url": "https://example.com/book-interior.pdf" }
The link has to work, without logging in, when you get the quote: that’s when we download the file. Links that expire (like presigned storage links) are fine.
Upload a file
For files on your computer (or an agent’s), ask for an upload address and send the file there.
POST https://www.blotprints.com/api/v1/uploads
No body is needed. Response:
{
"uploadId": "upl_3vN8qR2xKt5mW7yZ",
"uploadUrl": "https://…",
"expiresAt": "2026-10-25T14:30:00Z"
}
| Field | Description |
|---|---|
uploadId |
The upload, to use in your quote once the file is sent. |
uploadUrl |
Where to send the file, with a PUT. |
expiresAt |
Until when uploadUrl works (an hour). |
Then send the file:
curl -T book-interior.pdf "<uploadUrl>"
And use the upload in your quote:
{ "uploadId": "upl_3vN8qR2xKt5mW7yZ" }
- File types: PDF, JPG and PNG, as the product allows. We go by what’s in the file, not its name or extension: anything else is refused (
unsupported_file_type). - Up to 200 MB per file.
- An upload we refuse (another type, or too large) is deleted right away: upload the right file again.
- An upload address works for an hour; an upload is kept for a day.
3. Get a quote
A quote is the step before an order: you send what you want to print, with the files, and we check the files and tell you what it costs, including shipping. Nothing is ordered or paid yet. If it looks good, you place the order with the quote.
POST https://www.blotprints.com/api/v1/quotes
{
"items": [
{
"product": "poster",
"size": "a3-landscape",
"options": { "paper": "matte", "hanging": "photo_frame_black" },
"quantity": 2,
"files": { "design": { "uploadId": "upl_3vN8qR2xKt5mW7yZ" } }
}
],
"shipping": { "country": "NL", "method": "standard" }
}
| Field | Description |
|---|---|
items |
Required. What to print: one or more items, all in one order. |
items[].product |
Required. The product, like hardcover (see 1. Choose a product). |
items[].size |
Required. The size, like a5-portrait or 40x30. |
items[].options |
Optional. Per option of the product a value, like { "cover": "matte" }. An option you leave out gets its default. |
items[].quantity |
Optional. 1 to 50, the default is 1. |
items[].files |
Required. The files, by name, like interior and cover, or design: the ones in files of the size in the product. |
items[].files.{name}.url |
A link to the file. Give this or uploadId: see 2. Add your files. |
items[].files.{name}.uploadId |
An upload of the file. Give this or url: see 2. Add your files. |
shipping |
Required. Where and how to ship. |
shipping.country |
Required. Any EU country, as its two-letter code, like NL, DE or IT. |
shipping.method |
Optional. standard (the default) or express. Express isn’t available for every product: then the quote ships standard, and says so in shipping.note. |
Response:
{
"quoteId": "quo_8hQ2kV7xLm3pR9tY",
"expiresAt": "2026-10-26T12:00:00Z",
"previewUrl": "https://www.blotprints.com/preview/quo_8hQ2kV7xLm3pR9tY",
"items": [
{
"product": "poster",
"size": "a3-landscape",
"options": { "paper": "matte", "hanging": "photo_frame_black" },
"quantity": 2,
"unitPrice": 25.98,
"price": 51.96,
"vat": 9.02,
"warnings": [
{
"code": "design_adjusted",
"file": "design",
"message": "The design is a different size or shape than the size needs. We zoom it in to fit, so 9.1% is cut off at the edges. Check the preview.",
"values": { "cutOffPercent": 9.1 }
}
],
"previews": [
{
"file": "design",
"url": "https://www.blotprints.com/api/v1/quotes/quo_8hQ2kV7xLm3pR9tY/previews/0-design.jpg",
"width": 1200,
"height": 853,
"description": "The design as it's printed. The red line is where it's cut."
}
]
}
],
"shipping": {
"method": "standard",
"price": 5.00,
"vat": 0.87,
"estimatedDelivery": { "from": "2026-10-28", "to": "2026-10-29" },
"note": null
},
"shippingOptions": [
{
"method": "standard",
"price": 5.00,
"vat": 0.87,
"estimatedDelivery": { "from": "2026-10-28", "to": "2026-10-29" }
},
{
"method": "express",
"price": 12.50,
"vat": 2.17,
"estimatedDelivery": { "from": "2026-10-27", "to": "2026-10-27" }
}
],
"total": 56.96,
"vat": 9.89,
"currency": "EUR",
"termsUrl": "https://www.blotprints.com/terms"
}
| Field | Description |
|---|---|
quoteId |
The quote, to place the order with. |
expiresAt |
Until when the quote can be ordered (24 hours). |
previewUrl |
A page that shows the files as we print them, the same preview as on our website: where it’s cut, the spine of a book, every page. Show it to the person before ordering. It’s in their language, and only whoever has the link can see it. |
items[].pageCount |
Only for products with pages: the pages we counted in your file, which the price is based on. |
items[].unitPrice |
The price of one piece, with its options. |
items[].price |
The price of all pieces of the item. |
items[].vat |
The VAT included in the item’s price. |
items[].warnings |
Things we’ll fix while printing, that you should know about, like a design that’s a different shape and gets cut off a bit. Empty when there are none. To order anyway, set acceptWarnings when you place the order. |
items[].warnings[].code |
What kind of warning, like design_adjusted, pages_adjusted or cover_adjusted (a file of another size, zoomed in to fit), or low_resolution (an image that may print blurry). There are more: show the message to the person. |
items[].warnings[].file |
Which file of the item it’s about. |
items[].warnings[].message |
What’s going on, to show to a person. |
items[].warnings[].values |
The numbers in the message, so you can say it your own way. cutOffPercent on design_adjusted, pages_adjusted, cover_adjusted and scaled: how much is cut off (for pages, the most of any page), with difference: what differs (landscape or portrait: it lies the other way; wider or taller: another shape; smaller or larger: another size, then also fileSize and printSize, like "210 × 297 mm"). dpi and minimumDpi on low_resolution: what the image prints at, and from what it’s sharp. Other warnings can have others, like pageCount. |
items[].previews |
A picture of each file as we print it: a JPG of 1200 pixels on its long side, with the red line where it’s cut. For a book: the whole cover with its spine, and pages 2 and 3 of the interior (the previewUrl shows every page). Empty when we couldn’t make them; the quote works without. |
items[].previews[].file |
Which file of the item it shows. |
items[].previews[].url |
The picture. Only whoever has the link can see it. |
items[].previews[].description |
What the picture shows, and what its lines mean. |
shipping.method |
How it ships: standard or express. Standard when you asked for express and that isn’t available (see note). |
shipping.price |
The shipping costs. Standard shipping is free (0) when the items together cost €50 or more; express keeps its price. |
shipping.vat |
The VAT included in the shipping costs. |
shipping.estimatedDelivery |
When it should arrive, as a range of dates. Not guaranteed. |
shipping.note |
Why it ships another way than you asked, to show to the person: express isn’t available for this order, so it ships standard. Otherwise null. |
shippingOptions |
Every way this order can ship to this country, with its method, price, vat and estimatedDelivery, the one of this quote too. To let the person choose: show these, and get a new quote with the shipping.method they pick. total and vat are for the quote’s own shipping. |
total |
Everything together: the items and shipping. |
vat |
The VAT included in the total. |
termsUrl |
Our terms, which apply to the order. Read them before you accept them when you place the order. |
A file we can’t print (wrong type, too few pages, can’t be read) is an error: the quote fails with 422 and lists every problem of every file in problems, so you can fix them all at once. See Errors.
A quote can be ordered once. For another order, get a new quote.
4. Place the order
An order is a quote you’ve decided to buy, with the address to ship it to. You get a payment link: once it’s paid, we print and ship it, and you can follow its status until it arrives.
POST https://www.blotprints.com/api/v1/orders
{
"quoteId": "quo_8hQ2kV7xLm3pR9tY",
"acceptTerms": true,
"acceptWarnings": true,
"reference": "SHOP-2026-0042",
"email": "[email protected]",
"shippingAddress": {
"name": "Jane Doe",
"line1": "Keizersgracht 123",
"line2": "",
"postalCode": "1015 CJ",
"city": "Amsterdam",
"country": "NL"
}
}
| Field | Description |
|---|---|
quoteId |
Required. The quote to order, from 3. Get a quote. A quote can be ordered once. |
reference |
Optional. Your own reference for the order, like your order number: up to 100 characters. We return it with the order, so you can match it to yours. |
acceptTerms |
Required. true to accept our terms (the termsUrl of the quote). Without it, the order is refused with terms_not_accepted. The person who pays also accepts them on the payment page. |
acceptWarnings |
Optional. true to order a quote with warnings. Without it, such a quote is refused with warnings_not_accepted. |
email |
Required. Where we send the confirmation and the track and trace. |
shippingAddress |
Required. Where to ship. |
shippingAddress.name |
Required. The name of the receiver. |
shippingAddress.line1 |
Required. The first address line: usually street and house number. |
shippingAddress.line2 |
Optional. A second address line, like an apartment or a company name. |
shippingAddress.postalCode |
Required. The postal code. |
shippingAddress.city |
Required. The city. |
shippingAddress.country |
Required. The country of the quote. |
| Header | Description |
|---|---|
Idempotency-Key |
Optional, but advised. Any unique text, like a UUID, so a retry doesn’t order twice. See Idempotency. |
Response (201):
{
"orderId": "1042-x7Kq2mV9pL4tR8wN3bY6zA",
"reference": "SHOP-2026-0042",
"status": "awaiting_payment",
"paymentUrl": "https://www.blotprints.com/pay/1042-x7Kq2mV9pL4tR8wN3bY6zA",
"total": 56.96,
"currency": "EUR"
}
| Field | Description |
|---|---|
orderId |
The order. Keep it: it’s the only way to look the order up, and it’s secret, as anyone with it can see the order. |
reference |
Your own reference, as you sent it; null without one. |
status |
awaiting_payment: placed, waiting for payment. See 6. Track the order for all statuses. |
paymentUrl |
The page where a person pays for the order. See 5. Complete payment. |
5. Complete payment
Open the paymentUrl in a browser. It shows the order and asks for payment (iDEAL, card, and the other methods of the country). Once it’s paid, we print. An order that isn’t paid within 7 days is cancelled.
6. Track the order
GET https://www.blotprints.com/api/v1/orders/{orderId}
{
"orderId": "1042-x7Kq2mV9pL4tR8wN3bY6zA",
"reference": "SHOP-2026-0042",
"status": "shipped",
"createdAt": "2026-10-25T12:00:00Z",
"paidAt": "2026-10-25T12:05:00Z",
"items": [
{
"product": "poster",
"size": "a3-landscape",
"options": { "paper": "matte", "hanging": "photo_frame_black" },
"quantity": 2,
"unitPrice": 25.98,
"price": 51.96,
"vat": 9.02
}
],
"shipping": {
"method": "standard",
"price": 5.00,
"vat": 0.87,
"trackingUrl": "https://…"
},
"shippingAddress": {
"name": "Jane Doe",
"line1": "Keizersgracht 123",
"line2": "",
"postalCode": "1015 CJ",
"city": "Amsterdam",
"country": "NL"
},
"total": 56.96,
"vat": 9.89,
"currency": "EUR",
"paymentUrl": null
}
| Field | Description |
|---|---|
status |
Where the order is, see below. |
items, shipping, total, vat |
As in the quote the order was made from. |
paidAt |
When it was paid; null until then. |
shipping.trackingUrl |
Where to follow the parcel; null until it’s available. It can come a while after the order is marked as shipped. |
shippingAddress |
Where it’s shipped to, as you sent it when you placed the order, tidied up (like the spacing of the postal code). |
paymentUrl |
Where to pay; null once it’s paid or cancelled. |
status is one of:
| Status | Meaning |
|---|---|
awaiting_payment |
Placed, waiting for payment. |
paid |
Paid. |
preparing |
Being prepared for printing. |
in_production |
Being printed. |
shipped |
On its way. |
cancelled |
Not paid in time, or cancelled. |
Reference
Errors
An error has an HTTP status and a body like this:
{
"error": {
"code": "file_check_failed",
"message": "The interior has 18 pages. It needs at least 24. The cover isn't a file type we print.",
"item": 0,
"file": "interior",
"problems": [
{
"item": 0,
"file": "interior",
"code": "too_few_pages",
"message": "The interior has 18 pages. It needs at least 24.",
"values": { "pageCount": 18, "min": 24 }
},
{
"item": 0,
"file": "cover",
"code": "unsupported_file_type",
"message": "The cover isn't a file type we print."
}
]
}
}
| Field | Description |
|---|---|
error.code |
What went wrong, as a fixed code to check in your code (see the table below). |
error.message |
What’s wrong and how to fix it, written to be shown to a person or read by an agent. |
error.item |
Only for an error about an item of a quote: which one, counting from 0. |
error.file |
Only for an error about a file: which one of the item, like interior, cover or design. With several problems: the first one’s. |
error.problems |
Only for file_check_failed: every problem, of every file and item, each with its item, file, code (like too_few_pages, pdf_only, file_too_large or file_unreadable), message, and the numbers in it (values). |
error.docs |
The part of these docs about the error. |
| Status | Code | When |
|---|---|---|
400 |
invalid_request |
The JSON is wrong, or a field is missing. |
404 |
not_found |
No such product, upload, quote or order. |
409 |
quote_already_ordered |
The quote has been ordered already. Get a new one for another order. |
409 |
idempotency_key_reused |
The Idempotency-Key was used before, for a different request. Use a new key. |
410 |
quote_expired |
The quote is older than 24 hours. Get a new one. |
422 |
file_check_failed |
Files can’t be printed. problems lists them all. |
422 |
file_unreachable |
We couldn’t download a file link. |
422 |
country_mismatch |
shippingAddress.country isn’t the country of the quote. |
422 |
terms_not_accepted |
Set acceptTerms to accept our terms. |
422 |
warnings_not_accepted |
The quote has warnings: set acceptWarnings. |
429 |
rate_limited |
Too many requests. Wait Retry-After seconds. |
500 |
server_error |
Something went wrong on our side. Try again later. |
Idempotency
Networks fail, and scripts and agents try again. To make sure an order is never placed twice, send an Idempotency-Key header with POST /orders: any unique text, like a UUID.
Sending the same key again within 24 hours gives back the same order, instead of a new one. Use a new key for each new order.
Rate limits
Per IP address:
| What | Limit |
|---|---|
| All calls | 60 per minute |
POST /quotes |
30 per hour |
POST /orders |
10 per hour |
Over a limit, you get 429 with a Retry-After header: the seconds to wait.
For AI agents
This page is also plain Markdown, at /api/docs.md: read that to learn the API. AI tools can also find it through /llms.txt, with the tips below.
MCP server. AI assistants that support the Model Context Protocol (Claude, ChatGPT, Copilot, …) can add BlotPrints as a connector, at:
https://www.blotprints.com/mcp
No login is needed. The assistant then gets the steps of this page as tools: list_products, get_product, create_upload, get_quote, place_order and get_order.
OpenAPI. The API is also described as OpenAPI 3.1, at /api/openapi.json: for tools that read it, like code generators or a custom GPT’s actions.
Tips for agents:
- Start with
GET /productsto see what’s possible, and pick sizes and options from it: don’t guess names. - For files on the user’s computer, use an upload:
curl -T file.pdf "<uploadUrl>". - Show the user the quote’s preview (
previewUrl), price, warnings and terms (termsUrl) before you place the order, and ask whether the preview looks right. - Give the user the
paymentUrlto pay: you can’t pay yourself. - Keep the
orderIdto check on the order later.
Frequently asked questions
How do I change a quote?
A quote can't be changed. Get a new one with what you want now: a quote is free and only takes a moment. The old one simply expires after 24 hours.
How do I change or replace a file?
Before you place the order: upload or link the new file, and get a new quote with it.
After you placed it, but before it's paid: the paymentUrl opens our checkout, where the order can still be changed. Change next to an item opens it on our website, with its files, to pick another one.
Once it's paid, it can't be changed anymore: we print it specially for you.
How do I change an order, like its address or quantity?
There's no API call to change an order. Until it's paid, the paymentUrl opens our checkout, where the person paying can still change the address and the quantities, change files, or remove items. GET /orders/{orderId} then shows the order as it is.
You can also let it be and place a new order from a new quote: an order that isn't paid is cancelled by itself after 7 days.
Once it's paid, it can't be changed anymore: we print it specially for you.
How do I cancel an order?
Don't pay it: an unpaid order is cancelled by itself after 7 days, and its status becomes cancelled. Nothing is printed or charged.
A paid order can't be cancelled or returned: we print it specially for you (see our terms).
Can I test the API without ordering?
There's no test mode, and it isn't needed: products, uploads and quotes are free and change nothing. A quote checks your files and shows the previews, so you can test everything up to ordering.
You can even place an order: as long as it isn't paid, nothing is printed, and it's cancelled after 7 days.
Who pays, and can I pay by invoice?
Every order is paid on its own paymentUrl, with iDEAL, card or another method of the country: by you, or by your customer if you send them the link. Paying by invoice or monthly isn't possible yet.
An AI agent can't pay: it gives the paymentUrl to the person.
Can I ship straight to my customer?
Yes. Put your customer's address in shippingAddress. The email gets the confirmation and the track and trace: use your customer's, or your own to keep that to yourself.
One order goes to one address. To ship to several addresses, get a quote and place an order per address.
Which countries do you ship to?
To every country of the EU, with tracking: give its two-letter code in shipping.country, like NL, DE or IT. The quote gives the shipping costs and the expected delivery, which depend on how far it goes. Standard shipping is free from €50; express is faster, for the products that have it. The quote's shippingOptions list every way it can ship, with price and dates.
How do I know when an order is paid or shipped?
There are no webhooks yet. Ask for the order with GET /orders/{orderId} now and then, like every few hours: its status says where it is, and shipping.trackingUrl where the parcel is once it's shipped. The email of the order also gets a confirmation and the track and trace.
My quote has warnings. What should I do?
Look at the previewUrl first: it shows how we print the files. A warning means we fix something while printing, like zooming in a design of another shape, so its edges are cut off.
Looks fine? Order with "acceptWarnings": true. If not, fix the file and get a new quote. The warning's values say how much, like cutOffPercent or dpi.
Why was my file refused?
A file we can't print makes the quote fail with file_check_failed, and problems lists every problem with its file, so you can fix them all at once. Often it's the type (a book's interior must be a PDF), the number of pages, or a file over 200 MB. A link we can't download gives file_unreachable: check that it works without logging in.
The sizes, page counts and types each product needs are in its files.
Are the prices the same as on the website?
Yes, and VAT is included. The VAT in it is that of the country it ships to, and the quote shows it per item and for shipping. Prices can change: a quote's prices hold for its 24 hours.
Do I need an API key? What if the limits are too low?
No key is needed. The rate limits are per IP address, enough for most shops and agents. If you need more, let us know.
What do you do with my files?
We only use them to print your order. Only who has the links can see them: the previewUrl, the previews and the orderId can't be guessed. Uploads and quotes are deleted after 7 days, the files of an order 90 days after it's shipped.
Something's wrong with what arrived. Who do I contact?
Let us know with the order number (the part of the orderId before the dash) and the email of the order, and a photo if it helps. We'll sort it out.