# Guide to Whatnot Webhooks: Features and Best Practices

Whatnot webhooks tell your systems what is happening in a seller's shop: a product sells, an order is placed or cancelled, a listing changes, a livestream is scheduled or ends, or a shipping label is printed. If you are building an app on the Whatnot Seller API, the GraphQL API behind the live-shopping marketplace, webhooks are how that activity reaches your inventory, fulfilment and reporting systems without polling.

This guide covers how Whatnot webhooks are delivered and signed, the 11 topics and their payloads, the constraints that come with an API still in development preview, and the best practices for running a Whatnot receiver in production.

## What are Whatnot webhooks?

A Whatnot webhook is an HTTP `POST` to an endpoint you own, with a JSON body. Every delivery carries an `X-Whatnot-Webhook-Signature` header holding an HMAC-SHA256 of the raw request body, keyed with your app's webhook secret and encoded as lowercase hex.

The MAC covers the body bytes and nothing else. Whatnot signs no timestamp and adds no `sha256=` prefix, so if you have verified GitHub webhooks, it is the same computation with the prefix removed.

Every topic arrives in the same envelope, and the topic name is in the body rather than in a header. Whatnot describes the Seller API as a development preview with "a growing set of event topics", so new topics can appear without notice.

## Whatnot webhook features

| Feature | Details |
| --- | --- |
| Configuration | No dashboard or API during the preview. Subscriptions are requested from Whatnot by email with your App ID, the URL and the topic |
| Signature header | `X-Whatnot-Webhook-Signature: <hex digest>` |
| Scheme | HMAC-SHA256, lowercase hex, no prefix |
| Signed content | The raw request body, nothing else |
| Secret | One webhook secret key per app, issued by a Whatnot representative |
| Replay window | None. No timestamp is signed outside the body |
| Other headers | `X-Whatnot-Seller-Id`, the seller the event belongs to |
| Routing | The `topic` field in the body. There is no event-type header |
| Success response | `200 OK` specifically |
| Retries | Any other response is retried up to 5 times over about 10 minutes |
| Handshake | None documented |
| SDK | None for webhooks. Use your language's standard HMAC library |

No source IP allowlist is published, so the signature is the only credential.

## Common events

Whatnot documents 11 topics, each on its own page under the webhooks section of the developer docs. Route on the body's `topic` field.

| Topic | Sent when | Key `object` fields |
| --- | --- | --- |
| `product/sold` | A product is sold. Whatnot suggests using it to remove sold quantity from your own stock | `product_id`, `listing_id`, `order_id`, `variant_id`, `quantity` |
| `order/created` | An order is created | `order_id`, `items[]`, `subtotal`, `shipping_price`, `taxation`, `total`, `shipping_address`, `tracking_info` |
| `order/updated` | An existing order changes, including cancellation | Same shape as `order/created`, with `cancelled_at` set on a cancelled order |
| `listing/created` | A listing is created | `listing_id`, `product_id`, `quantity`, `transaction_type` (`AUCTION` or `BUY_IT_NOW`), `listing_status` |
| `listing/updated` | A listing is updated | Same shape as `listing/created` |
| `livestream/created` | A seller creates a livestream | `livestream_id`, `title`, `starts_at` |
| `livestream/stopped` | A livestream ends or is cancelled | `livestream_id` |
| `livestream/product_added` | A listing is added to a livestream | `livestream_id`, `listing_id`, `product_id` |
| `livestream/product_removed` | A listing is removed from a livestream | Same shape as `livestream/product_added` |
| `bulk_operation/finished` | A bulk operation completes, so you know when to fetch its result | `id`, `status` |
| `shipment/label_created` | A shipping label is generated or regenerated. Marked Beta | `shipment_id`, `reason`, `tracking_code`, `courier`, `label_url` |

Whatnot marks `shipment/label_created` as Beta: its types, behaviour and existence may change without notice, and Whatnot does not recommend it for production use.

Every delivery uses this envelope. Whatnot documents it with placeholder values rather than a real example:

```json
{
  "id": "<Event ID: UUID>",
  "timestamp": "<Event Timestamp: ISO 8601 UTC>",
  "topic": "<Event Topic>",
  "object": { "...": "<Object type specific to topic>" }
}

```

`id` identifies the event and is your idempotency key. `topic` names the event, for example `order/created`, and `object` holds the topic-specific payload. The per-topic pages document only the `object`, again with placeholders. Here is the documented `object` for `product/sold`:

```json
{
  "product_id": "<Product Global ID>",
  "listing_id": "<Listing Global ID>",
  "order_id": "<Order Global ID>",
  "external_id": "<Product External ID>",
  "variant_id": "<Variant Global ID, or null>",
  "variant_external_id": "<Variant External ID, or null>",
  "quantity": 1,
  "product_created_by_client_app_id": "<Client App ID that created the product, or null>"
}

```

