Appearance
Webhooks
Subscribe to HTTP callbacks when booking state changes — including changes you did not initiate (walk-ins, other partners). That is how you keep a local availability cache fresh.
Webhooks are best-effort notifications, not the record. A notification can arrive late, more than once, out of order, or — rarely — not at all. The API (GET /bookings, GET /bookings/{id}, GET /slots/availability) is the source of truth. See Delivery is best effort.
Auth for manage-subscription calls: x-api-key. The subscription is always yours; partner_id in the body is ignored.
Subscribe
http
POST /api/v1/partner/webhooks
Content-Type: application/json
x-api-key: <your-api-key>json
{
"endpoint_url": "https://partner.example.com/cbms/webhooks",
"event_types": [
"booking_created",
"booking_cancelled",
"hold_expired",
"hold_cancelled"
]
}201 includes secret_key. Store it. You need it to verify signatures. It is returned on create and on rotate (POST /api/v1/partner/webhooks/{id}/rotate-secret). Do not rely on listing subscriptions to retrieve it.
GET /api/v1/partner/webhooks lists your subscriptions.
There is no partner-facing unsubscribe or deactivate today. To stop deliveries, email the platform team at hello@venuesetu.com (or rotate the secret and reject unsigned traffic). That gap is on us, not something you should invent a DELETE for.
Event types
| Event | When |
|---|---|
hold_created | A hold was placed |
hold_cancelled | An ACTIVE hold was released before expiry |
hold_expired | A hold reached hold_expiry_at without confirm |
booking_created | A hold was confirmed. Not booking_confirmed. |
booking_cancelled | A booking was cancelled |
booking_refunded | A booking was refunded |
You receive events for subscribed types on the venues you are granted (the same venue grant availability and holds require), including other channels. Fan-out is by event type, scoped to your granted venues. If a grant is revoked, queued deliveries for that venue are dropped (not retried).
For a booking another channel made, details omits the customer's name, phone, email, payment reference and that channel's idempotency key. You get those only for bookings your own partner id created. To tell the two apart, use your own hold_id / booking_id records — see Which events are yours.
Delivery body
json
{
"event_id": "…",
"event_type": "booking_created",
"timestamp": "2026-09-20T17:12:43.812Z",
"venue_id": "…",
"slot_id": "…",
"hold_id": "…",
"booking_id": "…",
"partner_id": "…",
"details": {},
"delivery_id": "…"
}event_idis stable across retries of the same state change.delivery_ididentifies this attempt.timestampin the JSON is when the event happened. Do not use it for the replay window.detailsdepends on the event type — see Whatdetailscontains. Ignore unknown fields.partner_idis you — the partner this delivery is addressed to, i.e. the subscriber. It is the same on every delivery you receive, whoever caused the change. It does not identify the channel that made the hold or booking, and the body has no field that does.
Which events are yours
Because partner_id is always your own id, do not use it to decide whether an event came from your integration. Match the event against your own records instead:
hold_idorbooking_idmatches a hold/booking you created → the event is about your own customer's reservation (for example, the hold you placed expired, or the venue cancelled the booking you made).- No match → another channel (the venue's front desk, a walk-in, another partner) changed this slot. Treat it as a change to the same court and time — update your availability for
slot_id— not as something your integration did, and do not create a booking for it on your side.
For a booking another channel made, details also omits that customer's name, phone, email and payment reference (see above).
What details contains
Every key is optional; ignore keys you do not recognize. Keys marked own only appear only when the hold or booking was created with your key. shared_slot_states appears only at venues with shared courts.
| Event | details keys |
|---|---|
booking_created | customer_name (own only), customer_phone (own only), payment_reference (own only), shared_slot_states |
booking_cancelled | reason, freed_slot_ids, shared_slot_states |
booking_refunded | reason, previous_state ("BOOKED" or "CANCELLED"), freed_slot_ids, shared_slot_states |
hold_created | hold_duration_minutes, price_rupees, shared_slot_states |
hold_cancelled | reason, freed_slot_ids, shared_slot_states |
hold_expired | reason ("timeout"), idempotency_key (own only), shared_slot_states |
freed_slot_ids— slot ids that became bookable again because of this change. At venues with shared courts this can include the combined court as well as the one you booked.shared_slot_states—[{ "slot_id", "state" }]for combined or part courts whose state changed with this event (for example a full pitch becomingHELDbecause one half was held).reasonis the text sent with the cancel or refund, or a default when none was sent:"Customer request"(cancel),"Manager initiated refund"(refund, whoever refunds) or"Partner released hold"(hold release). Do not parse it.- There is no
customer_emailin any event. UseGET /bookings/{id}for the full booking.
Headers
http
Content-Type: application/json
X-Webhook-Event: booking_created
X-Delivery-Id: <same as delivery_id>
X-Delivery-Attempt: 1
X-CBMS-Timestamp: 1726850000
X-CBMS-Signature: sha256=<hex>X-CBMS-Timestamp is Unix seconds when this attempt was sent. Retries get a fresh timestamp and signature. A retry 45 minutes later would fail a 5-minute window if we signed the original event time.
Verify (Node)
HMAC-SHA256 over `${timestamp}.${rawBody}` using secret_key. Compare in constant time. Reject if |now - timestamp| > 300 seconds.
javascript
const crypto = require('crypto');
function verify(secretKey, timestampHeader, rawBody, signatureHeader) {
const timestamp = Number(timestampHeader);
if (!Number.isFinite(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
const match = /^sha256=([0-9a-f]+)$/i.exec((signatureHeader || '').trim());
if (!match) return false;
const expected = crypto
.createHmac('sha256', secretKey)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(match[1].toLowerCase(), 'utf8');
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}Use the raw body bytes as received. Re-serializing JSON will break the HMAC.
Retries and dead-letter
| Success | HTTP 2xx within 10 seconds |
| Retry | 5 attempts total |
| Backoff | 5 min, 15 min, 30 min, 60 min |
| Then | dead_letter — we stop retrying that delivery. No automated alert reaches you or VenueSetu staff when this happens. Check delivery counts with GET /partner/observability. |
Your handler should:
- Return 2xx quickly; queue downstream work.
- Be idempotent on
event_id(the same event can arrive twice if our 2xx was lost). - Tolerate out-of-order delivery.
- Never treat a missing notification as proof that nothing happened.
Delivery is best effort
We record the booking change first and queue its notifications straight after. If that queueing step fails, or your endpoint stays down past the last retry, you do not receive that notification, and nothing re-sends it. Plan for it:
- Reconcile on a schedule. Every few minutes during trading hours (or at least hourly), page through
GET /bookings?venue_id=…&from=…&to=…for the venues you sell and compare with your records. For availability you cache, re-readGET /slots/availabilitybefore you show a slot as bookable. - Reconcile after any gap. After your receiver was down, after deploys, or when you expected an event and none came, re-read the bookings and availability for the affected venues and dates.
- Act on the API, not the event. Before you tell a customer that a booking was cancelled or refunded, read it with
GET /bookings/{id}.
Checking deliveries
GET /api/v1/partner/observability— your subscription counts, delivery counts by status (pending,delivered,dead_letter, andsuppressedfor deliveries dropped because a venue grant was revoked) and recent events. A growingdead_lettercount means your endpoint is failing.GET /api/v1/partner/audit-events?venue_id=…&booking_id=…&limit=…— recent booking events visible to you, newest first (limitup to 200). Use it to see what you should have received.GET /api/v1/partner/webhooks/{partnerId}/signature-sample— a sample body and headers signed with your own webhook secret, to test your verifier before a real delivery arrives. It needs at least one subscription. The{partnerId}segment is required by the URL but ignored: the key decides who you are.
All three use your x-api-key. Field details are in the OpenAPI reference.
