Webhooks
Webhooks notify your app when something happens in a seller's store — an order is placed, a shipping label is generated, a show goes live — instead of you polling the API for changes.
Whatnot webhooks follow the Standard Webhooks specification, so you can verify them with the open-source Standard Webhooks libraries.
Setting up a subscription
Webhook subscriptions are configured per client app from your developer dashboard: register a URL and choose the topics you want delivered to it. There's no REST endpoint for managing subscriptions today.
Each topic requires a scope. Your app and the seller's installation must both hold it, or that seller's events aren't delivered to you.
Available topics
Each topic has its own page in the Webhooks reference showing its headers and payload, laid out like an API route.
| Topic | Description | Required scope |
|---|---|---|
orders. |
An order was placed, or for an offer, the seller accepted it. | read:orders |
orders. |
An order's status changed after it was placed, for example when it was cancelled or completed. | read:orders |
shows. |
A seller scheduled a show. Rehearsals aren't sent. | read:shows |
shows. |
A show changed, such as its title, start time or visibility. Every update is sent, even one that only changed updated_at. |
read:shows |
shows. |
A show went live, including when a stopped show resumed. | read:shows |
shows. |
A show ended, including when Whatnot ended an abandoned show. | read:shows |
shows. |
A show was cancelled. | read:shows |
merchandising. |
A product was added to a show. | read:merchandising |
merchandising. |
A product was removed from a show, including when its listing was deleted. | read:merchandising |
shipments. |
A shipment was created for an order. | read:shipments |
shipments. |
A shipping label was generated or regenerated for a shipment. | read:shipments |
shipments. |
A shipment got a tracking code, from a purchased label, the seller's own label or a replacement. | read:shipments |
shipments. |
The seller changed a shipment's parcel weight or dimensions. | read:shipments |
shipments. |
Items were added to a shipment, when orders were bundled at checkout or the seller bundled shipments. | read:shipments |
shipments. |
Items were removed from a shipment when it was split. | read:shipments |
products. |
A product was created. | read:inventory |
products. |
A product changed, including its variants, inventory or prices. The payload doesn't say which fields changed. | read:inventory |
products. |
A product was deleted. | read:inventory |
Some payloads include customer data only if your app holds read:customers. Without it, those fields are null:
| Topic | Customer fields |
|---|---|
orders.created, orders.updated |
buyer |
shipments.created |
recipient, destination_address, documents |
shipments.label_generated |
documents |
Destination addresses and shipping documents (labels print the buyer's address) also need customer-address access, which private apps have and public apps get once Whatnot approves them.
Delivery format
Every delivery is an HTTPS POST with a JSON body.
Headers
| Header | Description |
|---|---|
webhook-id |
Unique ID of the event. Retries of a delivery send the same ID, so use it to deduplicate. |
webhook-timestamp |
When this delivery attempt was sent, as a Unix timestamp in seconds. Each retry sends a new timestamp. The Standard Webhooks specification requires this format, so it differs from the payload's ISO 8601 timestamp. |
webhook-signature |
Space-separated signatures, each v1, followed by a base64 HMAC-SHA256 of the delivery. See Verifying signatures. |
Content-Type |
Always application/json. |
POST /webhooks/whatnot HTTP/1.1
Content-Type: application/json
webhook-id: 0b7e4f6c-3d1a-4a8e-9f2b-5c6d7e8f9a0b
webhook-timestamp: 1792512005
webhook-signature: v1,8m/VX9GLLqhNZJndMpPw7OTUKSDI4szDXAmIFY8ffBI=
{"type": "shows.started", "timestamp": "2026-10-20T16:00:00.123Z", "data": {"show_id": "9b2f6c1e-4d3a-4f8b-a1c2-3d4e5f6a7b8c"}, "version": "v2", "seller_id": "1234567"}
Payload
Every topic wraps its data in the same envelope:
{
"type": "shows.started",
"timestamp": "2026-10-20T16:00:00.123Z",
"data": { ... },
"version": "v2",
"seller_id": "1234567"
}
| Field | Description |
|---|---|
type |
The topic, such as orders.created. Use it to route the event. |
timestamp |
When the event happened, in ISO 8601 UTC with millisecond precision. Stable across retries, unlike the webhook-timestamp header. |
data |
The event's data. Its shape depends on the topic; each topic's page in the Webhooks reference documents it. Most topics send the same object the REST API returns, such as an order. |
version |
The payload version, v2. |
seller_id |
The ID of the seller the event belongs to. It's part of the signed body, so the signature also vouches for it. |
Verifying signatures
Verify every delivery before you trust it. Each signature is an HMAC-SHA256, keyed with your app's webhook secret, over the string {webhook-id}.{webhook-timestamp}.{body}. Any Standard Webhooks library can check it for you.
1. Remove the wn_wh_ prefix from your secret
Standard Webhooks libraries expect only the base64 key that follows the prefix, so remove wn_wh_ from the secret in your developer dashboard before passing it to one:
Secret: wn_wh_AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=
Signing key: AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=
Don't skip this step. The libraries strip only their own whsec_ prefix, so they read wn_wh_ as part of the key. Some, such as the Python and Ruby libraries, then decode a wrong key without raising an error, and every signature check fails with no hint that the secret is the problem.
2. Verify the delivery with a library
Pass the library the raw request body and the request headers. Use the bytes exactly as received: parsing and re-serializing the JSON can reorder keys or change whitespace, and the signature won't match.
// npm install standardwebhooks
import { createServer } from "node:http";
import { Webhook } from "standardwebhooks";
const secret = process.env.WHATNOT_WEBHOOK_SECRET.replace(/^wn_wh_/, "");
const wh = new Webhook(secret);
createServer(async (req, res) => {
if (req.method !== "POST" || req.url !== "/webhooks/whatnot") {
res.writeHead(404).end();
return;
}
// Read the body as the exact bytes Whatnot signed.
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
const body = Buffer.concat(chunks);
let event;
try {
event = wh.verify(body, req.headers);
} catch {
res.writeHead(400).end();
return;
}
res.writeHead(204).end();
// Process event.type and event.data after responding.
}).listen(3000);
# pip install standardwebhooks flask
import os
from flask import Flask, request
from standardwebhooks.webhooks import Webhook, WebhookVerificationError
wh = Webhook(os.environ["WHATNOT_WEBHOOK_SECRET"].removeprefix("wn_wh_"))
app = Flask(__name__)
@app.post("/webhooks/whatnot")
def whatnot_webhook():
try:
event = wh.verify(request.get_data(), dict(request.headers))
except WebhookVerificationError:
return "", 400
# Queue event["type"] and event["data"] for processing, then respond.
return "", 204
// go get github.com/standard-webhooks/standard-webhooks/libraries
package main
import (
"encoding/json"
"io"
"net/http"
"os"
"strings"
standardwebhooks "github.com/standard-webhooks/standard-webhooks/libraries/go"
)
type Event struct {
Type string `json:"type"`
SellerID string `json:"seller_id"`
Data json.RawMessage `json:"data"`
}
var wh *standardwebhooks.Webhook
func handle(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil || wh.Verify(body, r.Header) != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
var event Event
if err := json.Unmarshal(body, &event); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusNoContent)
// Process event.Type and event.Data after responding.
}
func main() {
secret := os.Getenv("WHATNOT_WEBHOOK_SECRET")
key := strings.TrimPrefix(secret, "wn_wh_")
var err error
wh, err = standardwebhooks.NewWebhook(key)
if err != nil {
panic(err)
}
http.HandleFunc("POST /webhooks/whatnot", handle)
http.ListenAndServe(":8080", nil)
}
# gem install standardwebhooks
require "standardwebhooks"
class WhatnotWebhooksController < ApplicationController
SECRET = ENV.fetch("WHATNOT_WEBHOOK_SECRET").delete_prefix("wn_wh_")
WEBHOOK = StandardWebhooks::Webhook.new(SECRET)
def create
event = WEBHOOK.verify(request.raw_post, {
"webhook-id" => request.headers["webhook-id"],
"webhook-timestamp" => request.headers["webhook-timestamp"],
"webhook-signature" => request.headers["webhook-signature"],
})
# Queue event["type"] and event["data"] for processing, then respond.
head :no_content
rescue StandardWebhooks::WebhookVerificationError
head :bad_request
end
end
<?php
// composer require standard-webhooks/standard-webhooks
require __DIR__ . '/vendor/autoload.php';
$secret = preg_replace('/^wn_wh_/', '', getenv('WHATNOT_WEBHOOK_SECRET'));
$wh = new \StandardWebhooks\Webhook($secret);
$payload = file_get_contents('php://input');
try {
$event = $wh->verify($payload, [
'webhook-id' => $_SERVER['HTTP_WEBHOOK_ID'] ?? null,
'webhook-timestamp' => $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? null,
'webhook-signature' => $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? null,
]);
} catch (\StandardWebhooks\Exception\WebhookVerificationException $e) {
http_response_code(400);
exit;
}
http_response_code(204);
// Process $event['type'] and $event['data'] after responding.
// dotnet add package StandardWebhooks
using System.Net;
using StandardWebhooks;
var secret = Environment.GetEnvironmentVariable("WHATNOT_WEBHOOK_SECRET")!;
var webhook = new Webhook(secret.Replace("wn_wh_", ""));
string[] signedHeaders = ["webhook-id", "webhook-timestamp", "webhook-signature"];
var app = WebApplication.Create(args);
app.MapPost("/webhooks/whatnot", async (HttpRequest request) =>
{
using var reader = new StreamReader(request.Body);
var payload = await reader.ReadToEndAsync();
var headers = new WebHeaderCollection();
foreach (var name in signedHeaders)
{
headers.Set(name, request.Headers[name]);
}
try
{
webhook.Verify(payload, headers);
}
catch (WebhookVerificationException)
{
return Results.BadRequest();
}
// Queue the payload for processing, then respond.
return Results.NoContent();
});
app.Run();
Each library rejects a delivery whose signature doesn't match or whose webhook-timestamp is more than 5 minutes from your server's clock, and returns or throws accordingly. Respond with a 4xx to a delivery that fails verification.
Verifying without a library
If no library fits your stack, compute the signature yourself:
- Build the signed content:
{webhook-id}.{webhook-timestamp}.{raw body}. - Compute an HMAC-SHA256 of it, keyed with the base64-decoded key from step 1, and base64-encode the result.
- Split
webhook-signatureon spaces. The delivery is valid if any entry isv1,followed by your signature. Compare in constant time. - Reject the delivery if
webhook-timestampis more than 5 minutes from your current time.
import base64
import hashlib
import hmac
import time
def verify(
raw_body: bytes, headers: dict[str, str], webhook_secret: str
) -> bool:
key = base64.b64decode(webhook_secret.removeprefix("wn_wh_"))
msg_id = headers["webhook-id"]
timestamp = headers["webhook-timestamp"]
if abs(time.time() - int(timestamp)) > 5 * 60:
return False
signed_content = f"{msg_id}.{timestamp}.".encode() + raw_body
digest = hmac.digest(key, signed_content, hashlib.sha256)
expected = base64.b64encode(digest).decode()
for entry in headers["webhook-signature"].split(" "):
version, _, signature = entry.partition(",")
if version == "v1" and hmac.compare_digest(signature, expected):
return True
return False
The example delivery under Headers is signed with the secret wn_wh_AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=. Use it to test your verification, skipping the timestamp check since that delivery is in the past.
Delivery guarantees
-
At least once, not exactly once. A delivery can be retried after you've already processed it — for example, if your endpoint accepts the request but the response times out before it reaches us. Always deduplicate (see below) rather than assuming one delivery per event.
-
No ordering guarantee. Events for the same resource can arrive out of order, especially across retries. Don't assume
orders.updatedalways arrives after theorders.createdfor the same order — use the payload'stimestampor the resource's own state to determine the current state, not arrival order. -
Retries use exponential backoff for up to 11 attempts over roughly 2.5 days, then the event is marked permanently failed and isn't retried further:
Attempt Delay before this attempt 2 ~31 seconds 3 ~1 minute 4 ~4.5 minutes 5 ~17.5 minutes 6 ~53 minutes 7 ~2.2 hours 8 ~4.7 hours 9 ~9.1 hours 10 ~16.4 hours 11 ~1.2 days Each attempt is signed again with a new
webhook-timestamp, so a retry passes the 5-minute timestamp check even days later. -
Only a
2xxresponse counts as success. Any other status, a timeout, or a connection failure is treated as a failure and queued for retry. Respond quickly (we time out at 5 seconds) and do your real processing asynchronously after responding, rather than making us wait on it. -
A failing endpoint is slowed down, then unsubscribed. If your endpoint doesn't respond to deliveries with a
2xxin a timely fashion, we reduce the rate we deliver to it, in hopes your service comes back up. If it keeps failing, you'll receive an email warning you that this is happening. If the failures continue for a prolonged period, we remove the subscription and email you to say so; subscribe again from your developer dashboard once your endpoint is healthy.
Deduplicating events
Every delivery carries a webhook-id header that uniquely identifies the event, and retries send the same ID. Store it, and if you receive a webhook-id you've already processed, skip it rather than reprocessing. This is the correct way to get exactly-once processing on top of an at-least-once delivery guarantee, and it also turns away a replayed delivery.
Best practices
- Return
2xximmediately, then process. Acknowledge the delivery first; do slow work (database writes, calling other services) in a background job afterward. - Verify the signature on every request before acting on the payload — don't rely on the URL being secret.
- Deduplicate by
webhook-id, since retries are expected, not exceptional. - Don't assume delivery order — reconcile based on the payload's own state and timestamps.
- Keep an endpoint for reconciliation. Webhooks are a notification mechanism, not a guaranteed event log — if your endpoint is down past the retry window, you'll miss events. Periodically reconcile against the API Reference (for example, listing recent orders or shipments) to catch anything a webhook didn't deliver.
- Handle unknown topics and fields gracefully. New topics and payload fields may be added over time; ignore what you don't recognize rather than failing.