Webhooks

Webhooks

TPP posts JSON to endpoints you own when a player reserves one of your products and when product availability changes. This page has everything you need to receive, verify and process those deliveries.

Endpoints are configured per establishment by TPP. Each event type is a separate configuration, so order.created, available-product and deleted-product normally arrive at three different URLs that you give us.

Signed deliveries follow the Standard Webhooks (opens in a new tab) specification, so you can verify them with a maintained, off-the-shelf library rather than writing crypto yourself.

Every delivery is signed. Signature verification is the only supported way to authenticate a TPP webhook. We never send an unsigned delivery, so treat any request without a valid signature as untrusted and reject it.

The wire contract

Every signed delivery carries three headers:

HeaderMeaning
webhook-idUnique per logical event. Identical across all retries — your deduplication key.
webhook-timestampUnix seconds for this attempt. Changes on every retry.
webhook-signatureOne or more space-separated signatures: v1,<base64> v1,<base64>

The signed content is those parts joined by literal full stops:

{webhook-id}.{webhook-timestamp}.{raw body}

HMAC-SHA256, output base64 — not hex. Your signing secret looks like whsec_…: the whsec_ prefix is stripped and the remainder base64-decoded to get the key bytes. The body is serialised once, signed, and transmitted unchanged.

Verify a delivery

Verification is mandatory. The URL you give us is reachable by anyone who learns it, so without a signature check an attacker can POST a fabricated order to your endpoint and you will process it as real. Treat an unverified request exactly as you would an unauthenticated one.

Verifying means all four of these, on every request, before you act on the body:

  1. Recompute the signature — HMAC-SHA256 over {webhook-id}.{webhook-timestamp}.{raw body}, keyed by your signing secret, encoded base64.
  2. Compare in constant time against every value in webhook-signature, accepting the request if any matches. Never use ==, === or strcmp.
  3. Reject stale timestamps — more than 300 seconds from your clock. This is what stops a captured request being replayed later.
  4. Use the raw body — the exact bytes we sent, before any JSON parsing.

Get any one of them wrong and verification either fails on every delivery or silently protects nothing. The official libraries do all four for you, which is why they are the recommended path.

Install the official library for your language. These are the packages maintained by the Standard Webhooks project — npm and Packagist both carry similarly-named third-party reimplementations, so check the coordinates match exactly and that the source is github.com/standard-webhooks/standard-webhooks (opens in a new tab).

LanguagePackageMinimum version
Nodenpm install standardwebhooks1.0.0
PHPcomposer require standard-webhooks/standard-webhooks1.0.2
Pythonpip install standardwebhooks1.1.0

Official libraries also exist for Go, Ruby, Java/Kotlin, Rust and C# in the same repository.

The library handles the signature comparison, the multiple-signature case and the timestamp freshness check for you. Pass it the raw request body — see below for why that matters more than anything else on this page.

Node / Express

const express = require("express");
const { Webhook } = require("standardwebhooks");
 
const app = express();
 
// whsec_… — issued by TPP, stored in your secret manager, never in source control.
const wh = new Webhook(process.env.TPP_WEBHOOK_SECRET);
 
// express.raw() hands you a Buffer of the EXACT bytes we sent. express.json()
// would parse and discard them, and the signature covers the bytes — see
// "Why the raw body matters" below.
app.use("/webhooks/tpp", express.raw({ type: "application/json" }));
 
function verified(req, res) {
    const rawBody = req.body.toString("utf8");
 
    try {
        // Throws on a bad signature, a signature that does not match any active
        // secret, or a timestamp outside the 300s tolerance. Returns the parsed
        // payload, so you never parse the body yourself.
        return wh.verify(rawBody, {
            "webhook-id": req.get("webhook-id"),
            "webhook-timestamp": req.get("webhook-timestamp"),
            "webhook-signature": req.get("webhook-signature"),
        });
    } catch (err) {
        res.status(400).send("invalid signature");
        return null;
    }
}
 
app.post("/webhooks/tpp/order.created", (req, res) => {
    const event = verified(req, res);
    if (!event) return;
 
    // Acknowledge immediately, then work asynchronously: we time out after ~8s
    // and retry anything we do not get a success response for.
    res.status(200).send("ok");
 
    handleOrderCreated(req.get("webhook-id"), event).catch((err) => {
        console.error("tpp order.created failed", err);
    });
});
 
