Order from your own system

XYZ Prints takes orders from partners’ systems — shops, apps, workflows — through a small HTTP API: send the artwork, price the order, place it, and follow it to your customer’s door.

Base address: https://www.xyz-prints.com/api/v1/partner

In five steps

  1. Ask the studio for a partner account: you receive a key and your terms — on account or prepaid, a credit limit, a discount.
  2. Upload each picture (POST /uploads) and keep the id it answers.
  3. Price the order (POST /quote): each line, the shipping options, VAT, the total — and whether it would go straight into production.
  4. Place it (POST /orders) with your own reference and an Idempotency-Key. It is priced again here; send expected_total so that a price that moved is refused, never charged.
  5. Follow it on your webhook, or ask GET /orders?updated_since= now and then.

A partner account, a key, a question: info@xyz-prints.com.

Authentication

Every call carries your partner key in the header Authorization: Bearer <key>. The key reaches this API only, and only your own orders and files. Keep it on your server — never in a browser or an app. The studio mints and revokes keys: ask for a new one if it leaks. It can also hold your key to the addresses your servers call from, so a key that leaks is worth nothing elsewhere.

curl \
  -H "Authorization: Bearer $XYZ_KEY" \
  https://www.xyz-prints.com/api/v1/partner

Money is EUR with VAT included, as the storefront shows it; the rate follows the delivery country and your VAT number, checked with VIES. Prices are always computed here — your system never computes money.

Files

Send each picture once, as the multipart/form-data field file. Formats: tiff, jpg, png, pdf, psd, heic, bmp. Up to 2048 MB a file; a single request carries up to 8 MB, so send anything larger in chunks. A PDF gives one id per page. A file not ordered within 72 hours is removed.

curl \
  -H "Authorization: Bearer $XYZ_KEY" \
  -F "file=@harbour-at-dusk.tif" \
  https://www.xyz-prints.com/api/v1/partner/uploads
201 — Answer
{
  "files": [
    {
      "id": "k3Fq9w2LxP0aZt7c",
      "name": "harbour-at-dusk.tif",
      "bytes": 88213504,
      "width_px": 7200,
      "height_px": 9000,
      "dpi": 300,
      "dpi_assumed": false,
      "colour": {
        "mode": "RGB",
        "bit_depth": 16,
        "profile": true,
        "assigned": null
      },
      "expires_at": "2026-09-28T10:00:00.000Z"
    }
  ]
}

In chunks: cut the file into parts of one size (the last may be shorter) and POST each one as multipart with session (your id for the file: up to 30 letters, digits, - and _), index (from 0), total, chunk_size, size (the whole file, in bytes), name and chunk. Each part answers 202; the last answers 201 with the file.

The answer gives the picture’s pixels, the resolution it declares and its colour profile. The resolution on the print depends on the size ordered: the quote warns below 100 DPI, which the studio calls a fair print.

What can be ordered

GET /catalogue lists today’s ids for each kind of line. Below, the same list, read live.

curl \
  -H "Authorization: Bearer $XYZ_KEY" \
  https://www.xyz-prints.com/api/v1/partner/catalogue

Fine-art prints kind: "print"

