Skip to main content
Print products from 1 piece

Reseller API

Sell Printen.eu products in your own store. Your order automatically becomes a production order, is paid from your balance or saved card, and ships white label to your customer: your name, no Printen.eu branding and no prices.

Quickstart

  1. 1. Create an API key

    Once your reseller account is approved, create a key in your dashboard. A prk_live_ key places real orders; a prk_test_ key is a sandbox that charges nothing and produces nothing. The full key is shown only once.

  2. 2. Get a quote

    POST /quotes with lines and the destination country returns your price including discount, VAT and shipping.

  3. 3. Place an order

    POST /orders with an Idempotency-Key header (for example your own order number). Retrying with the same key never creates a second order.

  4. 4. Receive webhooks

    Register an https endpoint and receive status changes, such as shipped with tracking.

# Product: customer options with defaults and allowed values (id or any slug)
curl https://www.printen.eu/api/reseller/v1/products/flyers?lang=en \
  -H "Authorization: Bearer prk_test_..." -H "User-Agent: MyShop-Integration/1.0"

# Prijs / quote: only customer options; omitted options get the product-page default.
# quantity = number of pieces (for run-priced products the run: here 1000 flyers)
curl -X POST https://www.printen.eu/api/reseller/v1/quotes \
  -H "Authorization: Bearer prk_test_..." -H "Content-Type: application/json" \
  -H "User-Agent: MyShop-Integration/1.0" \
  -d '{"country":"BE","lines":[{"productId":"<product id>","quantity":1000,"options":{"size":"a5","material":"135gr_gesatineerd_mc","printtype":"44"}},{"resellerSku":"POSTER-A2","quantity":1}]}'

# Order
curl -X POST https://www.printen.eu/api/reseller/v1/orders \
  -H "Authorization: Bearer prk_test_..." -H "Content-Type: application/json" \
  -H "Idempotency-Key: shop-order-1001" \
  -d '{"externalOrderId":"1001","lines":[{"resellerSku":"POSTER-A2","quantity":1,"fileUrl":"https://example.com/design.pdf"}],
       "recipient":{"name":"Anna de Vries","email":"anna@example.com","phone":"+31612345678","address1":"Dorpsstraat 1","postalCode":"1234 AB","city":"Utrecht","country":"NL"}}'

# Webhook
curl -X POST https://www.printen.eu/api/reseller/v1/webhooks \
  -H "Authorization: Bearer prk_live_..." -H "Content-Type: application/json" \
  -d '{"url":"https://example.com/printen-webhook","events":["order.shipped","order.delivered"]}'

Authentication

Send your key as Authorization: Bearer prk_live_... Keys can have scopes; a key without scopes may do everything.

With a prk_test_ key, orders are created but not charged and not sent to production.

Up to 120 requests per minute per key, plus account budgets for quotes and orders. Every response carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset (seconds) for the strictest limit that applies; above it you get 429 with a Retry-After header.

Base URL: https://www.printen.eu/api/reseller/v1

Endpoints

MethodPathDescriptionScope
GET/productsList catalogue productscatalog:read
GET/products/{id}Get a product with options and file requirementscatalog:read
POST/quotesPrice lines for a destination countryquotes:write
GET/reseller-productsList your own productsreseller-products:read
POST/reseller-productsCreate your own product (SKU with fixed options)reseller-products:write
GET/reseller-products/{id}Get your own product (id or SKU)reseller-products:read
PATCH/reseller-products/{id}Update your own productreseller-products:write
DELETE/reseller-products/{id}Delete your own productreseller-products:write
GET/ordersList your orders (newest first)orders:read
POST/ordersPlace an orderorders:write
GET/orders/{id}Get an orderorders:read
POST/orders/{id}/cancelCancel an order while it has not reached productionorders:write
GET/walletBalance and ledger entrieswallet:read
GET/webhooksList webhook endpointswebhooks:read
POST/webhooksCreate a webhook endpoint (the secret is only returned here)webhooks:write
DELETE/webhooks/{id}Delete a webhook endpointwebhooks:write

How the API works

Options and defaults

GET /products/{id} returns exactly the options a customer sees on the product page, each with its default and allowed values. Send only those keys in options. Leave an option out and it gets the default shown there, which is the product page’s starting configuration. For a few products (such as posters) the page adjusts its start after live availability checks, so send every option that matters to you.