app.post("/webhooks/tpp/available-product", (req, res) => {
    const products = verified(req, res);
    if (!products) return;
 
    res.status(200).send("ok");
    handleProductAvailability(products, "available").catch((err) => {
        console.error("tpp available-product failed", err);
    });
});
 
app.post("/webhooks/tpp/deleted-product", (req, res) => {
    const products = verified(req, res);
    if (!products) return;
 
    res.status(200).send("ok");
    handleProductAvailability(products, "unavailable").catch((err) => {
        console.error("tpp deleted-product failed", err);
    });
});
 
async function handleOrderCreated(webhookId, event) {
    // webhook-id is stable across every retry of this event — the dedup key.
    if (await seenBefore(webhookId)) return;
 
    // The public order object, same shape the orders endpoint returns.
    const { order } = event;
    await recordOrder({
        id: order.id,
        playerId: order.player_id,
        productId: order.product_id,
        reference: order.reference,
        status: order.status,
        totalAmountCents: order.total_amount_cents,
    });
 
    await markSeen(webhookId);
}
 
async function handleProductAvailability(products, availability) {
    // Product events carry the full current list, so they are idempotent:
    // apply as "latest state wins", ordered on occurredAt.
    for (const product of products) {
        await upsertProduct(product, availability);
    }
}
 
app.listen(3000, () => console.log("listening on :3000"));

PHP / Laravel

<?php
// routes/web.php — exclude webhook routes from CSRF in
// app/Http/Middleware/VerifyCsrfToken.php ($except = ['webhooks/tpp/*']).
 
use App\Http\Controllers\TppWebhookController;
 
Route::post('/webhooks/tpp/order.created', [TppWebhookController::class, 'orderCreated']);
Route::post('/webhooks/tpp/available-product', [TppWebhookController::class, 'availableProduct']);
Route::post('/webhooks/tpp/deleted-product', [TppWebhookController::class, 'deletedProduct']);
<?php
 
namespace App\Http\Controllers;
 
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use StandardWebhooks\Webhook;
 
class TppWebhookController extends Controller
{
    public function orderCreated(Request $request): Response
    {
        $event = $this->verify($request);
        if ($event instanceof Response) {
            return $event;
        }
 
        // webhook-id is stable across retries — the dedup key.
        ProcessTppOrderCreated::dispatch($request->header('webhook-id'), $event);
 
        return response('ok', 200);
    }
 
    public function availableProduct(Request $request): Response
    {
        $products = $this->verify($request);
        if ($products instanceof Response) {
            return $products;
        }
 
        ProcessTppProductAvailability::dispatch($products, 'available');
 
        return response('ok', 200);
    }
 
    public function deletedProduct(Request $request): Response
    {
        $products = $this->verify($request);
        if ($products instanceof Response) {
            return $products;
        }
 
        ProcessTppProductAvailability::dispatch($products, 'unavailable');
 
        return response('ok', 200);
    }
 
    /**
     * Verify the delivery. Returns the decoded payload, or a Response to
     * return directly when verification fails.
     *
     * @return mixed|Response
     */
    private function verify(Request $request)
    {
        // getContent() is the RAW body. Do NOT use $request->all(), which is
        // parsed and re-encoded — the signature covers the bytes we sent.
        $rawBody = $request->getContent();
 
        $webhook = new Webhook(config('services.tpp.webhook_secret'));
 
        try {
            // Throws on a bad signature, no matching active secret, or a
            // timestamp outside the 300s tolerance.
            return $webhook->verify($rawBody, [
                'webhook-id' => $request->header('webhook-id'),
                'webhook-timestamp' => $request->header('webhook-timestamp'),
                'webhook-signature' => $request->header('webhook-signature'),
            ]);
        } catch (\Throwable $e) {
            return response('invalid signature', 400);
        }
    }
}

Add the secret to config/services.php:

'tpp' => [
    'webhook_secret' => env('TPP_WEBHOOK_SECRET'), // whsec_…
],

Python / Flask

import logging
import os
 
from flask import Flask, request
from standardwebhooks import Webhook
 
app = Flask(__name__)
log = logging.getLogger(__name__)
 
