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.created An order was placed, or for an offer, the seller accepted it. read:orders
orders.updated An order's status changed after it was placed, for example when it was cancelled or completed. read:orders
shows.created A seller scheduled a show. Rehearsals aren't sent. read:shows
shows.updated 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.started A show went live, including when a stopped show resumed. read:shows
shows.ended A show ended, including when Whatnot ended an abandoned show. read:shows
shows.cancelled A show was cancelled. read:shows
merchandising.products.added_to_show A product was added to a show. read:merchandising
merchandising.products.removed_from_show A product was removed from a show, including when its listing was deleted. read:merchandising
shipments.created A shipment was created for an order. read:shipments
shipments.label_generated A shipping label was generated or regenerated for a shipment. read:shipments
shipments.tracking_code_added A shipment got a tracking code, from a purchased label, the seller's own label or a replacement. read:shipments
shipments.parcel_updated The seller changed a shipment's parcel weight or dimensions. read:shipments
shipments.items.added Items were added to a shipment, when orders were bundled at checkout or the seller bundled shipments. read:shipments
shipments.items.removed Items were removed from a shipment when it was split. read:shipments
products.created A product was created. read:inventory
products.updated A product changed, including its variants, inventory or prices. The payload doesn't say which fields changed. read:inventory
products.deleted 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:

  1. Build the signed content: {webhook-id}.{webhook-timestamp}.{raw body}.
  2. Compute an HMAC-SHA256 of it, keyed with the base64-decoded key from step 1, and base64-encode the result.
  3. Split webhook-signature on spaces. The delivery is valid if any entry is v1, followed by your signature. Compare in constant time.
  4. Reject the delivery if webhook-timestamp is 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.updated always arrives after the orders.created for the same order — use the payload's timestamp or 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 2xx response 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 2xx in 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 2xx immediately, 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.