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.
X-Printen-Event: the event type, for exampleorder.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?
Open the tab
In your dashboard, go to Webhooks.
Enter your URL
Under URL (https), enter the address of your own server, for example
https://yourdomain.com/printen-webhook.Choose the events
Tick the events you want to receive. By default
order.shipped,order.payment_failedandorder.awaiting_fundsare ticked.Add the webhook
Click Add webhook. You now see the secret, with the message Store this secret now. You will only see it once.
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.
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"]}'{
"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,.internalor.lanare rejected. - You can have up to 10 endpoints per account.
- Each endpoint has its own secret.
Which events are there?
| Event | When | What you usually do |
|---|---|---|
order.created | An order was created, through the API, the dashboard or a connected store. | Store our order number with your own order. |
order.submitted | The order is paid and goes to production. | Set the order to "in progress" in your system. |
order.awaiting_funds | Your balance is too low; the order is waiting. | Alert yourself and top up. |
order.payment_failed | The payment with your saved card failed. | Check your card and retry the order in the dashboard. |
order.in_production | Production has started. | Show the status. From now on the order can no longer be cancelled. |
order.shipped | The parcel has shipped; tracking contains the code and link. | Send the tracking to your customer or add it to your store. |
order.delivered | The parcel has been delivered. | Close the order. |
order.cancelled | The order was cancelled. | Cancel the order in your system and process any refund. |
order.file_problem | There 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.
{
"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" }
}
]
}
}
}| Field | Meaning |
|---|---|
id | Unique id of the event. Treat it as an opaque string and use it to recognise duplicate messages. |
type | The event type, the same as the X-Printen-Event header. |
created | When the event occurred (ISO 8601). |
data.order.orderNumber | Our order number, as shown in the dashboard. |
data.order.externalOrderId | Your own order id, if you sent it when ordering. |
data.order.state and status | The status on your side (for example SUBMITTED) and the production status (for example SHIPPED). See order states. |
data.order.tracking | Tracking code and tracking link, once the order has shipped. |
Extra fields in data | For 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.
Read the headers
Take
X-Printen-TimestampandX-Printen-Signaturefrom the request.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.
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 withv1=.Compare safely
Compare the result with
X-Printen-Signatureusing a constant-time comparison. If they do not match, reply with 401 and process nothing.
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
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);
}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.
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, notrequest.json. - Next.js (App Router): use
await request.text()and only thenJSON.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.
| Attempt | Wait after the previous attempt | Roughly after the first attempt |
|---|---|---|
| 1 | Immediately | Start |
| 2 | 1 minute | 1 minute |
| 3 | 5 minutes | 6 minutes |
| 4 | 15 minutes | 21 minutes |
| 5 | 30 minutes | 51 minutes |
| 6 | 1 hour | 2 hours |
| 7 | 2 hours | 4 hours |
| 8 | 4 hours | 8 hours |
| 9 | 8 hours | 16 hours |
| 10 | 8 hours | 24 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
idof every processed event. If the sameidarrives 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.shippedarrives afterorder.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?
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.
Add the endpoint
Add it under Webhooks and tick
order.created,order.submittedandorder.cancelled(or leave out the events through the API to receive everything).Place a sandbox order
Place an order through the API with a test key (
prk_test_). Within a few minutes you receiveorder.createdandorder.submitted(withtestindata).Cancel the test order
Cancel the order with
POST /orders/{id}/cancel. You then receiveorder.cancelled.Check your logs
Check that the signature matched, that you returned 2xx and that a repeated message with the same
idwas 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.
Related articles
- The API: ordering from your own systemCreate an API key, request prices, place orders with an Idempotency-Key, manage your own products and test everything safely in the sandbox.
- Tracking orders, making changes and solving problemsTrack your orders, understand every status, place an order manually, cancel in time and know what to do about payment issues, address errors or damage.
Still stuck?
We are happy to help, in person on working days and usually the same day.