wh = Webhook(os.environ["TPP_WEBHOOK_SECRET"])  # whsec_…
 
 
def verify():
    """Verify the delivery. Returns (payload, None) or (None, response)."""
    # get_data() is the RAW body. request.json would parse first and hand back
    # a re-serialised structure — the signature covers the bytes we sent.
    raw_body = request.get_data(as_text=True)
 
    headers = {
        "webhook-id": request.headers.get("webhook-id", ""),
        "webhook-timestamp": request.headers.get("webhook-timestamp", ""),
        "webhook-signature": request.headers.get("webhook-signature", ""),
    }
 
    try:
        # Raises on a bad signature, no matching active secret, or a timestamp
        # outside the 300s tolerance.
        return wh.verify(raw_body, headers), None
    except Exception:
        return None, ("invalid signature", 400)
 
 
@app.post("/webhooks/tpp/order.created")
def order_created():
    event, error = verify()
    if error:
        return error
 
    # webhook-id is stable across retries — the dedup key.
    webhook_id = request.headers["webhook-id"]
    if already_processed(webhook_id):
        return "ok", 200
 
    # The public order object, same shape the orders endpoint returns.
    order = event["order"]
    record_order(
        order_id=order["id"],
        player_id=order["player_id"],
        product_id=order["product_id"],
        reference=order.get("reference"),
        status=order.get("status"),
        total_amount_cents=order.get("total_amount_cents"),
    )
    mark_processed(webhook_id)
 
    return "ok", 200
 
 
@app.post("/webhooks/tpp/available-product")
def available_product():
    products, error = verify()
    if error:
        return error
 
    for product in products:
        upsert_product(product, availability="available")
 
    return "ok", 200
 
 
@app.post("/webhooks/tpp/deleted-product")
def deleted_product():
    products, error = verify()
    if error:
        return error
 
    for product in products:
        upsert_product(product, availability="unavailable")
 
    return "ok", 200
 
 
if __name__ == "__main__":
    app.run(port=3000)

Why the raw body matters

The signature covers the exact bytes we transmitted. If your framework parses the JSON and you then re-serialise it to verify, the bytes change — key order, numeric formatting, and escaping of non-ASCII characters all shift — and every delivery fails verification.

This is the single most common integration failure. Each handler above reads the raw body explicitly:

FrameworkRaw body
Expressexpress.raw({ type: "application/json" }), then req.body.toString("utf8")
Laravel$request->getContent()not $request->all()
Flaskrequest.get_data(as_text=True)not request.json

Verify first, then use the payload the library returns. Never parse the body yourself before verifying.

Verification without a dependency

If your organisation cannot add a package without a review cycle, the scheme is small enough to implement directly. Both snippets below are checked against the same signature fixtures as our sender, including the rotation and non-ASCII cases.

Prefer the official library where you can — it is maintained, and it already handles the edge cases these snippets have to spell out.

Node

const crypto = require("crypto");
 
const TOLERANCE_SECONDS = 300;
 
function verifyWebhook(rawBody, headers, secret) {
    const id = headers["webhook-id"];
    const timestamp = headers["webhook-timestamp"];
    const signatureHeader = headers["webhook-signature"];
 
    if (!id || !timestamp || !signatureHeader) return false;
    if (!/^\d+$/.test(String(timestamp))) return false;
 
    // Reject replays outside the freshness window.
    const now = Math.floor(Date.now() / 1000);
    if (Math.abs(now - Number(timestamp)) > TOLERANCE_SECONDS) return false;
 
    // Strip the whsec_ prefix, then base64-decode to the key bytes.
    const encoded = secret.startsWith("whsec_") ? secret.slice("whsec_".length) : secret;
    const key = Buffer.from(encoded, "base64");
 
    const expected = crypto
        .createHmac("sha256", key)
        .update(`${id}.${timestamp}.${rawBody}`, "utf8")
        .digest("base64");
    const expectedBytes = Buffer.from(expected, "base64");
 
    // Check EVERY signature. During a rotation the header carries one per
    // active secret and yours may not be the first.
    for (const part of signatureHeader.split(" ")) {
        const comma = part.indexOf(",");
        if (comma === -1) continue;
        if (part.slice(0, comma) !== "v1") continue;
 
        const candidateBytes = Buffer.from(part.slice(comma + 1), "base64");
 
        // crypto.timingSafeEqual THROWS when the lengths differ, so compare
        // lengths first. Unequal lengths already mean "no match".
        if (candidateBytes.length !== expectedBytes.length) continue;
        if (crypto.timingSafeEqual(candidateBytes, expectedBytes)) return true;
    }
 
    return false;
}

