Skip to main content
Print products from 1 piece

Webhooks: receive order updates automatically

12 min readUpdated

With webhooks, Printen.eu sends you a message as soon as something happens to an order: awaiting balance, in production, shipped with tracking, delivered or cancelled. That way you do not have to keep polling the API. In this article you add an endpoint, verify the signature and build processing that copes with repeats.

In this article

How do webhooks work?

You give us an https URL on your own server. For every event we send a POST request there with a JSON message. Your server replies with a 2xx status, and then we know the message arrived.

From event to processing.
  • X-Printen-Event: the event type, for example order.shipped.
  • X-Printen-Delivery: an id for this delivery. Useful in your logs.
  • X-Printen-Timestamp: when it was sent, in seconds since 1970 (Unix time).
  • X-Printen-Signature: the signature (v1= followed by a hex code), which you use to check that the message really comes from us.

How do you add an endpoint?

The Webhooks tab in your dashboard.
  1. Open the tab

    In your dashboard, go to Webhooks.

  2. Enter your URL

    Under URL (https), enter the address of your own server, for example https://yourdomain.com/printen-webhook.

  3. Choose the events

    Tick the events you want to receive. By default order.shipped, order.payment_failed and order.awaiting_funds are ticked.

  4. Add the webhook

    Click Add webhook. You now see the secret, with the message Store this secret now. You will only see it once.

  5. Store the secret

    Put the secret straight into your server configuration, for example as an environment variable. You need it to verify the signature.

You can also add an endpoint through the API with POST /webhooks. If you leave out events or send an empty list, you receive all events. The secret appears only in this one response.

Add an endpoint through the API
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"]}'
Response
{
  "data": {
    "id": "WEBHOOK_ID",
    "url": "https://example.com/printen-webhook",
    "events": ["order.shipped", "order.delivered"],
    "allEvents": false,
    "active": true,
    "createdAt": "2026-10-10T09:00:00.000Z",
    "updatedAt": "2026-10-10T09:00:00.000Z",
    "secret": "WEBHOOK_SECRET"
  }
}
  • The URL must use https and be publicly reachable. Addresses such as localhost, internal network addresses and names ending in .local, .internal or .lan are rejected.
  • You can have up to 10 endpoints per account.
  • Each endpoint has its own secret.

Which events are there?

EventWhenWhat you usually do
order.createdAn order was created, through the API, the dashboard or a connected store.Store our order number with your own order.
order.submittedThe order is paid and goes to production.Set the order to "in progress" in your system.
order.awaiting_fundsYour balance is too low; the order is waiting.Alert yourself and top up.
order.payment_failedThe payment with your saved card failed.Check your card and retry the order in the dashboard.
order.in_productionProduction has started.Show the status. From now on the order can no longer be cancelled.
order.shippedThe parcel has shipped; tracking contains the code and link.Send the tracking to your customer or add it to your store.
order.deliveredThe parcel has been delivered.Close the order.
order.cancelledThe order was cancelled.Cancel the order in your system and process any refund.
order.file_problemThere is a problem with the print file. We will contact you.Check your file and reply to our message.

What does a message look like?

Every message has the same structure. Below is an example of order.shipped.

Example: order.shipped
{
  "id": "EVENT_ID",
  "type": "order.shipped",
  "created": "2026-10-10T14:32:05.000Z",
  "data": {
    "order": {
      "id": "ORDER_ID",
      "orderNumber": "RS-3F9A1C7B2E",
      "externalOrderId": "1001",
      "externalOrderNumber": "1001",
      "state": "SUBMITTED",
      "status": "SHIPPED",
      "tracking": {
        "code": "3SABCD1234567",
        "url": "https://example.com/track/3SABCD1234567"
      },
      "items": [
        {
          "productId": "PRODUCT_ID",
          "name": "Poster",
          "quantity": 1,
          "options": { "size": "A2" }
        }
      ]
    }
  }
}
FieldMeaning
idUnique id of the event. Treat it as an opaque string and use it to recognise duplicate messages.
typeThe event type, the same as the X-Printen-Event header.
createdWhen the event occurred (ISO 8601).
data.order.orderNumberOur order number, as shown in the dashboard.
data.order.externalOrderIdYour own order id, if you sent it when ordering.
data.order.state and statusThe status on your side (for example SUBMITTED) and the production status (for example SHIPPED). See order states.
data.order.trackingTracking code and tracking link, once the order has shipped.
Extra fields in dataFor order.awaiting_funds and order.payment_failed the reason is in reason. order.cancelled may include refundedCents. In the sandbox, order.submitted has a test field.