Options the website sets automatically (production method, sheet size, bundling, packaging and similar) are set by us in the same way and cannot be sent: an unknown or automatic key returns 422 invalid_options with the list of allowed keys. For the same choices, the API price equals the website price.

A quote line returns options (the customer options used, including the defaults we filled in; safe to send back unchanged in POST /orders) and derivedOptions (read-only, for information).

Quantity and run size

quantity is always the number of pieces. Products with quantity.pricedBy = "copies" are priced per run: 25,000 flyers is quantity 25000 and the price follows the run. Do not send options.copies; if you do, it must equal quantity, otherwise you get 422.

If a run size cannot be produced for a configuration, you get 422 with the nearest available quantity instead of a price for a different run.

Validation and errors

A malformed body returns 400 invalid_request. A well-formed request that cannot be fulfilled returns 422 with error.details: one entry per problem with line, field, code and, where useful, allowedValues or allowedKeys. A combination our print partner does not offer (for example green UV coating on 300 g silk) names the conflicting options and the compatible values. Messages are in English.

Product ids and slugs

Use the product id as the stable key in quotes, orders and your own systems. Slugs differ per language and can change. GET /products/{id} accepts the id or the slug in any language; the slugs field lists them all.

Prices and rounding

All amounts are in euro cents and line totals are authoritative. Unit prices below one cent are not rounded: unitNetPrice on a quote line and unitPrice on an order item are exact decimal strings; unitPriceCents is rounded to whole cents.

discountCents is your reseller discount on the retail price, excl. VAT, per order item and in the order totals.

Print files

fileUrl must be a public https URL of a print-ready PDF, PNG or JPG. When you place the order we read the first bytes of the file (following redirects) and check the real file type. EPS, AI, PS and SVG are accepted too when the URL ends in the matching extension; we convert them to PDF before production, which can add a short manual step. Another type, such as an .ico or an HTML page, returns 422 invalid_file_url.

Recipient contact and carriers

Carriers need an email address and phone number for delivery messages. Send recipient.email and recipient.phone so they reach your customer. If either is missing, the email or phone of your reseller account is used instead, so the carrier contacts you. Every order shows carrierContact with emailSource and phoneSource (recipient or reseller).

User-Agent

Send your own User-Agent header, for example MyShop-Integration/1.0. Some generic library defaults, such as Python’s Python-urllib/3.x, are blocked by our CDN (error 1010) before they reach the API.

Webhooks

We POST JSON to your endpoint with the headers X-Printen-Event, X-Printen-Delivery, X-Printen-Timestamp and X-Printen-Signature. Respond with a 2xx status within 10 seconds. If that fails, we retry for about 24 hours. An event can arrive more than once: dedupe on the id field.

Events

  • order.created
  • order.submitted
  • order.awaiting_funds
  • order.payment_failed
  • order.in_production
  • order.shipped
  • order.delivered
  • order.cancelled
  • order.file_problem

Verifying the signature

X-Printen-Signature is v1= followed by the hex HMAC-SHA256 of "timestamp.raw body" using your endpoint secret. Reject messages with a timestamp older than 5 minutes.

Node.js

import crypto from 'node:crypto';

export function verifyPrintenWebhook(rawBody, headers, secret) {
  const timestamp = headers['x-printen-timestamp'];
  const signature = headers['x-printen-signature'] || '';
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const expected = 'v1=' + crypto.createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`).digest('hex');
  return signature.length === expected.length
    && crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}

PHP

<?php
function verify_printen_webhook(string $rawBody, string $timestamp, string $signature, string $secret): bool {
    if (abs(time() - (int) $timestamp) > 300) return false;
    $expected = 'v1=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
    return hash_equals($expected, $signature);
}

Python

import hashlib, hmac, time

def verify_printen_webhook(raw_body: bytes, timestamp: str, signature: str, secret: str) -> bool:
    if abs(time.time() - int(timestamp)) > 300:
        return False
    expected = "v1=" + hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

Errors

Every error has the shape {"error": {"code": "...", "message": "...", "details": [...]}}. 400 = malformed request; 422 = valid request that cannot be fulfilled (see details); 401, 403, 404, 409 and 429 as usual; 500 only for a failure on our side. Amounts are always in euro cents.

OpenAPI specification

Import the full specification (OpenAPI 3.1) into Postman, Insomnia or a code generator.

Download openapi.json