5682Hahnemühle German Etching 310groll, up to 110 × 1200 cm · 310 g/m² · mounts: pvc-4mm-white, pvc-5mm-white, dibond-3mm, museum-board-1-6mm, museum-board-2-2mm, pvc-5mm-black
5683Hahnemühle Museum Etching 350groll, up to 110 × 1200 cm · 350 g/m² · mounts: pvc-4mm-white, pvc-5mm-white, dibond-3mm, museum-board-1-6mm, museum-board-2-2mm, pvc-5mm-black
5684Hahnemühle PhotoRag 308groll, up to 110 × 1200 cm · 308 g/m² · mounts: pvc-4mm-white, pvc-5mm-white, dibond-3mm, museum-board-1-6mm, museum-board-2-2mm, pvc-5mm-black
5698Hahnemühle PhotoRag Baryta 315groll, up to 110 × 1200 cm · 315 g/m² · mounts: pvc-4mm-white, pvc-5mm-white, dibond-3mm, museum-board-1-6mm, museum-board-2-2mm, pvc-5mm-black
5685Hahnemühle William Turner 310groll, up to 110 × 1200 cm · 310 g/m² · mounts: pvc-4mm-white, pvc-5mm-white, dibond-3mm, museum-board-1-6mm, museum-board-2-2mm, pvc-5mm-black
5686Canson Infinity Edition Etching Rag 310groll, up to 110 × 1200 cm · 310 g/m² · mounts: pvc-4mm-white, pvc-5mm-white, dibond-3mm, museum-board-1-6mm, museum-board-2-2mm, pvc-5mm-black
5687Canson Infinity Platine Fibre Rag 310groll, up to 110 × 1200 cm · 310 g/m² · mounts: pvc-4mm-white, pvc-5mm-white, dibond-3mm, museum-board-1-6mm, museum-board-2-2mm, pvc-5mm-black
5937Canson Infinity Baryta Photographique 310groll, up to 110 × 1200 cm · 310 g/m² · mounts: pvc-4mm-white, pvc-5mm-white, dibond-3mm, museum-board-1-6mm, museum-board-2-2mm, pvc-5mm-black
5934Canson Infinity Rag Photographique II 310groll, up to 61 × 1200 cm · 310 g/m² · mounts: pvc-4mm-white, pvc-5mm-white, dibond-3mm, museum-board-1-6mm, museum-board-2-2mm, pvc-5mm-black
5688Hahnemühle Photo Pearl 310groll, up to 110 × 1200 cm · 310 g/m² · mounts: pvc-4mm-white, pvc-5mm-white, dibond-3mm, museum-board-1-6mm, museum-board-2-2mm, pvc-5mm-black
5689Hahnemühle Photo Luster 260groll, up to 110 × 1200 cm · 260 g/m² · mounts: pvc-4mm-white, pvc-5mm-white, dibond-3mm, museum-board-1-6mm, museum-board-2-2mm, pvc-5mm-black
5871Canson Infinity PhotoGloss Premium RC 270groll, up to 61 × 1200 cm · 270 g/m² · mounts: pvc-4mm-white, pvc-5mm-white, dibond-3mm, museum-board-1-6mm, museum-board-2-2mm, pvc-5mm-black
5690XYZ White Velvet 270groll, up to 110 × 1200 cm · 270 g/m² · mounts: pvc-4mm-white, pvc-5mm-white, dibond-3mm, museum-board-1-6mm, museum-board-2-2mm, pvc-5mm-black
5691Hahnemühle Studio Enhanced 210groll, up to 110 × 1200 cm · 210 g/m² · mounts: pvc-4mm-white, pvc-5mm-white, dibond-3mm, museum-board-1-6mm, museum-board-2-2mm, pvc-5mm-black
5692Hahnemühle Art Canvas Smooth 370groll, up to 110 × 1200 cm · 370 g/m²
5872Canson Museum ProCanvas WCroll, up to 110 × 1200 cm · 385 g/m²
5699Awagami Murakumo Kozo Select Natural 42groll, up to 91 × 1200 cm · 42 g/m² · mounts: pvc-4mm-white, pvc-5mm-white, dibond-3mm, museum-board-1-6mm, museum-board-2-2mm, pvc-5mm-black
5694Awagami Kozo White 70groll, up to 110 × 1200 cm · 70 g/m² · mounts: pvc-4mm-white, pvc-5mm-white, dibond-3mm, museum-board-1-6mm, museum-board-2-2mm, pvc-5mm-black
5702Transparent film 165µroll, up to 61 × 1200 cm · 100 g/m² · mounts: pvc-4mm-white, pvc-5mm-white, dibond-3mm, museum-board-1-6mm, museum-board-2-2mm, pvc-5mm-black
5696Fedrigoni Arena White Smooth 350gsheets 51 × 72 51 × 72, 72 × 102 cm 72 × 102 cm · 350 g/m² · double-sided · mounts: pvc-4mm-white, pvc-5mm-white, dibond-3mm, museum-board-1-6mm, museum-board-2-2mm, pvc-5mm-black
5697Fedrigoni Arena White Rough 300gsheets 51 × 72 51 × 72, 72 × 102 cm 72 × 102 cm · 300 g/m² · double-sided · mounts: pvc-4mm-white, pvc-5mm-white, dibond-3mm, museum-board-1-6mm, museum-board-2-2mm, pvc-5mm-black

