API Documentation
Welcome to the Print Trail API documentation, designed for developers who wish to create an integration.
👉 We recommend you read the sections describing how orders and fulfilments work.
OpenAPI
The Print Trail API is a REST API.
A full OpenAPI (formerly Swagger) description of the API is available (OpenAPI 3.0.3):
- Browse the interactive API reference — every endpoint with its example payloads and responses.
- Download the OpenAPI specification to use in your own tooling.
Contact us if you have any questions.
Versions and changes
The API changelog lists what changed in each version of the API, and its compatibility policy. Every API response, and every webhook, carries the version in a Print-Trail-Api-Version header.
Within a version (/v1/) the API only grows: new endpoints, new optional fields, new response and webhook fields, new statuses and other enum values, and new webhook events can be added at any time. So that these never break your integration, it must:
- ignore fields it does not recognise, in responses and webhooks;
- treat a status or other enum value it does not recognise as “something else”, not as an error;
- fall back on the HTTP status code when a problem
typeis new to it; - answer a webhook with a
notificationTypeit does not handle with a 2xx, and otherwise ignore it.
Where this page says something applies “from version 1.2.0” (or another version), it applies when the Print-Trail-Api-Version header you receive is that version or later.
OpenAPI enables you to integrate more quickly through the use of code generators such as OpenAPI Generator and Swagger Codegen. You can also load the specification into viewers such as Swagger UI or ReDoc to browse the endpoints interactively.
The specification covers the full set of endpoints, including a lightweight POST /test-connection check to verify your credentials, a GET /stock-levels endpoint that returns compact per-SKU stock for every product in a single call (see Stock levels), a POST /products/validate-skus endpoint for bulk-validating external SKUs against Print Trail products without pulling the full stock feed, and a GET /shipping-methods endpoint listing the shipping method codes you may request when confirming an order.
Once an order is confirmed, PUT /orders/{uuid}/lines amends its lines while nothing has reached a supplier yet: the order is draft, queued or scheduled and every fulfilment is still a draft. Send the full set of lines you want, matched on lineNumber. Quantities can go up or down, lines can be removed and new lines added. The order stays confirmed: Print Trail rebuilds its fulfilments, keeps the shipping methods chosen at confirm, and puts a queued order back in the queue with a fresh countdown. Every order body includes fulfillableFrom and scheduledAt, so you can tell how long the order can still be amended. The response always says what happened in an amendment object. Every refusal carries a stable reason code to branch on (for example stock-held, fulfilment-underway or being-rebuilt; only being-rebuilt and unexpected-error are worth retrying), a merchantMessage written for a shop owner, and a detail for developers. An amend sends no webhook, and the fulfilments get new uuids (their numbers may repeat), so store them from the response, keyed by uuid.
When creating an order, an optional strictSkus flag on POST /orders rejects the whole order (422) if any line references an unmatched SKU, instead of silently dropping that line — useful for integrations that want mapping mistakes to fail fast. See the OpenAPI specification for the exact request and response shapes.
API Credentials
The Print Trail API uses bearer authentication.
To use the Print Trail API, you need a channel’s Channel ID and an API token for that channel.
Creating API credentials
API credentials are created per channel, for any channel except the team’s ‘Standard’ channel.
- In the Print Trail app go to Settings > Channels, where you can either use an existing channel or click Create Channel.
- On the channel’s row, click Token, then Generate.
- Copy the API token from the pop-up. It is shown once and cannot be retrieved later. Generating a new token revokes the existing one, and Revoke token removes it altogether.
The Channel ID is shown in the channel’s ID column (click to copy). It is your team code and the channel code joined by a hyphen, for example acme-web, and it goes in every request path: https://api.printtrail.com/v1/channels/{channelId}.
These API details are per channel. Each integration will require a new channel, e.g. one per third-party e-commerce site or app.
⚠️ The ‘Standard’ channel holds the orders entered in the Print Trail app, and cannot be used for an integration: it has no Token action. From version 1.2.0, a token it was given before then is refused with a
403on every request (if the ‘Standard’ channel is also inactive, thechannel-inactive403below is returned instead). Create a dedicated channel for the integration instead, and revoke the old token.
A channel can be switched off with its active toggle in Settings > Channels. From version 1.2.0, an inactive channel’s token is refused on every request with a 403 (problem type ending /problems/channel-inactive), and no new webhooks are sent to the channel until it is switched on again. A delivery that was already queued before the channel was switched off may still be retried.
Using API credentials
Authenticate each request by sending a bearer token in the Authorization header:
Authorization: Bearer <your API token>
The API Access panel below the channels table on Settings > Channels shows the base URL, Channel ID format and header.
Webhooks
Webhooks are machine-to-machine notifications sent from Print Trail to your apps or e-commerce stores, for instance when an order’s state changes.
Webhooks are particularly useful for asynchronous events like when a fulfilment has shipped, or a tracking link has been added to a fulfilment.
To configure webhooks
Your integration registers its webhook URL through the API. There is no field for it in the Print Trail app.
- Create API credentials for the channel – see Creating API credentials.
- Call
POST /webhookswith the URL at which you wish to receive webhook notifications, for example{ "url": "https://mystore.com/webhooks/printtrail/{notificationType}" }. The optional{notificationType}placeholder is replaced with the event name when each webhook is sent. - Store the signing secret returned in the response (
data.webhookDetail.secret). You need it to verify webhooks.
Calling POST /webhooks again replaces the URL, for instance after your site moves. Use an https:// URL: from version 1.2.0 it is required, see Webhook URL requirements.
Webhook URL requirements
From version 1.2.0, POST /webhooks only accepts a URL that Print Trail can deliver to over the public internet. The URL must:
- be an absolute
https://URL of at most 200 characters; - have no user name, password or
#fragment; - be on a public host name that resolves in DNS, and every address it resolves to must be a public one. Host names such as
localhost, names ending in.localor.internal, single-word names, and private, loopback or link-local addresses are refused; - use the
{notificationType}placeholder, if at all, only in the path or the query string.
Any other URL is refused with a 422 and is not stored. The URL is checked first: while your team is inactive, a URL that fails these requirements still gets the 422, and only a valid one gets the inactive-team 403.
Deliveries do not follow redirects: a 3xx answer counts as a failed delivery and is retried like any other. Register the final URL your receiver answers on.
The same check runs again before every delivery, including retries. A webhook whose URL no longer meets it, for example an http:// URL registered before version 1.2.0, is not sent. If your integration registered an http:// URL, register an https:// one.
Testing against a local or http:// development site? Expose it through an HTTPS tunnel with a public host name, and register the tunnel’s URL.
⚠️ Webhooks are sent per channel. Order and fulfilment webhooks go to a channel’s webhook URL only for orders created through the API on that channel; orders entered in the Print Trail app do not send them. The stock webhook goes to every channel of the team that has a webhook URL, because stock is shared across channels. From version 1.2.0, no new webhooks of any kind are sent to an inactive channel; a delivery already queued before the channel was switched off may still be retried.
From version 1.2.0, POST /webhooks is refused with a 403 while your Print Trail team is inactive.
Webhook events
Each webhook carries a notificationType:
order-created– an order was created throughPOST /orders.order-status-updated– the order’s status changed, for example toholding,processing,complete,cancelled,back-order,returned,invalid,error,failedorpartial.order-fulfillable-status-updated– the order’sfulfillableStatuschanged, for example tonot-fulfillable.fulfilment-status-updated– a fulfilment’s status changed, for example todispatched,returned,invalid,cancelled,failedorback-order.fulfilment-tracking-created– tracking details were added to a fulfilment.stock-level-updated– the stock of one or more products changed. Deliveries are batched: at most one per channel every ten minutes, listing every product whose stock changed in that window with its current figure. See Stock webhook below.
An order’s final statuses are complete (every fulfilment dispatched), failed (every fulfilment failed, was cancelled or returned, with nothing shipped), partial (every fulfilment finished, at least one dispatched and at least one not) and cancelled. For partial, check each fulfilment’s status to see which goods were sent.
👉 Webhooks are only sent for a channel when the channel has a webhook URL registered.
Verifying webhooks
Every webhook is a POST with a JSON body and a Signature header. The header is the HMAC-SHA256 of the raw request body, keyed with the channel’s webhook secret, written as 64 lowercase hex characters (no prefix, not base64). Check it before you trust the payload:
- Read the raw body bytes exactly as received. Do not decode and re-encode the JSON first: the body is PHP
json_encodeoutput (/arrives as\/), so re-serialising changes the bytes and the signature will not match. - Compute the HMAC-SHA256 of those bytes with the secret, as lowercase hex.
- Compare it with the
Signatureheader using a constant-time comparison, and reject the request if it does not match.
PHP
$rawBody = file_get_contents('php://input');
$received = $_SERVER['HTTP_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $rawBody, $webhookSecret);
if (! hash_equals($expected, $received)) {
http_response_code(403);
exit('invalid signature');
}
$payload = json_decode($rawBody, true);
Node.js (Express)
const crypto = require('crypto');
function signatureMatches(rawBody, received, secret) {
if (!secret || typeof received !== 'string') return false;
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const receivedBuf = Buffer.from(received, 'utf8');
const expectedBuf = Buffer.from(expected, 'utf8');
// timingSafeEqual throws if the byte lengths differ, so check first.
return receivedBuf.length === expectedBuf.length &&
crypto.timingSafeEqual(receivedBuf, expectedBuf);
}
// express.raw keeps the body as the exact bytes Print Trail sent.
app.post('/webhooks/printtrail/:type', express.raw({ type: 'application/json' }), (req, res) => {
const received = req.get('Signature');
const ok = signatureMatches(req.body, received, currentSecret) ||
signatureMatches(req.body, received, previousSecret); // only for a few hours after rotating
if (!ok) return res.sendStatus(403);
const payload = JSON.parse(req.body.toString('utf8'));
// ... handle payload, then reply quickly
res.sendStatus(200);
});
Getting the secret
Each channel has its own webhook secret. POST /webhooks returns the channel’s current secret as data.webhookDetail.secret. Registering does not change it, so registering again (after a reinstall, say) returns the same secret. No other endpoint returns it, so store it when you register.
This means an integration that registers its own webhook needs only the API token from the merchant. A merchant can also see the secret in Print Trail through the Webhook secret action on Settings > Channels, but that action generates a new secret and shows it once. It is only needed for a receiver that does not register its own webhook.
Rotating the secret
POST /webhooks/secret/rotate (no body) replaces the secret and returns the new one, in the same shape as POST /webhooks. The Webhook secret action in Settings > Channels does the same thing. If the merchant rotates from there, your integration will start failing verification until it calls POST /webhooks again to fetch the new secret.
Each webhook is signed when it is queued, so webhooks already queued at the moment of rotation, and their retries, still carry a signature made with the old secret. A failed delivery is retried with increasing delays (5 attempts, about 10 seconds, 100 seconds, 1,000 seconds and 10,000 seconds apart), so a retry can arrive up to about 3 hours later, still with the old signature. Accept either secret for a few hours after rotating, then drop the old one. A webhook re-sent with POST /webhooks/resend after the rotation uses the new secret.
Re-requesting a webhook
If your endpoint missed or failed to process an order webhook, you can ask Print Trail to send it again using the resend endpoint (POST /webhooks/resend) with the order’s UUID and the event type. Only order-level events (order-created, order-status-updated and order-fulfillable-status-updated) can be re-requested. A resent order-status-updated describes the order’s current state: its originalOrderStatus is null because no transition is being reported. From version 1.2.0, the order must be one created through the API on your channel (see Which orders a channel can see); any other order uuid is refused with the same 422 as one that does not exist. See the OpenAPI specification for the exact request and response shapes.
Webhook payload
The payload consists of some common details, and some additional details provided by the event.
The common details on order and fulfilment events are:
notificationType- the event, as listed in Webhook eventstenantCode- Note: ‘tenant’ and ‘team’ have the same meaningchannelCodeorderUuidorderNumber- your own order reference, as supplied when the order was createdmessage- a user-oriented description of what has happened.createdAtupdatedAtconfirmedAt- when the order was confirmed for processing, ornull(see Was an order confirmed?)
Each event adds its own fields. For example, order-status-updated adds originalOrderStatus and currentOrderStatus, and fulfilment-status-updated adds originalFulfilmentStatus, currentFulfilmentStatus, orderStatus and products. The API reference lists the fields for every event.
For orders, the message template is:
Order "{orderNumber}" status has changed from "{originalOrderStatus}" to "{currentOrderStatus}"
When tracking details are added to a fulfilment, each tracking-detail object includes both the newer, recommended keys – carrier, trackingNumber, trackingUrl – and the legacy keys – courier, number, url. The two sets hold the same values; prefer the newer keys in new integrations.
currentTrackingDetails on fulfilment-tracking-created, and products on fulfilment-status-updated, are always JSON arrays. (Before version 1.1.3 either could occasionally arrive as an object keyed "0", "1", …; an integration that follows the specification needs no change.)
The tracking objects in a webhook also carry methodCode, Print Trail’s own shipping-method code. It is only sent in webhooks: the order’s trackingDetails in GET /orders/{uuid} does not include it. Prefer methodName.
Webhooks should be interpreted as a prompt to fetch the full details for the relevant resource (fulfilment and/or order).
Stock webhook
The stock-level-updated event lets a store keep its stock figures current without polling. It is sent when the stock of one or more products changes, whether from a restock, a manual adjustment, or a sale on any channel.
Stock can move many times a minute for a busy product, so deliveries are coalesced: the first change opens a ten-minute window, and when the window closes one webhook is sent to every channel of the team that has a webhook URL, listing every product that changed in the window with its stock at the moment of sending. A flurry of changes produces one webhook; a lone restock arrives within ten minutes.
{
"notificationType": "stock-level-updated",
"tenantCode": "acme",
"channelCode": "web",
"changedAt": "2026-09-16T15:10:00+00:00",
"products": [
{ "sku": "ABC-123", "productUuid": "c0ffee00-1234-4abc-8def-0123456789ab", "stock": 42 }
],
"message": "Stock changed for 1 product"
}
stockis the same figureGET /stock-levelsreturns: the sum of stock across the product’s enabled warehouses, plus 10000 when print-on-demand is approved and active.- The order fields (
orderUuid,orderNumber,createdAt,updatedAt,confirmedAt) are absent on this event. - The event cannot be re-requested through
POST /webhooks/resend. UseGET /stock-levelsas the fallback, for instance in a daily sweep. - Print-on-demand approval or activation is not yet pushed; it reaches the store through
GET /stock-levels.
Orders
Which orders a channel can see
A channel only sees its own orders: those created through the API on that channel.
- Since version 1.1.2,
GET /orders/{uuid},PUT /orders/{uuid}/cancel,DELETE /orders/{uuid}andPUT /orders/{uuid}/confirmanswer404for any other order, whether it belongs to another channel or was entered in the Print Trail app or imported. The response is the same as for an order uuid that does not exist, and the order is left unchanged.POST /ordersandPUT /orders/{uuid}/linesalready worked this way. - From version 1.2.0, the order search (
GET /orders) andPOST /webhooks/resendfollow the same rule: an order entered in the Print Trail app or imported does not appear in the search, even when it is filed under your channel.
Was an order confirmed?
Read confirmedAt on the order. It is on every order response (GET /orders/{uuid}, search results, and the create, confirm and amend responses) and on every order and fulfilment webhook.
- It is set when the order is confirmed for processing: by
PUT /orders/{uuid}/confirm, or by a user queueing, scheduling or processing the order in Print Trail. - It stays set from then on, including when the order is cancelled or later moves to
holding. - It is
nullfor a draft. It is cleared only when the order goes back to draft (for example, a user takes it out of the queue or saves it as a draft). Such an order can be replaced withPOST /ordersand confirmed again, which sets a newconfirmedAt.
Do not work out confirmation from status. holding can mean an order that has not been confirmed yet because its shipping address was rejected (fix the address by sending the order again, then confirm it), or a confirmed order that could not be sent. confirmedAt is null for the first and set for the second.
👉 Orders confirmed before this field was introduced (19 October 2026) may read
nulluntil Print Trail backfills them; fall back tostatusfor those.
Sending an order more than once
POST /orders is safe to retry. Within a channel, orderNumber identifies the order, so if your integration never saw the answer to a create (a timeout, a crash, a duplicate event from your shop), send the same order again. What happens depends on the order Print Trail already holds with that orderNumber:
| Existing order | Response | What to do |
|---|---|---|
| None | 201, order created | Nothing more. |
| A draft, or held before confirmation | 201, the draft is replaced by what you sent | Nothing more. |
Confirmed and still in progress (queued, scheduled, processing, holding, back-order or error, or counting down to processing) |
409, type ending /problems/order-already-confirmed |
Treat it as success. Take the order’s uuid from orderUuid and the order itself from data. Do not send it again. |
Finished (complete, cancelled, failed, returned or partial) or otherwise no longer changeable, even if it was confirmed once |
409, type ending /problems/order-not-updatable |
A real conflict: the order number is already used by an order that can no longer change. Do not retry. |
A finished status is checked first, so an order that was confirmed and has since completed or been cancelled gets order-not-updatable. In both 409 cases the existing order is left exactly as it was. Branch on the type, not on the wording of detail. The order’s status is in orderStatus (status holds the HTTP status code).
⚠️ Always read
orderStatus(ordata.status) as well. Never treat a cancelled order as placed.
If you changed the order in your shop after it was confirmed, compare data with what you sent: use PUT /orders/{uuid}/lines to change a confirmed order’s lines, or PUT /orders/{uuid}/cancel to withdraw it.
POST /orders can return a 409 for two other reasons:
- The
printTrailOrderNumberyou supplied is already used by another order in the team. The body is a plain problem with noorderUuid. Send a differentprintTrailOrderNumber, or leave it out and Print Trail generates one. - With
reserve: true, the stock could not be held. This body has notypeand lists the SKUs that are short. Nothing was created.
See the API reference for the full response bodies.
Errors and limits
Errors are returned as RFC 7807 problem documents with type, title, status and detail. A validation error (422) also lists violations, each naming the offending field in property. Branch on the status code and type, never on the wording of detail, and fall back on the status code for a type you do not recognise.
| Status | When |
|---|---|
| 401 | The token is missing or invalid. From version 1.2.0, also a Channel ID that does not exist: the response is the same as for a bad token. |
| 403 | The team is inactive and the request creates or amends an order; from version 1.2.0, also confirming an order or registering a webhook (reads and cancelling still work, so you can tidy up). From version 1.2.0, also: the token belongs to the ‘Standard’ channel, or the channel is inactive (type ending /problems/channel-inactive). |
| 404 | The order, product or channel is not one your token can see (see Which orders a channel can see). |
| 409 | See Sending an order more than once. |
| 422 | The request failed validation. The body’s violations name each field. |
| 428 | The order is not in a state that allows the request, for example cancelling an order that has already been dispatched. |
| 429 | Too many requests (see Rate limits). Wait for the number of seconds in the Retry-After header before trying again. |
| 500 | An unexpected error. From version 1.2.0 the detail is a generic message and the body carries a reference: quote it to Print Trail support so the error can be traced. |
From version 1.2.0 a request is checked in this order, and the first check it fails decides the response:
- the per-IP limit on failed requests (
429); - the Channel ID exists (
401if not, the same as a bad token); - the token is valid for that channel’s team (
401); - the token belongs to the channel in the path (
404); - the channel is active (
403,channel-inactive); - the token is not the ‘Standard’ channel’s (
403); - the per-token rate limit (
429).
Only then is the request itself handled, for example validated (422) and checked against the team’s state (the inactive-team 403).
Input limits
From version 1.2.0 these limits apply, and a request over any of them is refused with a 422 naming the field:
orderNumberandprintTrailOrderNumber: at most 255 characters;orderLines[].product.sku: at most 255 characters, onPOST /ordersandPUT /orders/{uuid}/lines;- at most 500
orderLinesin one request.
The shipping address fields have their own maximum lengths, listed in the API reference.
Rate limits
From version 1.2.0, requests are rate-limited per API token (by default 600 a minute), so two shops sharing an IP address do not share a limit. Requests that fail authentication (401) are also limited per IP address (by default 60 a minute); an integration with a working token does not use up that allowance. Once that limit is exceeded, every request from the address gets a 429 until the minute is up.
Over either limit the API answers 429 with a JSON problem body ("status": 429, "title": "Too Many Requests") and these headers:
Retry-After: seconds to wait before trying again;X-RateLimit-Limit: the per-minute limit that was exceeded;X-RateLimit-Remaining:0;X-RateLimit-Reset: when the limit resets, as a Unix timestamp.
Successful responses carry X-RateLimit-Limit and X-RateLimit-Remaining for your token’s limit.
Stock levels
GET /stock-levels returns { "sku": "…", "stock": … } for every product in your team that has not been deleted, whatever its state: inactive and pre-order products are included.
- A sold-out product is listed with
stock: 0. A SKU missing from the response is not a Print Trail product, which is different from being out of stock. skucan benullfor a product that has no SKU.- A
stockof 10000 or more means print-on-demand: effectively unlimited, not a count. - The response does not say whether a product is inactive. Use
GET /products, whoseactiveStateis one ofactive,inactiveorpre-order(lowercase, also when filtering withactive-state).