How do you verify the signature?

Anyone can send a request to your URL. So verify the signature of every message before you act on it. The signature is an HMAC-SHA256 using your endpoint’s secret.

  1. Read the headers

    Take X-Printen-Timestamp and X-Printen-Signature from the request.

  2. Check the time

    Reject the message if the timestamp differs from the current time by more than 5 minutes (300 seconds). That stops anyone from replaying an old message.

  3. Compute the expected signature

    Compute an HMAC-SHA256 with your secret over the text timestamp.rawBody: the timestamp, a dot and then the raw body. Prefix it with v1=.

  4. Compare safely

    Compare the result with X-Printen-Signature using a constant-time comparison. If they do not match, reply with 401 and process nothing.

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)

Pitfall: verify against the raw body

The signature is computed over the body exactly as we send it, byte for byte. Many frameworks parse the body as JSON automatically. If you then turn that JSON back into text, spacing, key order or characters can differ slightly, and the signature no longer matches.

Express: keep the raw body
import express from 'express';
import { verifyPrintenWebhook } from './verify-printen-webhook.js';

const app = express();

// express.raw (not express.json) keeps the body exactly as it was sent
app.post('/printen-webhook', express.raw({ type: 'application/json' }), async (req, res) => {
  const rawBody = req.body.toString('utf8');
  if (!verifyPrintenWebhook(rawBody, req.headers, process.env.PRINTEN_WEBHOOK_SECRET)) {
    return res.status(401).send('invalid signature');
  }
  const event = JSON.parse(rawBody);
  await saveEventForProcessing(event); // store first, process later
  res.status(200).send('ok');
});
  • PHP: read the body with file_get_contents("php://input"), not with $_POST.
  • Python (Flask): use request.get_data() for the raw bytes, not request.json.
  • Next.js (App Router): use await request.text() and only then JSON.parse.

What if your endpoint does not respond?

A delivery succeeds when you reply within 10 seconds with a 2xx status. Any other outcome counts as a failure: a timeout, an error status, but also a redirect (3xx). We then retry on this schedule.

AttemptWait after the previous attemptRoughly after the first attempt
1ImmediatelyStart
21 minute1 minute
35 minutes6 minutes
415 minutes21 minutes
530 minutes51 minutes
61 hour2 hours
72 hours4 hours
84 hours8 hours
98 hours16 hours
108 hours24 hours

After the tenth attempt we give up on that event. Your endpoint is never disabled automatically: we keep sending new events.

Handling duplicate and out-of-order events

Because of retries, the same event can arrive more than once, and events can arrive in a different order from the one in which they occurred. Build your processing with that in mind.

  • Dedupe on `id`: store the id of every processed event. If the same id arrives again, reply with 2xx and do nothing else.
  • Watch the order using `created`: if you have already processed a newer event for this order, do not overwrite a newer status with an older one.
  • Never move a status backwards: if order.shipped arrives after order.delivered, leave the order as delivered.
  • When in doubt, fetch the order: GET /orders/{id} always gives you the current state.

How do you test your webhook?

  1. Prepare your endpoint

    Make sure your URL is publicly reachable over https. If you work locally, use a tunnelling service that forwards a public https URL to your own computer.

  2. Add the endpoint

    Add it under Webhooks and tick order.created, order.submitted and order.cancelled (or leave out the events through the API to receive everything).

  3. Place a sandbox order

    Place an order through the API with a test key (prk_test_). Within a few minutes you receive order.created and order.submitted (with test in data).

  4. Cancel the test order

    Cancel the order with POST /orders/{id}/cancel. You then receive order.cancelled.

  5. Check your logs

    Check that the signature matched, that you returned 2xx and that a repeated message with the same id was ignored properly.

Troubleshooting

I receive nothing at all.

Check that the URL is correct, uses https and is reachable from the internet (not localhost or an internal address). Check that you ticked the right events. Keep in mind that an event can take a few minutes to arrive.

The signature never matches.

Check that you use the secret of this endpoint (each endpoint has its own secret) and that you verify against the raw body, not rebuilt JSON. See the raw body.

I receive messages twice.

That is normal after a timeout or error status. Dedupe on the event id. See duplicate events.

My server sends a redirect.

We do not follow redirects; a 301 or 302 counts as a failure. Use the final URL, paying attention to www, a trailing slash and http to https.

I did not store the secret.

Delete the endpoint and add it again. You get a new secret; update it in your server configuration.

I cannot add another endpoint.

You have reached the maximum of 10 endpoints. Delete one you no longer use first.

Still stuck?

We are happy to help, in person on working days and usually the same day.

All articles