Options

mounts
pvc-4mm-white PVC 4mm white · pvc-5mm-white PVC 5mm white · dibond-3mm Dibond 3mm · museum-board-1-6mm Museum Board 1.6mm · museum-board-2-2mm Museum Board 2.2mm · pvc-5mm-black PVC 5mm black
subframes
wood-battens Wood battens · aluminium-rails Aluminium rails

A “print” line

Every line has kind and qty (1–999); these are the rest.

FieldTypeWhat it is
paper_id
required
integerThe paper, by its id in the catalogue.
width_cm
required
numberWidth of the print as it hangs, in cm. On a cut-sheet paper, one of its sheet sizes (either way round).
height_cm
required
numberHeight of the print as it hangs, in cm.
file
required
stringThe picture: an upload id from POST /uploads. A PDF gives one id per page.
fit"fit" | "fill"
default "fit"
fit = the whole picture, as large as it goes, with paper showing where the shapes differ. fill = the print covered edge to edge, the picture cropped about its centre.
border_cmnumber
default 0
White border all round, in cm. A cut sheet always keeps at least its unprintable margin.
rotate0 | 90 | 180 | 270
default 0
Turn the picture clockwise before placing it, in degrees.
cropobjectKeep part of the picture: {"x", "y", "w", "h"} as fractions 0–1 of its width and height (after its EXIF orientation). Cut before fit or fill.
back_filestringDouble-sided, on a cut-sheet paper that prints both faces: the back’s upload id. Same fit, border and rotation.
back_blankboolean
default false
Double-sided with a blank back (the odd last page of a document).
mountstringMount the print on a board, by id (options.mounts). Not with double-sided.
subframestringA hanging subframe behind the mount, by id (options.subframes). Needs mount.
subframe_pctnumber
default 60
The subframe’s size as a percentage of the print (the catalogue gives the range).
framestringFrame the print, by id (options.frames). Not with double-sided.
glazingstringThe frame’s glazing, by id (options.glazings), or "none". Default: the first offered.
mattstringA passe-partout inside the frame, by id (options.matts).
matt_cmnumber
default 5
The passe-partout’s width all round, in cm (0–20).
notestringA word for the studio about this print (up to 500 characters).
Example
{
  "kind": "print",
  "qty": 2,
  "paper_id": 101,
  "width_cm": 40,
  "height_cm": 50,
  "file": "upl_…",
  "fit": "fill",
  "border_cm": 2
}

Products kind: "product"

gift-card-50Gift card — €50€50.00
film-scan-35mmFilm scanning — 35 mm frame€6.00

A “product” line

Every line has kind and qty (1–999); these are the rest.

FieldTypeWhat it is
product
required
stringThe item, by its slug (or SKU) in the catalogue.
variantstringThe option, by its id in the catalogue — required when the item has options.
notestringA word for the studio about this line (up to 500 characters).
Example
{
  "kind": "product",
  "qty": 3,
  "product": "the-slug"
}

Price it

POST /quote takes the body an order takes, reference aside, and writes nothing. It answers each line’s price, the shipping options, VAT and the total, warnings about the artwork (a low resolution, a crop), and would_be: the state the order would start in under your terms today.

curl \
  -H "Authorization: Bearer $XYZ_KEY" \
  -H "Content-Type: application/json" \
  -d @order.json \
  https://www.xyz-prints.com/api/v1/partner/quote