PHP

<?php
 
const TOLERANCE_SECONDS = 300;
 
function verifyWebhook(string $rawBody, array $headers, string $secret): bool
{
    $id = $headers['webhook-id'] ?? '';
    $timestamp = $headers['webhook-timestamp'] ?? '';
    $signatureHeader = $headers['webhook-signature'] ?? '';
 
    if ($id === '' || $timestamp === '' || $signatureHeader === '') {
        return false;
    }
    if (! ctype_digit((string) $timestamp)) {
        return false;
    }
 
    // Reject replays outside the freshness window.
    if (abs(time() - (int) $timestamp) > TOLERANCE_SECONDS) {
        return false;
    }
 
    // Strip the whsec_ prefix, then base64-decode to the key bytes.
    $encoded = str_starts_with($secret, 'whsec_') ? substr($secret, 6) : $secret;
    $key = base64_decode($encoded, true);
    if ($key === false || $key === '') {
        return false;
    }
 
    $expected = base64_encode(
        hash_hmac('sha256', "{$id}.{$timestamp}.{$rawBody}", $key, true)
    );
 
    // Check EVERY signature. During a rotation the header carries one per
    // active secret and yours may not be the first.
    foreach (explode(' ', $signatureHeader) as $part) {
        $comma = strpos($part, ',');
        if ($comma === false) {
            continue;
        }
        if (substr($part, 0, $comma) !== 'v1') {
            continue;
        }
 
        // hash_equals is constant-time and safe on unequal lengths.
        if (hash_equals($expected, substr($part, $comma + 1))) {
            return true;
        }
    }
 
    return false;
}

Never compare signatures with ==, === or strcmp. An early-exit comparison leaks the expected signature one byte at a time to anyone who can measure your response times.

Delivery behaviour

What we expect back

Respond 200–399. Any status in that range marks the delivery successful; 4xx and 5xx are treated as failures and retried.

Respond within 8 seconds — that is the default request timeout, and a timeout counts as a failure. Acknowledge first and process asynchronously, as every handler above does. Slow processing inside the request is the most common cause of unnecessary redeliveries.

Timestamp tolerance

Reject deliveries whose webhook-timestamp is more than 300 seconds (5 minutes) from your current time. This is the default in every official library, so you get it without configuration.

That check is what stops a captured request being replayed later, so it only works if your server clock is correct. Run NTP.

Retries

A failed delivery is retried approximately every 60 seconds for up to 14 days. There is no automatic pause: an endpoint that stays down keeps receiving retry traffic for the full window.

If you need deliveries stopped — during an incident or a long maintenance window — contact TPP to have your endpoint disabled. Returning 4xx does not stop them.

webhook-timestamp is regenerated on every attempt, so a delivery retried hours later still arrives inside the tolerance window. Do not treat it as the time the event happened.

Duplicates are expected

Deliveries are at-least-once, and duplicates are routine rather than rare. Messages are processed in batches, and a failure anywhere in a batch causes the whole batch to be redelivered — so a delivery you already answered 200 to can arrive again because a different event failed alongside it.

Deduplicate on webhook-id. It is identical across every retry of the same event. webhook-timestamp is not, and neither is a hash of the body.

Product events need no deduplication: each carries the full current product list, so applying one twice is a no-op.

Ordering is not guaranteed

Deliveries are processed concurrently and retried independently, so they can arrive out of order. A retried available-product can land after a later deleted-product for the same product.

Order on the payload, not on arrival. Product payloads carry occurredAt; discard an update whose occurredAt is older than the state you have already applied.

Secret rotation

An endpoint can have two active signing secrets at once. During a roll, the webhook-signature header carries one signature per active secret:

webhook-signature: v1,<signed with old secret> v1,<signed with new secret>

Accept the request if any signature matches your secret. The official libraries do this for you. If you hand-rolled verification, make sure your loop checks every space-separated value and does not stop at the first — that bug passes all your tests and then fails roughly half of all deliveries the moment a rotation starts.

