Webhooks
When seats are booked or released, holds expire or a venue publishes a new chart, venuery POSTs a signed notification to your endpoints. Prefer polling? The same notifications are in a feed.
Add an endpoint
curl https://www.venuery.com/v1/webhook_endpoints -u sk_live_…: \
-d '{"url":"https://tickets.example.com/hooks/venuery","events":["objects.booked","hold.expired"]}'The response has the endpoint's signing secret (whsec_…), shown this once. Endpoints made with a test key get test-mode notifications only. Leave out events to get every type.
What you receive
venuery-signature: t=1779991506,v1=5f0c4e…
venuery-notification-id: ntf_812
content-type: application/json
{
"id": "ntf_812",
"object": "notification",
"type": "objects.booked",
"created_at": "2027-05-20T18:05:06.000Z",
"livemode": true,
"data": {
"event": "evt_9q1x7c4b0d8f3k2m",
"action": "book",
"objects": ["s_4k9q2m8f1x7c"],
"ga": [],
"order_ref": "ORD-1042",
"hold": "hld_x7c4b0d8f3k2m9q1",
"metadata": { "email": "[email protected]" }
}
}| Type | When | data |
|---|---|---|
hold.created | A hold was made through the API or the seat map. | hold, event, objects, ga, expires_at |
hold.expired | A hold ran out before it was booked. Its seats are back on sale. | hold, event, objects |
objects.booked | Objects or GA places were booked. | event, objects, ga, order_ref, hold, metadata |
objects.released | Booked places were freed. | event, objects, ga, order_ref |
objects.status_changed | Places were blocked or unblocked. | event, action, objects, ga, status |
season.booked | Seats were booked for a whole season. | season, objects, ga, order_ref, events, metadata |
season.released | Season seats were released. | season, objects, ga, order_ref, events |
order.completed | venuery's own checkout sold tickets (ticketed events). | order, event, objects, ga, total_cents, tickets |
order.refunded | A venuery checkout order was refunded. | order, event, refund, amount_cents, fully_refunded |
event.updated | An event changed: name, time or status (on sale, cancelled). | event, key, changes, status, starts_at |
chart.published | A new chart version was published. | chart, version, chart_version, capacity |
Check the signature
venuery-signature holds a timestamp t and v1, the hex HMAC-SHA256 of `${t}.${raw body}` keyed with your endpoint's secret. Compute it over the raw body, before any JSON parsing, and reject old timestamps so a captured request can't be replayed.
import crypto from "node:crypto";
import express from "express";
function verify(secret, rawBody, header, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSeconds || !parts.v1) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return parts.v1.length === expected.length && crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
}
const app = express();
app.post("/hooks/venuery", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(process.env.VENUERY_WEBHOOK_SECRET, req.body.toString("utf8"), req.get("venuery-signature") ?? "")) {
return res.sendStatus(400);
}
const n = JSON.parse(req.body);
// look up n.id to skip duplicates, then handle n.type
res.sendStatus(200);
});import hmac, hashlib, time
def verify(secret: str, raw_body: bytes, header: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t = int(parts.get("t", "0"))
if abs(time.time() - t) > tolerance or "v1" not in parts:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"])Retries and ordering
- Answer with any 2xx within 10 seconds. Anything else (including redirects, which aren't followed) is retried after 1, 2, 4, 8, 16, 32 and 64 minutes: eight tries over about two hours.
- A notification can arrive more than once, and not always in order. Use its
idto skip duplicates; when order matters, fetch the current state (/objects, the hold) rather than trusting the sequence. - Each endpoint's recent deliveries, with status codes and errors, are at /deliveries.
- Missed some? Everything stays in the notifications feed: page through it with
afterset to the last id you saw.
Your endpoint must be a public
https:// URL. Private and local addresses are refused; for local testing use a tunnel such as ngrok or Cloudflare Tunnel.