order.json — Example
{
  "lines": [
    {
      "kind": "print",
      "qty": 2,
      "paper_id": 101,
      "width_cm": 40,
      "height_cm": 50,
      "file": "k3Fq9w2LxP0aZt7c",
      "fit": "fill",
      "border_cm": 2
    }
  ],
  "delivery": {
    "mode": "ship"
  },
  "ship_to": {
    "first_name": "Ana",
    "last_name": "Silva",
    "address_1": "Rua das Flores 12",
    "city": "Porto",
    "postcode": "4050-262",
    "country": "PT",
    "phone": "+351 912 345 678"
  },
  "note": "Gift — no invoice in the parcel."
}
Answer
{
  "quote": {
    "currency": "EUR",
    "lines": [
      {
        "kind": "print",
        "name": "Hahnemühle Photo Rag 308",
        "qty": 2,
        "unit_price": 54.12,
        "total": 108.24,
        "vat_rate": 23,
        "details": [
          {
            "name": "File",
            "value": "harbour-at-dusk.tif"
          },
          {
            "name": "Sheet",
            "value": "40 × 50 cm"
          },
          {
            "name": "Image",
            "value": "36 × 46 cm"
          }
        ]
      }
    ],
    "warnings": [
      {
        "line": 0,
        "code": "cropped",
        "message": "fill cuts 3.2% of the picture away to cover the print; send fit to keep all of it."
      }
    ],
    "delivery": {
      "mode": "ship",
      "service": "ctt_express",
      "label": "CTT Expresso",
      "options": [
        {
          "id": "ctt_express",
          "label": "CTT Expresso",
          "price": 12.05,
          "days": [
            1,
            2
          ]
        }
      ]
    },
    "totals": {
      "goods": 108.24,
      "discount": 0,
      "shipping": 12.05,
      "vat": 22.49,
      "total": 120.29
    },
    "vat": {
      "rate": 23,
      "exempt": false,
      "reason": null,
      "number": "PT509999999",
      "status": "valid_domestic"
    },
    "would_be": {
      "state": "in_production",
      "holds": []
    }
  }
}

Place it

POST /orders needs an Idempotency-Key header: a retry with the same key answers the first result and never places a second order. Your reference is unique as well — a second order carrying it is refused and the first one comes back. Send expected_total, the total /quote gave you, and a price that changed meanwhile is refused (409 price_changed) instead of charged.

curl \
  -H "Authorization: Bearer $XYZ_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: SHOP-10482-1" \
  -d @order.json \
  https://www.xyz-prints.com/api/v1/partner/orders
order.json — Example
{
  "reference": "SHOP-10482",
  "lines": [
    {
      "kind": "print",
      "qty": 2,
      "paper_id": 101,
      "width_cm": 40,
      "height_cm": 50,
      "file": "k3Fq9w2LxP0aZt7c",
      "fit": "fill",
      "border_cm": 2
    }
  ],
  "delivery": {
    "mode": "ship"
  },
  "ship_to": {
    "first_name": "Ana",
    "last_name": "Silva",
    "address_1": "Rua das Flores 12",
    "city": "Porto",
    "postcode": "4050-262",
    "country": "PT",
    "phone": "+351 912 345 678"
  },
  "note": "Gift — no invoice in the parcel.",
  "expected_total": 120.29
}
201 — Answer
{
  "order": {
    "number": 6214,
    "reference": "SHOP-10482",
    "state": "in_production",
    "holds": [],
    "status": "processing",
    "created_at": "2026-09-25T10:04:11.000Z",
    "updated_at": "2026-09-25T10:04:11.000Z",
    "currency": "EUR",
    "payment": {
      "terms": "account",
      "state": "unpaid",
      "paid_at": null
    },
    "totals": {
      "goods": 108.24,
      "discount": 0,
      "shipping": 12.05,
      "vat": 22.49,
      "total": 120.29,
      "refunded": 0
    },
    "vat": {
      "rate": 23,
      "exempt": false,
      "reason": null,
      "number": "PT509999999"
    },
    "delivery": {
      "mode": "ship",
      "service": "CTT Expresso",
      "ship_to": {
        "first_name": "Ana",
        "last_name": "Silva",
        "address_1": "Rua das Flores 12",
        "city": "Porto",
        "postcode": "4050-262",
        "country": "PT",
        "phone": "+351 912 345 678"
      },
      "tracking": null
    },
    "invoice": null,
    "note": "Gift — no invoice in the parcel.",
    "lines": [
      {
        "kind": "print",
        "name": "Hahnemühle Photo Rag 308",
        "qty": 2,
        "unit_price": 54.12,
        "total": 108.24,
        "vat_rate": 23,
        "details": [
          {
            "name": "File",
            "value": "harbour-at-dusk.tif"
          },
          {
            "name": "Sheet",
            "value": "40 × 50 cm"
          },
          {
            "name": "Image",
            "value": "36 × 46 cm"
          }
        ],
        "file": "k3Fq9w2LxP0aZt7c"
      }
    ]
  },
  "warnings": [
    {
      "line": 0,
      "code": "cropped",
      "message": "fill cuts 3.2% of the picture away to cover the print; send fit to keep all of it."
    }
  ]
}