A roll looks like this:

  1. TPP adds your new secret alongside the current one. Deliveries now carry two signatures and keep verifying against your existing secret.
  2. You deploy the new secret.
  3. TPP removes the old one.

Event reference

Five event types are delivered. Each is configured separately, so each normally has its own URL.

TypeFires when
order.createdA player reserves one of your products, creating an order.
order.status_changedAn order moves to sent, completed, cancelled or delayed.
order.shipping_updatedA tracking number, recipient or delivery address changes.
available-productA product becomes available — created, restocked, or status changed to available.
deleted-productA product becomes unavailable — stock exhausted, status changed, or removed.

The three order.* names match those used by TPP's newer platform, so handling written against these keeps working if your integration is later migrated.

Order events

All three order events share one payload shape: an order object holding the same public order you receive from the orders endpoint, plus event-specific context.

AttributeTypeDescription
idstringEvent identifier, e.g. msg_9f2ab3c1…. Same value as the webhook-id header, so you can deduplicate from either.
created_atstringISO-8601 timestamp of when the event happened. Fixed — unlike webhook-timestamp, it does not change on retry, so order events on this.
establishment_idstringYour establishment identifier.
typestringorder.created, order.status_changed or order.shipping_updated.
orderobjectThe full public order object — see below.
itemsarrayOrder lines. Always exactly one for now — see Migration notes.
previous_statusstringorder.status_changed only. The status the order moved from.
statusstringorder.status_changed only. The status the order moved to.

The order object carries the fields below. It is the same shape the orders endpoint returns, so a field added there appears here too.

AttributeTypeDescription
idstringOrder identifier.
operation_idstringInternal operation identifier.
player_idstringTPP player identifier.
product_idstringIdentifier of the ordered product.
referencestringHuman order reference, e.g. TPP-2607-160266.
statusstringpending, shipped, completed, cancelled or delayed.
display_statusstringDisplay form of the status.
order_typestringReservation or Delivery.
datestringISO-8601 order date.
total_amount_centsnumberOrder total in minor units1234 means 12.34. null if unknown.
currencystringISO currency code of total_amount_cents, or null if your establishment has none configured.
tracking_numberstringCarrier tracking number. Empty until one is set.
addressobjectRecipient and delivery address: first_name, last_name, address_line1, address_line2, city, province, country, postal_code, email.
city, country, postal_codestringConvenience copies of those address fields at the top level.
player_emailstringPlayer email.
establishment_idstringYour establishment identifier.
establishment_namestringYour establishment name.
establishment_imagestringEstablishment image URL.
establishment_thumbnail_urlstringEstablishment thumbnail URL.
provider_order_finalized_atstringISO-8601 timestamp when the provider finalised the order, or null.

Order statuses are lowercase: pending, shipped, completed, cancelled, delayed. These are the values TPP's newer platform uses, so status handling written here needs no translation if your integration is later migrated.

The order object is an allow-list: only the fields above are ever sent. Internal values such as cost, margin and provider details are never included, even though they exist on the underlying record.

Every key above is always present on the order object. A string with no value is sent as "" and providerOrderFinalizedAt as null — so you can read order.trackingNumber without a presence check, though it may be empty.

