Webhooks
Webhooks push events to your server as HTTPS POST requests, signed so you can check they come from Status302. Use them for things you react
to; use the stream for continuous market data.
Webhooks need the Elite plan or higher. Manage endpoints in Settings → Developers → Webhooks or over the API with the
webhooks:write scope. An organization can have up to 50 endpoints.
Events
Section titled “Events”| Type | Sent when | data |
|---|---|---|
alert.fired |
One of your organization’s alert rules fires. | The firing, as in GET /v1/alerts/events |
onsale.announced |
A new event, on-sale or presale is announced. | The onsales stream payload |
event.status_changed |
An event in the endpoint’s list changes status: on sale, off sale, cancelled, postponed or rescheduled. | {eventId, discoveryId, name, from, to, startsAt} |
list.sale |
A ticket sale on an event in the endpoint’s list. | The Sale schema with its event summary |
inventory.restocked |
Primary (face value) seats become available on an event in the endpoint’s list: new releases, returns, GA pools growing. | Market activity |
inventory.sold |
Primary seats on an event in the endpoint’s list are sold. | Market activity |
resale.listed |
Seats on an event in the endpoint’s list are listed on Ticketmaster resale. | Market activity |
resale.sold |
Ticketmaster resale listings on an event in the endpoint’s list disappear in a way that looks like a sale. | Market activity |
All types except alert.fired and onsale.announced follow a saved list: set the endpoint’s listId, and only events in
that list trigger them. The unfiltered sales firehose is only on the stream.
To send different events to different places, register one endpoint per destination with the types it needs: for example restocks to one URL and resale activity to another.
Market filter
Section titled “Market filter”markets limits list.sale and the four market-activity types to some markets:
| Market | Covers |
|---|---|
primary |
Ticketmaster face-value inventory: inventory.restocked, inventory.sold and primary list.sale |
tm_resale |
Ticketmaster verified resale: resale.listed, resale.sold and resale list.sale |
Leave markets empty (the default) for every market. More markets, such as secondary marketplaces as secondary:<marketplace>, will
be added to this list; treat values you don’t know as other markets. PATCH the endpoint with "markets": [] to remove the filter.
Market activity
Section titled “Market activity”Status302 polls each event’s Ticketmaster inventory and compares consecutive polls. Each of the four market-activity types is delivered at most once per poll of an event: one delivery carries every change of that type the poll found, grouped by section, row and price.
{ "id": "5d1c0e8a-77f3-5b0e-9a4c-2f61d0c3b8e4", "type": "inventory.restocked", "createdAt": "2026-11-05T00:00:02.114Z", "test": false, "data": { "event": { "id": 46, "name": "…", "startsAt": "…", "segment": "Music", "genre": "Rock", "venue": "…", "city": "…", "country": "US", "imageUrl": "…", "url": "https://www.ticketmaster.com/…/event/…" }, "market": "primary", "at": "2026-11-05T00:00:01Z", "quantity": 6, "truncated": false, "changes": [ { "kind": "restock", "section": "101", "row": "A", "seats": ["1", "2"], "quantity": 2, "priceMinor": 13510, "listMinor": 11500, "currency": "USD", "confidence": null }, { "kind": "ga_restock", "section": "GA Floor", "row": "", "seats": [], "quantity": 4, "priceMinor": 8900, "listMinor": 7500, "currency": "USD", "confidence": null } ] }}eventis the event summary, withurlits Ticketmaster page when known.marketisprimaryforinventory.*andtm_resaleforresale.*.atis the time of the poll.changeslists the seats that changed.kindisrestockorga_restockforinventory.restocked,saleorga_saleforinventory.sold,resale_listedforresale.listedandresale_removedforresale.sold. General admission pools have an emptyrowand noseats.priceMinoris the all-in price per ticket in minor units,listMinorthe face price before fees; both arenullwhen the seats have no known price.quantitycounts tickets over all changes. A poll with more than 200 changes delivers the first 200 and setstruncated.
The envelope id is derived from the poll and the type, so a poll is never delivered twice, even across retries and Status302 restarts.
Resale price changes on a listing that stays listed are not detected.
Set up an endpoint
Section titled “Set up an endpoint”-
Expose an HTTPS URL that accepts
POSTand answers2xxquickly. The samples below listen on/webhooks/status302. -
Register it with the event types it wants. The response contains the endpoint’s signing secret (
whsec_…) once; store it with your receiver.samples/curl/webhooks-create.sh #!/usr/bin/env bash# Register an endpoint for alert firings and new on-sales. The answer holds the# signing secret (whsec_…) exactly once: store it with your receiver.set -euo pipefailAPI="${STATUS302_API_URL:-https://api.status302.com}"curl --fail-with-body -sS "$API/v1/webhooks" \-H "Authorization: Bearer $STATUS302_API_KEY" \-H "Content-Type: application/json" \-d "{\"url\": \"$WEBHOOK_URL\", \"description\": \"alerts and on-sales\", \"events\": [\"alert.fired\", \"onsale.announced\"]}" -
Send a test event and check the delivery log:
samples/curl/webhooks-test.sh #!/usr/bin/env bash# Queue a signed test delivery ("test": true) to an endpoint, then read its# delivery log: status, attempts, response status and latency.set -euo pipefailAPI="${STATUS302_API_URL:-https://api.status302.com}"AUTH="Authorization: Bearer $STATUS302_API_KEY"curl --fail-with-body -sS -X POST "$API/v1/webhooks/$WEBHOOK_ID/test" -H "$AUTH" \-H "Content-Type: application/json" -d '{"type": "alert.fired"}' | jq '{id, eventId, status}'sleep 2curl --fail-with-body -sS "$API/v1/webhooks/$WEBHOOK_ID/deliveries?limit=5" -H "$AUTH" \| jq '.items[] | {id, event, test, status, attempts, responseStatus, latencyMs}'
Endpoint URLs must use https, must not contain credentials, and must resolve to public addresses: private, loopback and link-local
networks are refused when you register the URL and again when Status302 connects. Redirects aren’t followed.
Payload
Section titled “Payload”Every delivery has the same envelope:
{ "id": "0b9d7c1e-2f4a-4d6b-9e8f-1a2b3c4d5e6f", "type": "list.sale", "createdAt": "2026-11-05T00:00:00Z", "test": false, "data": { "ts": "2026-11-04T23:59:41Z", "eventId": 46, "scope": "primary", "kind": "sale", "section": "101", "row": "A", "seats": ["1", "2"], "qty": 2, "priceMinor": 13510, "listMinor": 11500, "currency": "USD", "confidence": "inferred_high", "event": { "id": 46, "name": "…", "startsAt": "…", "segment": "Music", "genre": "Rock", "venue": "…", "city": "…", "country": "US" } }}ididentifies the event. Retries and redeliveries repeat it: process eachidonce.typeis one of the events above.testistruefor test deliveries, whosedatais a realistic sample.createdAtis when the event happened, in UTC with millisecond precision.
Each request also carries these headers:
| Header | Value |
|---|---|
Status302-Signature |
t=<unix seconds>,v1=<hex HMAC-SHA256>, see verifying signatures |
Status302-Event |
The event type, e.g. alert.fired |
Status302-Delivery-Id |
The delivery’s id, the same on every attempt; a redelivery gets a new one |
Status302-Attempt |
1 for the first attempt, then 2, 3, … |
User-Agent |
Status302-Webhooks/1 |
Content-Type |
application/json |
Verifying signatures
Section titled “Verifying signatures”The signature is an HMAC-SHA256 over the timestamp, a dot and the raw request body, keyed with the whole signing secret (including
whsec_):
signed_payload = t + "." + raw_bodyv1 = hex(HMAC_SHA256(key = secret, message = signed_payload))To verify a delivery:
- Parse
tand everyv1fromStatus302-Signature. - Reject it if
tis more than 5 minutes from your clock: this stops replays of captured requests. - Compute the HMAC of
t + "." + bodyover the bytes you received, before any JSON parsing or re-encoding. - Compare it with each
v1in constant time. Accept if any matches.
Answer 2xx once verified (and stored), and do the actual work afterwards; answer 4xx to a failed verification.
// A webhook receiver on node:http. Verify the signature over the raw body// before parsing, answer 2xx fast, and process each event `id` once (retries// and redeliveries repeat it).import { createServer } from "node:http";import { verifyWebhook, WebhookVerificationError } from "@status302/sdk";
const secret = process.env.STATUS302_WEBHOOK_SECRET ?? "";const processed = new Set<string>(); // use your database in production
const server = createServer(async (req, res) => { if (req.method !== "POST" || req.url !== "/webhooks/status302") { res.writeHead(404).end(); return; } const chunks: Buffer[] = []; for await (const chunk of req) chunks.push(chunk as Buffer); const body = Buffer.concat(chunks);
try { const event = await verifyWebhook(body, req.headers["status302-signature"]?.toString(), secret); if (processed.has(event.id)) { console.log(`duplicate ${event.type} ${event.id} (delivery ${req.headers["status302-delivery-id"]})`); } else { processed.add(event.id); console.log(`verified ${event.type} ${event.id}${event.test ? " (test)" : ""}`); } res.writeHead(204).end(); } catch (err) { if (!(err instanceof WebhookVerificationError)) throw err; console.log(`rejected: ${err.message}`); res.writeHead(400).end(err.message); }});
server.listen(Number(process.env.PORT ?? 3000), () => console.log(`listening on :${process.env.PORT ?? 3000}`));# A webhook receiver on the standard library's http.server. Verify the# signature over the raw body before parsing, answer 2xx fast, and process each# event `id` once (retries and redeliveries repeat it).import osfrom http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from status302 import WebhookVerificationError, verify_webhook
SECRET = os.environ["STATUS302_WEBHOOK_SECRET"]processed: set[str] = set() # use your database in production
class Handler(BaseHTTPRequestHandler): def do_POST(self) -> None: if self.path != "/webhooks/status302": self.send_response(404) self.end_headers() return body = self.rfile.read(int(self.headers.get("Content-Length", "0"))) try: event = verify_webhook(body, self.headers.get("Status302-Signature"), SECRET) except WebhookVerificationError as err: print(f"rejected: {err}", flush=True) self.send_response(400) self.end_headers() return if event["id"] in processed: print(f"duplicate {event['type']} {event['id']} (delivery {self.headers.get('Status302-Delivery-Id')})", flush=True) else: processed.add(event["id"]) print(f"verified {event['type']} {event['id']}{' (test)' if event.get('test') else ''}", flush=True) self.send_response(204) self.end_headers()
def log_message(self, format: str, *args: object) -> None: pass # keep the output to the events above
port = int(os.environ.get("PORT", "3000"))server = ThreadingHTTPServer(("", port), Handler) # binds and listens hereprint(f"listening on :{port}", flush=True)server.serve_forever()// A webhook receiver with the standard library: verify Status302-Signature over// the raw body (HMAC-SHA256 of "<t>.<body>" keyed by the whole whsec_ secret),// reject timestamps older than 5 minutes, and process each event id once.package main
import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "log" "math" "net" "net/http" "os" "strconv" "strings" "sync" "time")
const tolerance = 5 * time.Minute
// verify checks a Status302-Signature header ("t=<unix>,v1=<hex>[,v1=<hex>]").func verify(secret, header string, body []byte, now time.Time) error { var ts string var sigs []string for part := range strings.SplitSeq(header, ",") { k, v, ok := strings.Cut(strings.TrimSpace(part), "=") switch { case !ok: case k == "t": ts = v case k == "v1": sigs = append(sigs, strings.ToLower(v)) } } t, err := strconv.ParseInt(ts, 10, 64) if err != nil || len(sigs) == 0 { return errors.New("malformed Status302-Signature") } if age := now.Sub(time.Unix(t, 0)); math.Abs(float64(age)) > float64(tolerance) { return fmt.Errorf("timestamp outside tolerance (%s)", age.Round(time.Second)) } mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(ts + ".")) mac.Write(body) expected := hex.EncodeToString(mac.Sum(nil)) for _, s := range sigs { if hmac.Equal([]byte(s), []byte(expected)) { return nil } } return errors.New("no signature matches the signing secret")}
type envelope struct { ID string `json:"id"` Type string `json:"type"` CreatedAt time.Time `json:"createdAt"` Test bool `json:"test"` Data json.RawMessage `json:"data"`}
func main() { secret := os.Getenv("STATUS302_WEBHOOK_SECRET") var mu sync.Mutex processed := map[string]bool{} // use your database in production
http.HandleFunc("POST /webhooks/status302", func(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) if err != nil { http.Error(w, "read error", http.StatusBadRequest) return } if err := verify(secret, r.Header.Get("Status302-Signature"), body, time.Now()); err != nil { log.Printf("rejected: %v", err) http.Error(w, err.Error(), http.StatusBadRequest) return } var ev envelope if err := json.Unmarshal(body, &ev); err != nil { http.Error(w, "bad json", http.StatusBadRequest) return } mu.Lock() dup := processed[ev.ID] processed[ev.ID] = true mu.Unlock() if dup { fmt.Printf("duplicate %s %s (delivery %s)\n", ev.Type, ev.ID, r.Header.Get("Status302-Delivery-Id")) } else { test := "" if ev.Test { test = " (test)" } fmt.Printf("verified %s %s%s\n", ev.Type, ev.ID, test) } w.WriteHeader(http.StatusNoContent) })
port := os.Getenv("PORT") if port == "" { port = "3000" } ln, err := net.Listen("tcp", ":"+port) // bind first: deliveries can start as soon as we report ready if err != nil { log.Fatal(err) } fmt.Println("listening on :" + port) log.Fatal(http.Serve(ln, nil))}The TypeScript and Python receivers use the SDKs’ verifyWebhook / verify_webhook; the Go receiver shows the algorithm with the
standard library only. All three are tested against real signed deliveries on every change.
Retries
Section titled “Retries”A delivery succeeds when your endpoint answers any 2xx status within 10 seconds. Anything else (another status, a timeout, a
connection error, a redirect) is a failure, and Status302 retries with exponential backoff: after 30 seconds, then 1, 2, 4, 8, 16, 32 and 64
minutes, then every 2 hours, for up to 24 hours after the event. Each attempt is signed afresh with a new t. Test deliveries are
attempted once and not retried.
Because of retries and redeliveries, the same event can arrive more than once: deduplicate on the envelope id. Deliveries can also
arrive out of order; use createdAt or the payload’s own timestamps to order them.
Automatic disabling
Section titled “Automatic disabling”An endpoint whose deliveries have all failed for 24 hours is disabled: Status302 stops sending to it, fails its pending deliveries and
emails the organization’s owners and admins. The endpoint shows the reason in Settings. Fix the receiver, then re-enable the endpoint
(PATCH /v1/webhooks/{webhookId} with "status": "active"); test deliveries don’t count towards disabling.
Delivery log and redelivery
Section titled “Delivery log and redelivery”GET /v1/webhooks/{webhookId}/deliveries lists the last 30 days of deliveries, newest first, with their status (pending, succeeded,
failed), attempt count, your response’s status and first 512 bytes, latency and next attempt time. The same log is in Settings.
To send a delivery again, for example after fixing a bug in your receiver:
#!/usr/bin/env bash# Send a delivery again (e.g. after fixing your receiver). The envelope keeps its# `id`, so a receiver that deduplicates on it processes the event once.set -euo pipefailAPI="${STATUS302_API_URL:-https://api.status302.com}"
curl --fail-with-body -sS -X POST \ "$API/v1/webhooks/$WEBHOOK_ID/deliveries/$DELIVERY_ID/redeliver" \ -H "Authorization: Bearer $STATUS302_API_KEY" | jq '{id, eventId, status, redeliveryOf}'A redelivery is a new delivery (new Status302-Delivery-Id, redeliveryOf pointing at the original) of the same envelope, id
included, so a receiver that deduplicates processes it once.
An endpoint can have at most 5 pending test deliveries and redeliveries at a time; further requests answer 429 with
"limit": "concurrent" and a Retry-After until some of them finish.
Rotating the signing secret
Section titled “Rotating the signing secret”POST /v1/webhooks/{webhookId}/rotate-secret (or Rotate secret in Settings) returns a new secret, which signs every delivery from that
moment; the old secret stops being used immediately. To rotate without failed deliveries, let your receiver accept a list of secrets:
- Rotate, and add the new secret to your receiver’s list next to the old one.
- Deploy. Deliveries that failed in between are retried and now verify.
- Remove the old secret.
Both SDKs’ verify functions accept an array of secrets for this.