ship_to is the recipient: first_name and last_name, or company; address_1, city, postcode and country (two letters); a phone and an e-mail help the carrier. Your own company is the one invoiced — the studio keeps it on your account. delivery.service picks one of the shipping options /quote gave; without it, the first. The recipient hears nothing from the studio unless you ask for it.

Payment and acceptance

On account: an order that passes every check goes straight into production. It is invoiced to you and paid by bank transfer against the invoice; the studio records the payment.

Prepaid: the order waits for its money. payment.pay_url is its payment page (card, MB WAY, PayPal…); paid, it goes into production by itself.

A hold keeps an order out of production until the studio releases it; holds says why:

  • review — Your account is set so that a person looks at every order first.
  • credit_limit — This order takes what you owe on account past your credit limit.
  • no_credit_limit — Your account is on credit, but no credit limit has been agreed yet — the studio sets one, then releases the order.

The state of an order

StateMeaning
awaiting_paymentPrepaid terms: waiting for its money through `payment.pay_url`. Paid, it goes into production by itself.
heldKept out of production until the studio releases it — see `holds`.
in_productionAccepted: being printed, finished and packed.
shippedHanded to the carrier; `delivery.tracking` when there is a number.
completedDelivered or collected, and closed.
cancelledCancelled; nothing more happens.
refundedRefunded in full.
failedA payment failed and the order stopped.

An order that is held, or still waiting for its payment, can be cancelled: POST /orders/{number}/cancel. Once it is in production, write to the studio.

Follow it

GET /orders/{number} reads one order; GET /orders?reference= finds yours by your reference. To keep your system in step without a webhook, ask GET /orders?updated_since=<the last updated_at you saw>: every order changed since, oldest change first.

curl \
  -H "Authorization: Bearer $XYZ_KEY" \
  "https://www.xyz-prints.com/api/v1/partner/orders?updated_since=2026-09-25T10:04:11.000Z"

Webhooks

Give the studio an https address and every change to your orders is POSTed there as JSON, with the order as it stands. Answer with any 2xx within 8 seconds. Each message is sent once: if you were down, GET /orders?updated_since= catches you up. Messages can arrive out of order — compare data.order.updated_at.

EventWhen
order.createdThe order was placed — `order.state` says whether it went into production, was held, or waits for payment.
order.acceptedA held or prepaid order went into production.
order.paidIts payment was recorded.
order.shippedIt left the studio; `tracking` when there is a number.
order.readyReady to collect at the studio.
order.invoicedThe invoice was issued; `invoice` is its number.
order.completedClosed.
order.cancelledCancelled.
order.refundedRefunded.
order.updatedAnything else about it changed (`from` and `to` for its status).

The studio’s “Send a test” posts the event ping, signed the same way, with no order.