{
  "establishment_id": "522ef85f-7a5c-4ebb-b38d-46f40981be04",
  "type": "order.status_changed",
  "id": "msg_9f2ab3c1-4d5e-4a7b-9c8d-0e1f2a3b4c5d",
  "created_at": "2026-07-30T09:14:02.311Z",
  "previous_status": "pending",
  "status": "shipped",
  "order": {
    "id": "10db5998-99cd-438c-9861-620f390545f6",
    "operation_id": "op_7f3c1a",
    "player_id": "29df8b26-a2c8-4a45-9601-07fec4c4242d",
    "product_id": "ffc9e6ea-2fbb-4804-a738-50898cca6875",
    "reference": "TPP-2607-160266",
    "status": "shipped",
    "display_status": "shipped",
    "order_type": "Delivery",
    "date": "2026-07-29T11:13:27.000Z",
    "total_amount_cents": 1234,
    "currency": "EUR",
    "tracking_number": "TRACK-9",
    "address": {
      "first_name": "Ana",
      "last_name": "Pérez",
      "address_line1": "Calle Mayor 12",
      "address_line2": "",
      "city": "Madrid",
      "province": "Madrid",
      "country": "ES",
      "postal_code": "28013",
      "email": "ana@example.com"
    },
    "city": "Madrid",
    "country": "ES",
    "postal_code": "28013",
    "player_email": "ana@example.com",
    "establishment_id": "522ef85f-7a5c-4ebb-b38d-46f40981be04",
    "establishment_name": "888Hold",
    "establishment_image": "",
    "establishment_thumbnail_url": "",
    "provider_order_finalized_at": null
  },
  "items": [
    {
      "product_id": "ffc9e6ea-2fbb-4804-a738-50898cca6875",
      "product_name": null,
      "sku": "TPP-2607-160266",
      "quantity": 1,
      "status": "shipped",
      "is_completed": false,
      "recipient_name": "Ana Pérez",
      "recipient_email": "ana@example.com",
      "recipient_phone": null,
      "shipping_address": "Calle Mayor 12\n28013 Madrid\nMadrid\nES",
      "tracking_code": "TRACK-9"
    }
  ]
}

previous_status and status appear only on order.status_changed. A single update can produce two events — changing an order to shipped while also setting a tracking number emits order.status_changed and order.shipping_updated, each with its own webhook-id, so handle them independently.

Note the difference between the two levels: every key on the order object is always present (empty string or null when unset), but keys on the event envelope are omitted when they do not apply — previous_status and status simply are not there on order.created or order.shipping_updated. Read envelope fields with a presence check; order fields you can read directly.

Order line items

items mirrors the array TPP's newer platform sends, so code that iterates it keeps working after a migration. A legacy reservation is always one product, so the array always has exactly one element — iterate it anyway rather than reading items[0].

Field names are snake_case here, matching the newer platform, while the order object above stays camelCase. That inconsistency is deliberate: it is what lets item-handling code port across unchanged.

AttributeTypeDescription
product_idstringIdentifier of the ordered product.
product_namestringProduct name when known, otherwise null.
skustringOrder reference used as the line SKU.
quantitynumberUnits ordered.
statusstringLine status, same vocabulary as order.status.
is_completedbooleantrue once the line is completed.
recipient_namestringRecipient name, or null.
recipient_emailstringRecipient email, falling back to the player email.
recipient_phonestringRecipient phone, or null.
shipping_addressstringDelivery address as a newline-separated string.
tracking_codestringCarrier tracking number, or null.

No prices on line items. TPP order values are coin amounts, while the newer platform's unit_price_cents is currency. Carrying one under the other's name would silently corrupt reconciliation, so money is omitted here rather than guessed. Use order and your own catalog to price a line.

Migration notes

These webhooks are shaped to make a later move to TPP's newer platform cheap. What already matches:

Event namesorder.created, order.status_changed, order.shipping_updated — identical
Status valuespending, shipped, completed, cancelled — identical
Event identityid and created_at on the envelope — same role, same names
Line itemsitems[] with the same field names
Signature schemeStandard Webhooks on both, so your verification code is unchanged

What will change when you migrate:

  • The envelope nests order data under a data object.
  • The order object switches to snake_case and adds money (total_amount_cents, currency) and payment fields.
  • items[] becomes genuinely multi-line, which is why you should iterate it now.
  • establishment_id becomes sub_brand_id.
  • delayed has no counterpart on the newer platform.

available-product and deleted-product

Product events are still camelCase. The two *-product events send the product objects through unchanged from the products endpoint, so their keys (productId, imageUrl, establishmentId…) keep that casing. Only the order events are snake_case today.

Both are sent as a bare JSON array of the affected products — not an object, and with no type field in the body. The event type is identified by which of your configured endpoints received it.

Each entry is the full product object, priced for your establishment.

