Developers / Webhooks
Webhooks
Subscribe to real-time events from Reservory. Webhooks enable you to sync bookings to your CRM, email platform, analytics, or custom systems.
Creating a Webhook Subscription
Create subscriptions at /dashboard/settings/developers in the operator dashboard (admin), or call the create API with an admin API key that has write scope. That makes REST hooks possible: an integration such as Zapier subscribes when a user turns a trigger on and calls DELETE /api/webhooks/endpoints/{id} when it is turned off. The create API accepts the same event names as the catalogue below.
POST /api/webhooks/endpoints
Content-Type: application/json
{
"url": "https://your-server.com/webhooks/reservory",
"events": [
"booking.created",
"booking.confirmed",
"booking.cancelled",
"booking.reissued",
"booking.rescheduled"
]
}The response contains your signing_secret — store this securely. It will not be shown again.
Event Taxonomy
Every data object also carries event_id, schema_version, entity_type and entity_id (the subject: booking, customer, wallet, membership or order). Booking events add the customer's contact fields. Fetch the full record from the v1 read API when you need more than the event carries.
Reservory currently emits these operator-subscribable events:
A customer (or operator at POS) created a booking. The booking may still be pending payment — listen for booking.confirmed if you only care about paid bookings.
Fires: After the booking transaction commits, across widget, POS and operator flows.
A booking became confirmed. charge_amount_cents is the order total after discounts and tax; confirmation alone does not prove payment.
Fires: After a booking transaction commits a confirmed status. Includes unpaid operator orders; inspect the payment ledger for money received.
An operator cancelled the booking. This does not imply a refund — listen for refund.created if you also care about money movement.
Fires: After a booking transaction commits cancelled status, including customer self-service.
An operator cancelled a booking and reissued the paid amount as a new gift card instead of a Stripe refund.
Fires: POST /api/bookings/[id]/reissue-gift-card.
An operator moved a confirmed booking to a different slot for the same experience. The payment is unchanged.
Fires: After a confirmed booking changes slot, including customer self-service.
An operator re-attributed a confirmed booking to a new customer. Subsequent emails will go to the new address.
Fires: POST /api/bookings/[id]/transfer by an operator with role manager+.
A booking entered refunded status. Use refund.created for each succeeded money movement.
Fires: After a booking transaction commits refunded status.
A refund was issued through Stripe. amount_cents is the requested amount; status reflects Stripe’s response.
Fires: When a refund transaction records succeeded status, including provider reconciliation.
A partial (or full) refund was issued via the keep-booking-active flow. The booking stays confirmed and the seat stays reserved — money was returned but the customer is still expected to attend.
Fires: When a refund with keep_booking_active reaches succeeded status.
A legacy or participant waiver was signed. Version 2 carries evidence identifiers, not signer names, signature bytes or signed access links. Linked legacy evidence does not emit a second event.
Fires: When new signed evidence commits, including online, POS and offline recovery.
A paid gift card sale committed through POS or online checkout. Redemption codes and recipient contact details are excluded.
Fires: When gift-card issuance commits with its sale record.
A cashier opened the POS register with a starting cash count.
Fires: POST /api/pos/shifts/open.
A manager closed the POS register. variance_cents is ending − expected: positive = over, negative = short.
Fires: POST /api/pos/shifts/close.
Guests were added to or removed from a booking. Party amendments carry the booking and the money deltas; participant-level changes carry only action and participant_id, with the booking as entity_id.
Fires: When a party amendment or participant change commits.
A customer record was created by a booking, import, POS sale or operator. Contact fields are read at delivery time.
Fires: After a customer insert commits. Anonymised customers never emit.
A customer’s email, name, phone, tags or marketing preferences changed. GET /api/v1/customers returns the full record.
Fires: After one of those changes commits. Booking-counter updates do not emit.
Gift card value was applied to a booking or order. Redemption codes are excluded.
Fires: When a gift card redemption commits.
An online membership or wallet purchase was started. It is unpaid until membership.activated, membership.subscription_started or wallet.funded.
Fires: When the online order is prepared for payment.
A retail order with no admissions was confirmed. Confirmation alone does not prove payment.
Fires: After the order commits confirmed status.
An unpaid online membership or wallet order was abandoned. The order is entity_id; there are no other fields.
Fires: When the pending online order is cancelled.
Paid value was added to a customer wallet.
Fires: When the wallet funding activates.
Wallet value was used as a POS tender.
Fires: When the POS order settles the wallet tender.
A refund returned value to the wallet it was paid from.
Fires: When a refund restores the wallet tender.
A purchase that funded the wallet was refunded and the wallet was debited. Online refunds add debited_cents, shortfall_cents and reason.
Fires: When a refund of the funding purchase commits.
A refund of wallet funding could not be fully debited because the value was already spent.
Fires: When an online funding refund leaves a shortfall.
The remaining wallet balance expired.
Fires: When the wallet expiry job runs.
A wallet card or token was issued, replaced or revoked.
Fires: When staff change wallet tokens.
A paid fixed-term membership started.
Fires: When the membership payment activates it.
A membership was created by a membership import.
Fires: When an import row commits.
A fixed-term membership reached its expiry.
Fires: When the membership lifecycle job expires it.
A member redeemed a plan benefit.
Fires: When the benefit use is recorded.
A member used a guest pass.
Fires: When the guest pass use is recorded.
An online booking reserved membership visits. The booking is entity_id.
Fires: When the online booking applies its pass uses.
A member discount was applied to a booking or order.
Fires: When the discount is recorded against the sale.
A membership was frozen. Recurring memberships add fee_cents.
Fires: When a freeze starts.
A frozen membership resumed; expires_at moves out by the frozen days.
Fires: When the freeze ends.
A membership moved to a different holder.
Fires: When staff change the holder.
A holder photo was captured. The image is not included.
Fires: When the photo is saved.
The first payment of a recurring membership succeeded.
Fires: When the first billing cycle is paid.
A recurring membership charge succeeded. recovered is true after earlier failures.
Fires: When a billing cycle is paid.
A recurring membership moved into a new paid period.
Fires: When a renewal cycle is paid.
A recurring membership charge failed. final is true when no retry remains.
Fires: When a charge attempt fails.
A recurring membership became past due after a failed charge.
Fires: When the subscription enters past due.
Access was paused because payment retries ran out.
Fires: When the final retry fails.
A recurring membership ended.
Fires: When the subscription ends, for any reason.
A member or staff scheduled a plan change.
Fires: When the change is requested.
A scheduled plan change took effect.
Fires: When the change applies.
A pending plan change was withdrawn.
Fires: When the pending change is cancelled.
A recurring membership freeze was scheduled.
Fires: When the freeze is booked.
A scheduled or active freeze was cancelled and billing resumed.
Fires: When the freeze is cancelled.
A new price was scheduled for a recurring membership.
Fires: When the operator schedules a plan price change.
Webhook Payload
Every webhook POST uses this envelope. id is the delivery UUID (also sent as X-Reservory-Delivery-Id). Dashboard "Send test event" deliveries add _test: true.
POST https://your-server.com/webhooks/reservory
X-Reservory-Delivery-Id: 00000000-0000-4000-8000-0000000000de
X-Reservory-Event: booking.confirmed
X-Reservory-Timestamp: 1716170400000
X-Reservory-Signature: v1,abc123def...
Content-Type: application/json
{
"id": "00000000-0000-4000-8000-0000000000de",
"event": "booking.confirmed",
"emitted_at": "2026-05-20T12:00:00.000Z",
"data": {
"schema_version": 2,
"booking_id": "00000000-0000-0000-0000-000000000001",
"experience_id": "00000000-0000-0000-0000-0000000000aa",
"venue_id": "00000000-0000-0000-0000-0000000000bb",
"slot_id": "00000000-0000-0000-0000-0000000000cc",
"customer_email": "sample@example.com",
"order_id": "00000000-0000-0000-0000-000000000001",
"is_primary_booking": true,
"charge_amount_cents": 4500,
"currency": "AUD"
}
}Signature Verification
Verify webhook authenticity using HMAC-SHA256. X-Reservory-Timestamp is milliseconds since epoch (same units as Date.now()). The signature is computed over ${timestamp}.${rawBody}:
import { createHmac, timingSafeEqual } from 'node:crypto';
// Pass the original UTF-8 request body BEFORE parsing JSON.
export function verifyWebhook(rawBody, headers, secret) {
const timestamp = headers.get('x-reservory-timestamp');
const signature = headers.get('x-reservory-signature');
if (!timestamp || !/^\d+$/.test(timestamp)) return false;
// Timestamp is milliseconds. Reject old requests and clock drift.
if (Math.abs(Date.now() - Number(timestamp)) > 5 * 60 * 1000) return false;
if (!signature || !/^v1,[a-f0-9]{64}$/.test(signature)) return false;
const expected = createHmac('sha256', secret)
.update(timestamp + '.' + rawBody).digest();
const received = Buffer.from(signature.slice(3), 'hex');
return received.length === expected.length && timingSafeEqual(received, expected);
}
// After verification: deduplicate X-Reservory-Delivery-Id in persistent
// storage, enqueue your work, then return 2xx within 10 seconds.
// During rotation, validate X-Reservory-Signature-Previous with the
// old secret using the same checks while you migrate to the new secret.Retry Policy
If your endpoint returns a non-2xx status or times out (10s), Reservory retries with the same backoff the delivery worker uses:
- Attempt 1: immediate
- Attempt 2: 30 seconds after the previous failure
- Attempt 3: 5 minutes after the previous failure
- Attempt 4: 30 minutes after the previous failure
- Attempt 5: 4 hours after the previous failure
- Attempt 6: 24 hours after the previous failure
After 6 failed attempts, the delivery is marked abandoned and logged at /dashboard/settings/developers. Twenty consecutive endpoint failures auto-disable the subscription until you resume it.
Signing Secret Rotation
When you rotate a signing secret at /dashboard/settings/developers, the old secret remains valid for 7 days via X-Reservory-Signature-Previous. Deploy verification for the new secret before the grace period ends.
Best Practices
- Always verify the signature before processing.
- Treat the timestamp as milliseconds and reject values older than 5 minutes to prevent replay attacks.
- Deduplicate on
X-Reservory-Delivery-Id(or bodyid) in persistent storage. - Return 200 quickly; process heavy tasks asynchronously.
- Store your signing secret in environment variables, never in code.
- Monitor delivery logs at
/dashboard/settings/developersto catch failures.