Agent skill
Whatnot Webhooks Skill
Receive and verify Whatnot webhooks (whatnot.com — the live-shopping and livestream-auction marketplace). Use when building a Whatnot developer app that handles topics like orders.created, orders.updated, shows.started, shows.ended, shipments.label_generated or products.updated, when debugging webhook-id / webhook-timestamp / webhook-signature (Standard Webhooks) verification with a wn_wh_ secret, or when migrating off the deprecated X-Whatnot-Webhook-Signature scheme. Whatnot signs base64 HMAC-SHA256 over `{webhook-id}.{webhook-timestamp}.{raw body}`, keyed with the base64-decoded secret after stripping `wn_wh_`, with a 5-minute timestamp tolerance. Route on the body's `type`; the payload is under `data`.
Install this skill
npx skills add hookdeck/webhook-skills --skill whatnot-webhooks
Whatnot (whatnot.com) is a live-shopping marketplace where sellers run livestream auctions and buy-it-now sales. This skill covers webhooks from the Whatnot developer platform (developers.whatnot.com). Whatnot signs every delivery with Standard Webhooks (webhook-id, webhook-timestamp, webhook-signature).
Canonical docs: Webhooks guide and the per-topic reference.
Whatnot replaced its webhook system in October 2026. If your handler reads
X-Whatnot-Webhook-Signatureor routes ontopic, it's on the old system. See Deprecated: the pre-2026-10 webhook system.
When to Use This Skill
- How do I receive Whatnot webhooks?
- How do I verify a Whatnot
webhook-signature? - Why does my Whatnot signature check fail with a
wn_wh_secret? - How do I handle
orders.created,shows.startedorshipments.label_?generated - How do I subscribe to a Whatnot webhook topic?
- How do I migrate from
X-Whatnot-Webhook-Signatureandproduct/sold-style topics?
Verification (core)
| Header | Value |
|---|---|
webhook-id | UUID, unique per event. Retries reuse it, so it's the dedupe key |
webhook-timestamp | Unix seconds when this attempt was sent. Changes on every retry |
webhook-signature | One or more space-separated v1,<base64 HMAC-SHA256> entries |
Signed content is {webhook-id}.{webhook-timestamp}.{raw body}. The key is the dashboard secret with its wn_wh_ prefix stripped, then base64-decoded. Reject timestamps more than 5 minutes from your clock.
const crypto = require('crypto');
function verifyWhatnot(rawBody, headers, secret) {
const id = headers['webhook-id'], ts = headers['webhook-timestamp'], sigs = headers['webhook-signature'];
if (!id || !ts || !sigs) return false;
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // 5-minute tolerance
const key = Buffer.from(secret.replace(/^wn_wh_/, ''), 'base64'); // strip, then decode
const expected = Buffer.from(
crypto.createHmac('sha256', key).update(`${id}.${ts}.${rawBody}`).digest('base64')
);
return sigs.split(' ').some((entry) => {
const [version, sig = ''] = entry.split(',');
const received = Buffer.from(sig);
return version === 'v1' && received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}
import base64, hashlib, hmac, time
def verify_whatnot(raw_body: bytes, headers, secret: str) -> bool:
msg_id, ts, sigs = headers.get("webhook-id"), headers.get("webhook-timestamp"), headers.get("webhook-signature")
if not (msg_id and ts and sigs) or abs(time.time() - int(ts)) > 300: # 5-minute tolerance
return False
key = base64.b64decode(secret.removeprefix("wn_wh_")) # strip, then decode
digest = hmac.digest(key, f"{msg_id}.{ts}.".encode() + raw_body, hashlib.sha256)
expected = base64.b64encode(digest).decode()
for entry in sigs.split(" "):
version, _, sig = entry.partition(",")
if version == "v1" and hmac.compare_digest(sig, expected):
return True
return False
The examples use the official standardwebhooks library (npm ^1.1.1, PyPI >=1.1.0) instead. Strip wn_wh_ before passing the secret to it. The library only strips its own whsec_ prefix. With wn_wh_ left on, the Node library throws and the Python library silently decodes the wrong key, so every check fails.
const { Webhook } = require('standardwebhooks');
const event = new Webhook(process.env.WHATNOT_WEBHOOK_SECRET.replace(/^wn_wh_/, ''))
.verify(rawBody, headers); // returns the parsed event; throws → respond 400
For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.
There is no Whatnot SDK for webhook verification.
Envelope
{
"type": "shows.started",
"timestamp": "2026-10-20T16:00:00.123Z",
"data": { "show_id": "9b2f6c1e-4d3a-4f8b-a1c2-3d4e5f6a7b8c" },
"version": "v2",
"seller_id": "1234567"
}
- Route on
type. There's no event-type header. - The topic-specific payload is under
data. Most topics send the same object the REST API returns, such as an order. timestampis when the event happened (ISO 8601, millisecond precision) and is stable across retries.seller_idis part of the signed body, so multi-seller apps can route on it.- There's no event
idin the body. Dedupe on thewebhook-idheader.
Topics
Each topic needs a scope that both your app and the seller's installation hold, or that seller's events aren't delivered.
| Topic | Scope | data |
|---|---|---|
orders.created | read:orders | Order |
orders.updated | read:orders | Order |
shows.created | read:shows | Show. Excludes rehearsals |
shows.updated | read:shows | Show. Fires even when only updated_at changed |
shows.started | read:shows | { show_id }. Also fires when a stopped show resumes |
shows.ended | read:shows | { show_id }. Includes Whatnot ending an abandoned show |
shows.cancelled | read:shows | { show_id } |
merchandising.products.added_ | read:merchandising | { product_id, show_id } |
merchandising.products.removed_ | read:merchandising | { product_id, show_id }. Also fires when the listing was deleted |
shipments.created | read:shipments | Shipment |
shipments.label_ | read:shipments | { shipment_ |
shipments.tracking_ | read:shipments | { shipment_ |
shipments.parcel_updated | read:shipments | { shipment_ |
shipments.items.added | read:shipments | { shipment_ |
shipments.items.removed | read:shipments | { shipment_ |
products.created | read:inventory | Product |
products.updated | read:inventory | Product. Doesn't say which fields changed |
products.deleted | read:inventory | { id } |
The topic string uses underscores (shipments.label_), but the reference page URLs hyphenate it (.../post/shipments.label-generated). Use the underscore form in code. Whatnot says new topics and fields may be added, so acknowledge unknown types with a 2xx. Full payload shapes are in references/overview.md.
Delivery
- Any 2xx counts as delivered. Whatnot times out after 5 seconds, so respond first and process asynchronously.
- Non-2xx responses, timeouts and connection failures are retried with exponential backoff, up to 11 attempts over about 2.5 days, then marked permanently failed.
- At least once, no ordering guarantee.
orders.updatedcan arrive beforeorders.created. Reconcile on the payload's own timestamps and state, never on arrival order. - A persistently failing endpoint is rate-limited, emailed a warning and eventually unsubscribed. Re-subscribe from the dashboard.
- Respond 4xx to a delivery that fails verification.
Gotchas
Use the raw body. Whatnot sends space-separated JSON ({"type": "..."}), so parsing and re-serializing it changes the bytes and breaks the signature. In Express, use express.raw() on the webhook route; in Next.js, await request.text(); in FastAPI, await request.body().
Strip wn_wh_, then base64-decode. HMAC with the decoded bytes, not the base64 text and not the full prefixed string.
Keep the timestamp check for direct deliveries. Every Whatnot retry is re-signed with a fresh webhook-timestamp, so the 5-minute window doesn't drop legitimate retries. Requests replayed later by a proxy that keeps the original headers (Hookdeck retries and replays, for example) fail it after 5 minutes, so behind Hookdeck rely on the source's verification or widen the window.
Customer data needs read:customers. Without it these are null, so null-check them: buyer on orders; recipient, destination_address and documents on shipments.created; documents on shipments.label_. Addresses and shipping documents also need customer-address access (private apps have it; public apps get it once Whatnot approves them).
Money is a string. { "amount": "49.77", "currency": "USD" }. Keep amount as a string or decimal, never a float.
No handshake and no IP allowlist. Whatnot documents neither.
Subscribing
In the Whatnot developer dashboard, open your client app, register your webhook URL and choose topics. There's no REST endpoint for managing subscriptions. Copy the webhook secret (wn_wh_...) from the same place. See references/setup.md.
Environment Variables
# REQUIRED. Copy from the developer dashboard, wn_wh_ prefix included.
WHATNOT_WEBHOOK_SECRET=wn_wh_your_base64_secret
The examples strip the prefix themselves and fail closed: with the secret unset they return 500 rather than skip verification.
Local Development
npx hookdeck-cli listen 3000 whatnot --path /webhooks/whatnot
No account required. The CLI gives you a public HTTPS URL to register in the Whatnot dashboard, plus a web UI showing each request's raw body and headers. (Use port 8000 for the FastAPI example.)
Deprecated: the pre-2026-10 webhook system
These topics are deprecated as of 2026-10-24. That's the date we confirmed the replacement; Whatnot published no migration notice or sunset date of its own.
| Deprecated topic | Status |
|---|---|
product/sold | Deprecated 2026-10-24 |
bulk_operation/finished | Deprecated 2026-10-24 |
listing/created | Deprecated 2026-10-24 |
listing/updated | Deprecated 2026-10-24 |
order/created | Deprecated 2026-10-24 |
order/updated | Deprecated 2026-10-24 |
livestream/created | Deprecated 2026-10-24 |
livestream/stopped | Deprecated 2026-10-24 |
livestream/product_added | Deprecated 2026-10-24 |
livestream/product_ | Deprecated 2026-10-24 |
shipment/label_created | Deprecated 2026-10-24 |
Whatnot no longer documents these names, the topic field, the {id, timestamp, topic, object} envelope or the X-Whatnot-Webhook-Signature hex-HMAC scheme. The old doc URLs return 404 as of 2026-10-28. Existing integrations must move to Standard Webhooks verification and the topics above.
There's no one-to-one rename table. The old resource model (product, listing, livestream, bulk operation) doesn't map directly onto the new one (shows, orders, shipments, merchandising, products), so choose new topics by what your handler needs rather than by name.
Reference Materials
- references/overview.md: envelope, every topic and its payload, Money and customer-data rules, delivery and retries
- references/setup.md: dashboard subscription, the
wn_wh_secret and scopes - references/verification.md: the signature byte by byte, Whatnot's golden test vector and debugging failures