TCG Plein API Docs
Beta The seller API is still in beta, so small changes can happen while it settles. Questions or suggestions? Let us know on the support page.
Events

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);

Cookies op TCG Plein. We gebruiken noodzakelijke cookies om de site te laten werken, analytische cookies om te zien hoe bezoekers de site gebruiken en marketingcookies om te meten of onze advertenties werken. Analytische en marketingcookies plaatsen we alleen als je ze accepteert. Lees meer in ons cookiebeleid.