Agent skill
Whatnot Webhooks Skill
Receive and verify Whatnot webhooks (whatnot.com — the live-shopping and livestream-auction marketplace). Use when building a Whatnot Seller API app that handles webhook topics like order/created, order/updated, product/sold, listing/created, livestream/stopped or bulk_operation/finished, or when debugging X-Whatnot-Webhook-Signature verification. Whatnot signs with HMAC-SHA256 over the RAW request body, lowercase hex, keyed with the app's webhook secret key (issued by a Whatnot representative). No timestamp, no prefix. The topic is in the JSON body (`topic`), the payload under `object`.
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 Seller API (developers.whatnot.com), a GraphQL API that Whatnot describes as being in development preview.
Canonical docs: Webhooks overview. The docs site renders its code blocks with JavaScript, so a plain HTTP fetch of the page shows the prose but not the envelope, headers or sample code. Open it in a browser.
When to Use This Skill
- How do I receive Whatnot webhooks?
- How do I verify
X-Whatnot-Webhook-Signature? - Why does my Whatnot signature check fail?
- How do I handle
order/created,product/soldorlivestream/stopped? - How do I subscribe to a Whatnot webhook topic?
- Does Whatnot retry failed webhooks?
Verification (core)
HMAC-SHA256 over the raw request body bytes, keyed with your app's webhook secret key, sent as a lowercase hex digest in X-Whatnot-Webhook-Signature. Nothing else is signed: no timestamp, no prefix, no field concatenation.
const crypto = require('crypto');
function verifyWhatnotSignature(rawBody, signature, secret) {
if (!signature || !secret) return false; // fail closed
const received = signature.trim().toLowerCase(); // hex is case-insensitive
if (!/^[0-9a-f]{64}$/.test(received)) return false; // also guards timingSafeEqual's length check
const expected = crypto
.createHmac('sha256', secret) // the webhook secret, as-is
.update(rawBody) // RAW bytes, never re-serialized JSON
.digest('hex');
return crypto.timingSafeEqual(Buffer.from(received, 'hex'), Buffer.from(expected, 'hex'));
}
Whatnot's own sample (Python, from the overview page) is the same computation:
calculated_signature = hmac.new(
bytes(secret, 'utf-8'), bytes(raw_request_body, 'utf-8'), hashlib.sha256
).hexdigest()
return calculated_signature == signature
It compares with ==. Use hmac.compare_digest (Python) or crypto.timingSafeEqual (Node) instead, as the examples here do.
Hookdeck's WHATNOT source type verifies the same way: HMAC-SHA256, hex, header x-whatnot-webhook-signature, over the raw body.
For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.
There is no Whatnot SDK for webhook verification. Use node:crypto, Web Crypto, or Python's hmac + hashlib.
Envelope
Every delivery is an HTTP POST with this body:
{
"id": "<Event ID: UUID>",
"timestamp": "<Event Timestamp: ISO 8601 UTC>",
"topic": "<Event Topic>",
"object": { "...": "<Object type specific to topic>" }
}
- Route on
topic. There is no event-type header. - The topic-specific payload is under
object. - Dedupe on
id. Retries resend the same event.
Headers
| Header | Value |
|---|---|
X-Whatnot-Seller-Id | The seller the event belongs to. Useful for multi-seller apps |
X-Whatnot-Webhook-Signature | Lowercase hex HMAC-SHA256 of the raw body |
Whatnot documents no other X-Whatnot-* headers.
Topics
| Topic | Sent when | object fields |
|---|---|---|
product/sold | A product is sold | product_id, listing_id, order_id, external_id, variant_id, variant_external_id, quantity, product_ |
bulk_operation/finished | A bulk operation completes | id, status (a GraphQL BulkOperationStatus) |
listing/created | A listing is created | product_id, listing_id, external_id, quantity, transaction_type (AUCTION or BUY_IT_NOW), listing_status, condition, category_name, category_id |
listing/updated | A listing is updated | Same shape as listing/created |
order/created | An order is created | IDs, quantity, Money totals, items[], shipping_address, tracking_info, cancelled_at (see overview) |
order/updated | An order is updated | Same shape as order/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 | product_id, livestream_id, listing_id, product_external_id |
livestream/product_ | A listing is removed from a livestream | Same shape as livestream/product_added |
shipment/label_created | A shipping label is generated or regenerated. Beta: Whatnot says it may change without notice | shipment_id, reason (label_generated or label_regenerated), tracking_code, courier, label_url, label_generated_at |
IDs are GraphQL Global IDs. Whatnot says more topics may be added during the preview, so acknowledge unknown topics with 200 rather than erroring.
Gotchas
Use the raw body. The digest covers the exact bytes Whatnot sent. Parsing the JSON and re-serializing it can change whitespace, key order or escaping and break the check. In Express, use express.raw() on the webhook route; in Next.js, await request.text(); in FastAPI, await request.body().
Respond with 200 OK. Whatnot's docs say the request "must be responded to with HTTP Status 200 OK". Any other status is retried up to 5 times over roughly 10 minutes. Return 200, not 202 or 204, and do slow work after responding.
Retries mean duplicates. Dedupe on the envelope id.
No replay protection is built in. There is no timestamp header and no tolerance window in the scheme. The envelope's timestamp sits inside the signed body, so it can't be altered without breaking the signature, but a captured delivery can still be resent unchanged. Deduping on id handles that. If you also want a staleness check, compare timestamp against the clock yourself. That's this skill's suggestion, not something Whatnot specifies.
Customer data needs a scope. shipping_address, tracking_info and label_url are only populated for apps granted the read_customers scope. Without it they're null or missing, so null-check them.
Money is a string. Monetary fields are { "amount": "12.34", "currency": "USD" }. Keep amount as a string or decimal, never a float.
No handshake and no IP allowlist. Whatnot documents neither. Don't invent one.
Subscribing
There's no dashboard or API for creating subscriptions during the preview. Contact Whatnot by email (the address is on the overview page) with your App ID, the URL that will receive the POSTs, and the topic. A Whatnot representative also gives you the webhook secret key.
Environment Variables
# REQUIRED. Your app's webhook secret key, issued by a Whatnot representative.
WHATNOT_WEBHOOK_SECRET=your_whatnot_webhook_secret
The examples 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 send Whatnot as your webhook URL, plus a web UI showing each request's raw body and headers. (Use port 8000 for the FastAPI example.)
Reference Materials
- references/overview.md: envelope, every topic and its payload, Money and customer-data rules, delivery and retries
- references/setup.md: getting a webhook secret and subscribing to topics during the preview
- references/verification.md: the signature byte by byte, a test vector, and debugging failures