order.shipped — Example
{
  "id": "evt_6f1c0a9b2d3e4f5a6b7c8d9e",
  "event": "order.shipped",
  "created_at": "2026-09-27T15:31:02.000Z",
  "data": {
    "tracking": "EA123456789PT",
    "order": {
      "number": 6214,
      "reference": "SHOP-10482",
      "state": "shipped",
      "holds": [],
      "status": "processing",
      "created_at": "2026-09-25T10:04:11.000Z",
      "updated_at": "2026-09-25T10:04:11.000Z",
      "currency": "EUR",
      "payment": {
        "terms": "account",
        "state": "unpaid",
        "paid_at": null
      },
      "totals": {
        "goods": 108.24,
        "discount": 0,
        "shipping": 12.05,
        "vat": 22.49,
        "total": 120.29,
        "refunded": 0
      },
      "vat": {
        "rate": 23,
        "exempt": false,
        "reason": null,
        "number": "PT509999999"
      },
      "delivery": {
        "mode": "ship",
        "service": "CTT Expresso",
        "ship_to": {
          "first_name": "Ana",
          "last_name": "Silva",
          "address_1": "Rua das Flores 12",
          "city": "Porto",
          "postcode": "4050-262",
          "country": "PT",
          "phone": "+351 912 345 678"
        },
        "tracking": "EA123456789PT"
      },
      "invoice": null,
      "note": "Gift — no invoice in the parcel.",
      "lines": [
        {
          "kind": "print",
          "name": "Hahnemühle Photo Rag 308",
          "qty": 2,
          "unit_price": 54.12,
          "total": 108.24,
          "vat_rate": 23,
          "details": [
            {
              "name": "File",
              "value": "harbour-at-dusk.tif"
            },
            {
              "name": "Sheet",
              "value": "40 × 50 cm"
            },
            {
              "name": "Image",
              "value": "36 × 46 cm"
            }
          ],
          "file": "k3Fq9w2LxP0aZt7c"
        }
      ]
    }
  }
}

Every message is signed: X-WebOffice-Signature: t=<unix time>,v1=<HMAC-SHA256 of “t.body” under your signing secret, in hex>. Check it against the raw body, and refuse a t more than five minutes away. The studio gives you the secret and can rotate it.

import crypto from 'node:crypto'

// raw: the request body exactly as it arrived (a string, before any JSON parsing)
// header: the X-WebOffice-Signature header; secret: your signing secret (whsec_…)
export function verified(raw, header, secret) {
  const parts = Object.fromEntries(String(header).split(',').map((kv) => kv.trim().split('=')))
  const t = Number(parts.t)
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > 300) return false
  const want = crypto.createHmac('sha256', secret).update(t + '.' + raw).digest('hex')
  const got = String(parts.v1 || '')
  return got.length === want.length && crypto.timingSafeEqual(Buffer.from(got), Buffer.from(want))
}

Errors

An error answers an HTTP status and { error: { code, message, fields } }; fields names each problem the way the body spells it (lines[0].width_cm). Codes are stable, messages are for people.

422 — Example
{
  "error": {
    "code": "invalid_lines",
    "message": "Hahnemühle Photo Rag 308: That size is too large for this paper — the largest print it can make is 111.8 × 1500 cm.",
    "fields": [
      {
        "field": "lines[0].width_cm",
        "code": "size_not_available",
        "message": "Hahnemühle Photo Rag 308: That size is too large for this paper — the largest print it can make is 111.8 × 1500 cm."
      }
    ]
  }
}
CodeMeaning
unauthorisedNo key, or not a partner key (401).
partner_inactiveThe key’s partner is switched off, or the brand no longer takes partner orders (403).
address_not_allowedYour account only takes calls from the addresses the studio listed, and this is not one (403).
rate_limitedPast a limit — calls, lines priced, orders an hour, or bad keys from one address: wait the Retry-After seconds (429).
internalSomething failed on our side; nothing you sent was wrong. Try again (500).
storage_fullToo many of your files are waiting for an order: order them or let them expire first (507).
unknown_callNo such call (404).
invalid_queryA query parameter is not what the call takes — updated_since is an ISO 8601 date-time (400).
invalid_bodyThe body is not what the call takes; `fields` names each problem (422).
invalid_linesA line cannot be made as asked — an unknown paper, a size it cannot take, a finishing it does not offer, a file that is not yours; `fields` names the line and the field (422).
no_shippingNo carrier takes this order to this address, or its freight is quoted by hand (422).
unknown_servicedelivery.service is not one of the options for this order (422).
destination_refusedThe studio does not ship to that country (422).
pickup_not_offeredCollection at the studio is not offered (422).
idempotency_key_requiredPOST /orders needs an Idempotency-Key header (400).
in_progressAnother request with this Idempotency-Key is still running (409).
duplicate_referenceAn order already carries this reference; it comes back as `order` (409).
price_changedThe total is not your expected_total; the current `total` comes back — price it again (409).
pausedThe studio is not taking orders until `until` (503).
unknown_orderNo order of yours with that number (404).
not_cancellableThe order is already in production, shipped or settled (409).
unknown_fileNo upload of yours with that id — expired, or never sent (404).
no_fileThe upload carried no file (400).
format_not_acceptedThat file type is not accepted (415).
too_largeA file, a single upload request or an order body is too large: chunk the file, or send fewer lines (413).
unreadableThe file could not be read as a picture or a PDF (415).
upload_failedA chunk did not fit the upload it claimed to belong to (400).