[
  {
    "productId": "05a3647a-18ed-4e62-bd4a-299ad4cd7f8e",
    "status": "available",
    "stock": -1,
    "reason": "stock-restored",
    "occurredAt": "2026-04-02T15:48:56.000Z",
    "reference": "",
    "price": 800,
    "type": "consumer",
    "digital": false,
    "featured": false,
    "imageUrl": "https://tpp-media-bucket.s3.eu-north-1.amazonaws.com/uploads/product-images/2026/04/02/row-26-fde08fb1-7ee3-4511-b4c7-5d0045d97e20.jpg",
    "catalogs": [
      {
        "establishmentId": "33450a3a-7053-4450-958d-889cb45332bc",
        "catalogId": "eec0cd74-57f2-456a-9113-7eae78c247ca",
        "name": "888Hold"
      }
    ],
    "providers": [],
    "medias": [],
    "translations": {
      "en-EN": {
        "name": "APPLE CARGADOR 5W - USB-A 5W POWER ADAPTER AA - WHITE",
        "description": "Power and connectivity at your fingertips.",
        "extendedDescription": "Certified and safe product. Fast shipping and official manufacturer warranty.",
        "additionalDetails": "Power and connectivity always within your reach."
      }
    },
    "category": {
      "categoryId": "a9ca822c-974f-4632-bd8f-97ed589229ac",
      "name": "Technology & Electronics",
      "translations": {
        "en-EN": { "name": "Technology & Electronics", "description": "" }
      }
    }
  }
]

Key fields for availability handling:

AttributeTypeDescription
productIdstringProduct identifier.
statusstringavailable or unavailable.
stocknumberRemaining stock. -1 means unlimited.
reasonstringWhy availability changed — see below.
occurredAtstringISO-8601 UTC timestamp of the change. Order on this field, not on arrival.

reason values:

CodeMeaning
product-createdThe product was created and is available.
status-availableStatus changed to available.
stock-restoredStock went from zero to positive or unlimited.
product-availableAvailable for another reason.
status-unavailableStatus changed to unavailable.
stock-zeroStock reached zero.
product-deletedThe product was removed.
product-unavailableUnavailable for another reason.

New reason codes may be added. Store unknown codes as received rather than rejecting the delivery.

Troubleshooting

Every delivery fails verification. Almost always the raw body. If you passed your framework's parsed object, or parsed and re-serialised the JSON before verifying, the bytes no longer match what we signed. Use express.raw(), $request->getContent() or request.get_data() and verify before parsing. Non-ASCII product names and escaped quotes make this fail even when the payload looks identical.

Signatures never match and the body is definitely raw. Check the encoding: the signature is base64, not hex. digest("hex"), hash_hmac(...) without the true binary flag, or .hexdigest() all produce a valid-looking string that never matches.

Still no match — check the secret. The whsec_ prefix must be stripped, and the remainder base64-decoded to raw bytes before use as the HMAC key. Using the whole whsec_… string as the key, or using the base64 text without decoding it, both yield a wrong key and a wrong signature.

Verification worked, then started failing at random. Clock skew. The timestamp check has a 300-second tolerance; if your server clock drifts past that, valid deliveries are rejected. Run NTP and compare your clock to webhook-timestamp on a failing request.

Verification fails for about half of deliveries during a rotation. Your code is only checking the first signature. During a roll the header carries several space-separated values (v1,… v1,…) and yours may be second. Loop over all of them and accept if any matches.

Everything fails after a proxy or WAF was added. Anything that modifies the body in transit breaks the signature — recompression, pretty-printing, charset rewriting, or appending a trailing newline. Some gateways also strip unknown headers. Log the exact bytes and the three webhook-* headers as received by your application, and compare against what your edge received.

The same event arrives repeatedly. Expected. Deliveries are at-least-once and a failing message redelivers its whole batch. Deduplicate on webhook-id, which is stable across retries. If duplicates persist without failures on your side, check that you answer within 8 seconds — a timeout is a failure and triggers a retry.

Product updates apply out of order. Deliveries are concurrent and retried independently, so arrival order is not event order. Compare occurredAt against the state you have stored and discard older updates.

A 4xx did not stop the retries. Nothing you return stops delivery. Any response outside 200–399 is a failure and is retried for up to 14 days. Contact TPP to have an endpoint disabled.

No webhook-signature header at all. We never send an unsigned delivery, so the header was stripped in transit — check your reverse proxy, API gateway or WAF for header filtering. Reject such requests; do not fall back to accepting them.

Empty or unparseable body. Product events are a bare JSON array, not an object, and carry no type field. Code that assumes every delivery is an object with a type key will fail on them. Branch on the endpoint that received the request, or on Array.isArray().