IDs are GraphQL Global IDs, which you can pass straight back to the Seller API. `external_id` is your own product ID, if you set one. Money fields are objects with a string amount and an ISO 4217 currency, such as `{ "amount": "12.34", "currency": "USD" }`.

## Setting up Whatnot webhooks

During the development preview there is nowhere to add a webhook yourself:

1. Create a Seller API app, if you do not have one, and note its App ID.
2. Email Whatnot at the address on the [webhooks overview page](https://developers.whatnot.com/docs/webhooks/overview#creating-a-webhook) with your App ID, the URL that will receive the `POST` requests, and the topic or topics to subscribe to.
3. Store the webhook secret key the Whatnot representative gives you server-side, for example as `WHATNOT_WEBHOOK_SECRET`.
4. Request the `read_customers` scope for your app if you need shipping addresses, tracking details or label URLs. Without it those fields are `null` or missing.

The docs site renders its code blocks with JavaScript, so open it in a browser. A plain HTTP fetch returns the prose without the envelope, headers or sample code.

Because every subscription change goes through Whatnot staff, pick a URL you will not need to change. Pointing Whatnot at a stable gateway URL, and moving the destination behind it, saves a new request each time your receiver moves.

## Securing Whatnot webhooks

Read the raw body, compute HMAC-SHA256 over it with your webhook secret, encode the result as hex, and compare it with `X-Whatnot-Webhook-Signature` in constant time. Check that the header is 64 hex characters first. That rejects a base64 or prefixed value outright, and it stops Node's `timingSafeEqual` throwing on a length mismatch, which would surface as a 500 that Whatnot then retries.

```javascript
const crypto = require('crypto');
const express = require('express');

const HEX_SHA256 = /^[0-9a-f]{64}$/;

function verifyWhatnotSignature(rawBody, signature, secret) {
  if (!signature || !secret) return false; // fail closed

  const received = signature.trim().toLowerCase();
  if (!HEX_SHA256.test(received)) return false; // rejects base64, prefixes, wrong length

  // The secret is used as-is. The body is the raw bytes, never re-serialised JSON.
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
  return crypto.timingSafeEqual(Buffer.from(received, 'hex'), Buffer.from(expected, 'hex'));
}

const app = express();

app.post('/webhooks/whatnot', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.WHATNOT_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500);

  if (!verifyWhatnotSignature(req.body, req.get('x-whatnot-webhook-signature'), secret)) {
    return res.sendStatus(400);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  const sellerId = req.get('x-whatnot-seller-id');

  // Route on event.topic. Persist the event under event.id, then process async.
  // Whatnot expects exactly 200 OK, including for topics you don't handle yet.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac
import os
import re

HEX_SHA256 = re.compile(r"^[0-9a-f]{64}$")

def verify_whatnot_signature(raw_body: bytes, signature: str | None, secret: str | None) -> bool:
    if not signature or not secret:
        return False  # fail closed

    received = signature.strip().lower()
    if not HEX_SHA256.match(received):
        return False

    expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(received, expected)

# In FastAPI: raw_body = await request.body()
# verify_whatnot_signature(raw_body, request.headers.get("x-whatnot-webhook-signature"),
#                          os.environ["WHATNOT_WEBHOOK_SECRET"])

```

Whatnot's own Python sample computes the same digest with `hexdigest()` but compares it with `==`, which is not constant-time. Use `hmac.compare_digest` or `crypto.timingSafeEqual` instead. Whatnot's sample also takes the body as a string and re-encodes it. Most frameworks hand you bytes already, so sign those directly.

> Make Whatnot webhooks production-ready. [Hookdeck Event Gateway](/event-gateway) verifies the `X-Whatnot-Webhook-Signature` header, answers Whatnot with an immediate `200 OK`, and durably queues every order, listing and livestream event before your service sees it.

## Whatnot webhook limitations and pain points

### Ten minutes of retries, then nothing

The Problem: An outage longer than about ten minutes loses events for good, and an order or sale your systems never heard about has to be found some other way.

Why It Happens: Whatnot retries any response other than `200 OK` up to 5 times over roughly 10 minutes, then stops. There is no manual redelivery. Whatnot also asks for `200` specifically, so a receiver that answers `202 Accepted` or `204 No Content` is retried as if it had failed.

Workarounds:

* Return exactly `200` for every verified delivery, including topics you do not recognise yet.
* Acknowledge first and process afterwards, so slow work never turns into a failed delivery.
* After any outage, reconcile orders and listings from the Seller API for the period you were down.

How Hookdeck Can Help: Hookdeck answers each delivery as soon as it arrives and keeps it, then retries against your service on a schedule you set and lets you replay any event, so your own downtime does not count against Whatnot's ten-minute budget.

### Nothing in the signature limits replay

The Problem: A delivery captured in transit or from a log verifies forever, so it can be sent to your endpoint again at any time.

Why It Happens: The MAC covers only the body. There is no signed timestamp header and no tolerance window, and Whatnot documents no source IP range to filter on.

Workarounds:

* Store every envelope `id` and drop any delivery you have already processed.
* If you want a staleness check as well, compare the envelope `timestamp` with your clock. It sits inside the signed body, so it cannot be altered without breaking the signature. This is your own policy, not part of Whatnot's scheme, so allow for the ten-minute retry window.

How Hookdeck Can Help: Hookdeck verifies the signature at the edge and can deduplicate deliveries before they reach you, so a resent event is caught in one place.

### Subscriptions are changed by email

The Problem: Moving your receiver to a new URL, adding a topic or rotating a secret means waiting on a person at Whatnot.

Why It Happens: During the development preview there is no dashboard or API for webhook subscriptions, and the secret is issued by a Whatnot representative. Every change is a support request.

Workarounds:

* Give Whatnot a URL that will not move, such as a gateway or load balancer address you control.
* Ask for every topic you might need in the first request rather than one at a time.
* Keep your App ID and current subscription list written down, because there is nowhere to look them up.

How Hookdeck Can Help: A Hookdeck source gives you one fixed URL to register with Whatnot. You can then point it at local development through the CLI, staging or production by changing the connection, without asking Whatnot for anything.

### The docs show shapes, not payloads

The Problem: Code written against the documentation can break on the first real delivery, because no documented example is a real payload.

Why It Happens: Every example in Whatnot's webhook docs uses placeholder values such as `"<Product Global ID>"`, and the per-topic examples show only the `object`, without the envelope. There is no test-delivery button either, so the first real payload you see is a live one.

Workarounds:

* Treat ID formats and nullable fields as unknown until you have captured real deliveries.
* Null-check `shipping_address`, `tracking_info`, `label_url` and `customs`, which can be `null` or missing.
* Log the raw body of your first deliveries for each topic and build your fixtures from those.

How Hookdeck Can Help: Hookdeck keeps the full headers and raw body of every request a source receives, so the first live delivery of each topic becomes a real fixture you can inspect and replay into your handler.

### Some topics leave you to work out what happened

The Problem: A handler cannot tell an ended livestream from a cancelled one, or rely on the shipment topic staying as it is.

Why It Happens: `livestream/stopped` fires when a livestream ends and when it is cancelled, and its payload is just the `livestream_id`. `shipment/label_created` is marked Beta, so its fields, behaviour and existence may change without notice.

Workarounds:

* Query the livestream from the Seller API when you need to know how it stopped.
* Keep `shipment/label_created` handling behind a feature flag, and fall back to the order's `tracking_info` on `order/updated`.
* Answer `200` for unknown topics and log them, so a topic Whatnot adds during the preview shows up in your logs instead of in its retry queue.

How Hookdeck Can Help: Hookdeck filters let you route each topic to its own destination, so Beta or newly added topics can go to a separate consumer while you decide what to do with them.

## Best practices

### Verify against the raw body

The signature covers the exact bytes Whatnot sent. In Express, mount `express.raw()` on the webhook route and parse only after verification. In Next.js route handlers, read `await request.text()`. In FastAPI, read `await request.body()`. Parsing and re-serialising the JSON can change whitespace, key order or escaping, and the signature will not match.

### Use the secret exactly as issued

The webhook secret key is the HMAC key as a UTF-8 string. Do not hex-decode or base64-decode it, and trim any whitespace that crept in when it was pasted into your configuration. If every delivery fails, check this before anything else.

### Deduplicate on the envelope id

Retries resend the same event, so the same `id` can arrive more than once. Store it before you process, and skip anything you have already seen. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Acknowledge with 200 and process asynchronously

Verify, persist the event, return `200`, and do the real work afterwards. Whatnot gives you about ten minutes of retries, so a slow handler that times out burns through them quickly and produces duplicates on the way. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Keep money as strings

Money fields carry `amount` as a string such as `"12.34"`. Keep it as a string or parse it into a decimal type. Converting it to a floating-point number introduces rounding errors in totals and tax.

### Use the seller id in multi-seller apps

`X-Whatnot-Seller-Id` tells you which seller a delivery belongs to. If your app serves more than one seller, use it to look up the right account before you touch any data, and store it alongside each event.

## Conclusion

Whatnot signs every delivery with a lowercase hex HMAC-SHA256 of the raw body in `X-Whatnot-Webhook-Signature`, with no timestamp and no prefix. Verify against the raw bytes in constant time, route on the body's `topic`, deduplicate on the envelope `id`, and answer `200 OK` quickly, because ten minutes of retries is all you get.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks every `X-Whatnot-Webhook-Signature`, and holds each event until your service has processed it, so an outage on your side costs latency instead of lost orders.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Whatnot webhooks reliably in minutes.