Webhooks
Register a URL to receive order and listing events as they happen, instead of polling. All three
endpoints need the webhooks:manage scope. You can register up to 16 endpoints.
List your endpoints
GET /webhooks
This endpoint takes no parameters. Unlike the catalog and listings endpoints, the response is a plain
array under data, not paginated (you'll never have more than 16 endpoints, so
pagination would add nothing). The signing secret is never included here: it's
returned only once, at creation, see below.
Response fields
| Field | Type | Description |
|---|---|---|
id |
integer | The endpoint's id. |
url |
string | The URL deliveries are POSTed to. |
events |
array of strings | The subscribed event names (see Values). |
active |
boolean | False once auto-disabled after repeated failures. |
disabled_at |
string or null | When the endpoint was auto-disabled. ISO 8601. |
consecutive_failures |
integer | Failed deliveries in a row; resets on a successful delivery. |
created_at |
string | ISO 8601. |
curl "https://tcgplein.nl/api/v1/webhooks" \
-H "Authorization: Bearer tcgp_live_K7mP2xQ9vZ4nR8wT3yL6bC1jD5sF0gH7uM2oA9iE"
Register an endpoint
POST /webhooks
url must be a public http or https
URL. Registration only rejects what it can check synchronously: a non-http(s) scheme, or a literal
private/reserved IP address given directly as the host. No DNS lookup happens at registration time, so a
hostname that only resolves privately, like localhost or an internal DNS name,
is accepted here and returns 201. It's caught later instead: every delivery
attempt re-resolves the host first and refuses to send if it points to a private or reserved address,
failing that delivery outright with no retries. If your endpoint is never receiving deliveries, check the
deliveries log in your API dashboard for this error before
assuming the request never fired.
Body
| Parameter | Type | Required | Description |
|---|---|---|---|
url |
string | Required | A public http(s) URL, max 2048 characters. |
events |
array of strings | Required | Non-empty. Event names from the catalog below (also listed on Values). |
The response includes a one-time secret field, sibling to the endpoint object:
it's shown exactly once, right here, and is never retrievable again. Store it immediately: it's what you
use to verify the signature on every delivery.
Response fields
| Field | Type | Description |
|---|---|---|
data |
object | The created endpoint. |
data.id |
integer | The endpoint's id. |
data.url |
string | The URL deliveries are POSTed to. |
data.events |
array of strings | The subscribed event names (see Values). |
data.active |
boolean | Always true on creation. |
data.disabled_at |
string or null | Always null on creation. Set once auto-disabled after repeated failures. ISO 8601. |
data.consecutive_failures |
integer | Always 0 on creation. |
data.created_at |
string | ISO 8601. |
secret |
string | The plaintext signing secret, sibling to data. Shown exactly once, here. |
curl -X POST "https://tcgplein.nl/api/v1/webhooks" \
-H "Authorization: Bearer tcgp_live_K7mP2xQ9vZ4nR8wT3yL6bC1jD5sF0gH7uM2oA9iE" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks/tcgplein",
"events": ["order.created", "order.label_ready", "order.cancelled", "listing.sold"]
}'
Delete an endpoint
DELETE /webhooks/{id}
{id} is the endpoint's id; there are no other parameters and no body. Returns
204 No Content. An endpoint id that doesn't belong to you 404s.
Event catalog
| Event | Fires when |
|---|---|
| order.created | A checkout finalizes and creates an order row for one of your listings (once per row, not once per checkout). |
| listing.sold | Alongside order.created, for the same sale. |
| order.cancelled | An order row is cancelled and refunded (buyer refund, dispute resolved in the buyer's favour, or an admin void). |
| order.label_ready | A shipping label is generated. Can fire twice for the same order, see below. |
order.label_ready can legitimately arrive twice for the same order: once when
POST /orders/{id}/label first creates the shipment
(sometimes without a tracking barcode yet, if the carrier assigns it asynchronously), and again later if
the carrier backfills that barcode afterwards. Treat this event as "the tracking state changed", not as
"this fires exactly once per order". Use X-TCGPlein-Delivery (below) to tell
repeat deliveries of the identical event apart from this legitimate second, different delivery.
Delivery
Delivery is at-least-once, never assume exactly-once. Every delivery carries a unique
X-TCGPlein-Delivery id: use it as an idempotency key on your side (store
processed ids, skip duplicates) so a retried or repeated delivery never double-processes an event.
A failed delivery (connection error, timeout, or a non-2xx response) is retried with backoff: 1 minute, 10 minutes, 1 hour, then 6 hours after the first attempt (5 attempts total). If every attempt fails, the delivery is marked failed, and that counts as one failed delivery against the endpoint, regardless of how many attempts it took. After 10 consecutive failed deliveries, the endpoint is automatically disabled and you're notified by e-mail and on the dashboard; re-enable it from your API dashboard once the target is fixed.
Payloads
Every delivery POSTs the same JSON envelope: event (the event name),
delivery_id (this delivery's unique id, same value as the
X-TCGPlein-Delivery header), created_at (when the
event happened, ISO 8601; stable across retries) and data, the event-specific
fields. The full payload for each of the four events:
order.created
One delivery per order row, so a checkout with three of your listings produces three of these.
data.delivery_address is the buyer's shipping address, included so an external
order system can pack and ship without an extra GET /orders/{id} call;
house_number_suffix and phone are null when not
provided, and the whole delivery_address object can be null for orders without
a stored address.
{
"event": "order.created",
"delivery_id": 90142,
"created_at": "2026-08-11T09:14:00+00:00",
"data": {
"order_id": 9012,
"slice_key": "4471-88",
"listing_id": 5231,
"quantity": 1,
"price": 4.50,
"currency": "EUR",
"item_name": "Pikachu",
"buyer_name": "J. de Vries",
"delivery_address": {
"full_name": "J. de Vries",
"street": "Kerkstraat",
"house_number": "12",
"house_number_suffix": null,
"zip": "1234 AB",
"city": "Utrecht",
"country": "NL",
"phone": null
}
}
}
listing.sold
Fires alongside order.created for the same sale, from the listing's
perspective: how many units sold and how many remain. remaining_quantity: 0
means the listing just flipped to sold.
{
"event": "listing.sold",
"delivery_id": 90143,
"created_at": "2026-08-11T09:14:00+00:00",
"data": {
"listing_id": 5231,
"order_id": 9012,
"quantity_sold": 1,
"remaining_quantity": 2
}
}
order.cancelled
cancelled_by says who cancelled: buyer,
seller, admin or system
(see Values).
{
"event": "order.cancelled",
"delivery_id": 90731,
"created_at": "2026-08-12T16:03:00+00:00",
"data": {
"order_id": 9012,
"cancelled_by": "buyer"
}
}
order.label_ready
has_label is whether a printable PDF is available. When it is,
label_url holds the same signed download link
POST /orders/{id}/label returns, valid for
5 days from this delivery, so you can fetch the PDF straight from the webhook without a second
call. tracking_code can be null on the first delivery if the carrier
assigns the barcode asynchronously, in which case a second delivery follows once it lands
(with label_url null when no printable label exists).
{
"event": "order.label_ready",
"delivery_id": 90514,
"created_at": "2026-08-11T10:41:00+00:00",
"data": {
"order_id": 9012,
"has_label": true,
"label_url": "https://tcgplein.nl/api-labels/9012?expires=1786968060&signature=6e35...",
"tracking_code": "3SMYPXXXXXXXXXX"
}
}
Headers
Every delivery carries these four headers:
| Header | Meaning |
|---|---|
| X-TCGPlein-Signature | Hex-encoded HMAC-SHA256 of the request. See Verifying signatures below. |
| X-TCGPlein-Timestamp | Unix timestamp (seconds) of this delivery attempt. Part of the signed string. |
| X-TCGPlein-Event | Same value as the body's event field. |
| X-TCGPlein-Delivery | Same value as the body's delivery_id. Your idempotency key. |
Verifying signatures
The signature is an HMAC-SHA256, hex-encoded, of the string {timestamp}.{body}
(the X-TCGPlein-Timestamp header value, a literal dot, then the exact raw request
body), keyed with your endpoint's signing secret. Compute it over the exact bytes you
received: never decode the JSON and re-encode it before verifying, since a different key order or
whitespace would silently produce a different signature. Always compare with a constant-time comparison
(hash_equals / timingSafeEqual), never ==
or ===, to avoid leaking the correct signature through timing. It's also good
practice to reject a delivery whose X-TCGPlein-Timestamp is more than a few
minutes old, as defense against a captured request being replayed later.
<?php
$secret = 'tcgp_whsec_K7mP2xQ9vZ4nR8wT3yL6bC1jD5sF0gH7uM2oA9iE'; // from POST /webhooks
$body = file_get_contents('php://input'); // the exact raw bytes, never re-encoded
$timestamp = $_SERVER['HTTP_X_TCGPLEIN_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_TCGPLEIN_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $timestamp.'.'.$body, $secret);
if (! hash_equals($expected, $signature)) {
http_response_code(400);
exit('Invalid signature');
}
if (abs(time() - (int) $timestamp) > 300) {
http_response_code(400);
exit('Stale timestamp');
}
$event = json_decode($body, true);