Per key, every ten minutes: 600 calls, 2000 lines priced (a quote or an order counts its lines) and 8192 MB of uploads. Per account: 120 orders an hour, 20 GB of files waiting for an order, and a body of up to 1024 KB. Past a limit the answer is 429 with Retry-After (507 for the files). Calls with a bad key are throttled per address.

Every call

GET /partner

Who the key belongs to: your terms, what you owe on account, the limits.

GET /partner/catalogue

What can be ordered today, kind by kind: the ids a line names.

POST /partner/uploads

Send a file. Up to the single-request limit as multipart field `file`; larger files in chunks. A PDF becomes one file per page.

no_file format_not_accepted too_large unreadable upload_failed storage_full unauthorised partner_inactive address_not_allowed rate_limited unknown_call internal

GET /partner/uploads/{id}

A file you sent: its pixels, resolution and colour, and until when it is kept.

unknown_file unauthorised partner_inactive address_not_allowed rate_limited unknown_call internal

POST /partner/quote

Price an order without placing it: lines, shipping options, VAT, total — and whether it would go straight into production.

too_large invalid_body invalid_lines no_shipping unknown_service destination_refused pickup_not_offered unauthorised partner_inactive address_not_allowed rate_limited unknown_call internal

POST /partner/orders

Place an order. Priced here, then accepted into production, held, or left waiting for its payment — by your terms.

  • Idempotency-Key (required) — Your unique key for this attempt (8–200 characters). A retry with the same key returns the first answer and never places a second order.

idempotency_key_required in_progress too_large invalid_body invalid_lines duplicate_reference price_changed no_shipping unknown_service destination_refused pickup_not_offered paused unauthorised partner_inactive address_not_allowed rate_limited unknown_call internal

GET /partner/orders

Your orders, newest first — or every order changed since a moment, oldest change first, to keep your system in step.

  • ?updated_since= — ISO 8601 date-time: orders changed after it, oldest change first. Keep the last updated_at and ask again.
  • ?reference= — Your own reference: the one order that carries it.
  • ?limit= — 1–100, default 50. `more: true` says there is another page.

invalid_query unauthorised partner_inactive address_not_allowed rate_limited unknown_call internal

GET /partner/orders/{number}

One of your orders by its number.

unknown_order unauthorised partner_inactive address_not_allowed rate_limited unknown_call internal

POST /partner/orders/{number}/cancel

Cancel an order that is held or still waiting for its payment. Once in production, write to the studio.

unknown_order not_cancellable unauthorised partner_inactive address_not_allowed rate_limited unknown_call internal

GET /partner/openapi.json

This description as OpenAPI 3.1, for your code generator. Add ?brand=<slug> for the line fields of a brand.

The same, as OpenAPI 3.1 for a code generator: https://www.xyz-prints.com/api/v1/partner/openapi.json?brand=xyz-prints