Skip to content

Keep your system in sync

You own the guest record. A hotel group’s CRM, an agency’s reporting warehouse, a loyalty programme’s member system — something that already knows who these people are, and now needs to know what they booked. The restaurant’s bookings have to arrive in it and stay current, including the ones nobody on your side made.

Three mechanisms carry that, and a sync that owns a CRM uses all three.

A webhook is a signal that something happened. It is not a guarantee that you heard about everything, and four properties of this delivery make that concrete:

  • Delivery is at-least-once and unordered. You may see reservation.seated before reservation.confirmed, and you may see either one twice.
  • An endpoint that fails for about three days is disabled, and events that fired while it was down are not queued for you.
  • Reservations imported from another provider emit nothing. A restaurant migrating from a previous system keeps importing, and none of that traffic fires a webhook.
  • A booking on a guaranteed slot is published late, and sometimes never. See Guaranteed bookings arrive out of order.

Webhooks tell you when to look. updated_since tells you what is true.

Build both. The webhook handler keeps your CRM live; a periodic updated_since sweep makes it correct again after an outage, a migration or a missed delivery. An integration with only the first is right most days and quietly wrong after the one day that matters.

Scope Why the sync needs it
reservations:read Reservations, their events, holds, and the waitlist queue. This is the whole booking surface.
guests:read Guest records: GET /v1/guests, ?expand=guest, and ?expand=modification_token on POST /v1/reservations. Also each booking’s contact details and guest notes, and each waitlist entry’s notes.

A booking carries its own party size, times and status; the person is a gst_… id and nothing more until you expand it. The booking’s own contact details, the guest’s notes and the restaurant’s own note (contact_email, contact_phone, guest_notes, internal_notes) are guest data too, absent from a key without guests:read: see Guest data on a booking. A key with only reservations:read therefore syncs a complete booking feed and no contact details at all.

That is the right key for a reporting warehouse, and the wrong one for a CRM. Decide which you are building, and ask the restaurant for the narrower scope when the narrower one does the job — guest names, emails and phone numbers are the restaurant’s own guest relationships, and a scope you do not hold is one you cannot leak. expand=guest is gated at a single chokepoint, so a key without guests:read gets 403 insufficient_scope with param: "expand" wherever the guest is reached from, including from inside a waitlist entry. See Scopes.

Register the endpoint, and verify what arrives

Section titled “Register the endpoint, and verify what arrives”

Webhook endpoints are self-serve: the restaurant adds yours under Settings → Developers, picks the events, and reveals a signing secret. Up to five endpoints per restaurant, each pinned to the API version it was created at.

Two pages cover the rest, and they are the reference rather than this one:

  • Webhooks overview — the envelope, retries, auto-disable, and why you deduplicate on event.id.
  • Verifying signatures — do this before you parse the body. Your endpoint URL is public; the signature is the only thing that says a delivery came from Service.

The event catalog lists all 30 event types with a full payload for each.

Your key’s scopes do not reach the endpoint. A delivery carries the booking’s contact details and notes, and the guest.* events carry the guest profile, even when the key you sync with holds only reservations:read. See Deliveries carry guest data.

The catalog answers this per event. Four patterns cause most of the sync bugs, and they are worth holding in your head while you write the handler.

A status you reach automatically fires nothing extra. reservation.confirmed fires when a pending booking is later approved. A booking that was auto-confirmed at creation never fires it — its reservation.created already carried status: "confirmed". A handler waiting for confirmed before writing the booking to your CRM will wait forever for most restaurants.

Some transitions fire two events. A hold that converts emits hold.converted and reservation.created, about the same party. A waitlist promotion emits waitlist_entry.promoted and a reservation event. Count one of each pair or you will count the covers twice.

A flag-only change now emits reservation.updated. Setting or clearing excluded_from_shift_limits on a booking used to change nothing you could see; it now fires, with previous_attributes carrying the old value. That is the signal that a booking left or joined the service’s covers pool, which matters to anything of yours that reports on capacity. See Book past the rules.

previous_attributes rides on *.updated events only, and of those only reservation.updated and waitlist_entry.updated build one — guest.updated carries none. On every other type data.object is the full resource and the diff is yours to compute against your own copy. The field’s own rule is on The webhook event object.

This one is worth its own section, because a webhook-only sync gets it wrong in both directions.

A create on a slot carrying a card guarantee returns 201 with the booking in awaiting_guarantee, and the guest is emailed a pay-link. The booking is readable by your key from that moment — GET /v1/reservations/{id} resolves it, and ?status=awaiting_guarantee matches it. But its reservation.created webhook is withheld until the card is actually saved.

Two consequences:

  • The webhook arrives after the booking does. If the guest saves the card two hours later, that is when reservation.created fires — long after your 201 — with status confirmed, or pending when the service asks for approval. A PATCH that takes the card requirement away fires it too. No other reservation.* event about the booking comes before it, including the reservation.updated a PATCH in the meantime would have sent.
  • If the guest never saves a card, nothing fires at all. The request lapses, the booking is cancelled, and there is no reservation.cancelled either, because a cancellation webhook with no created before it would describe a booking your system was never told about.

A sync that reconciles from the API rather than from the event stream sees the booking, sees it settle, and sees it disappear. A sync that trusts the stream sees none of it. Take a deposit covers the rest of that flow.

Terminal window
curl -G "https://api.useservice.app/v1/reservations" \
-H "Authorization: Bearer $SERVICE_API_KEY" \
--data-urlencode "updated_since=2026-09-21T06:00:00Z" \
--data-urlencode "limit=100"

The change feed is available on GET /v1/reservations, GET /v1/guests and GET /v1/waitlist-entries. Passing it flips the sort to (updated_at, id) ascending, so you read oldest change first and page forward with starting_after as usual. Pagination has the mechanics, including why the feed is at-least-once and why you overlap your window rather than resuming exactly where you stopped.

Three things the feed does not do:

  • Holds have no updated_since. GET /v1/holds filters on status and date instead. A hold is short-lived by construction, so the four hold.* events are the sync surface, and a hold that converts leaves a reservation the feed does carry.
  • Guest statistics are computed at read time. visit_count, no_show_count and cancellation_count are not part of the sync surface and do not by themselves move a guest’s updated_at. Read the guest when you need them.
  • It does not tell you what changed, only that something did. Diff against your own copy, or read the booking’s events for a field-level history.

Pick a cadence from what the restaurant needs, not from what the limit allows. Once a minute is generous for a reconciling sweep next to a live webhook handler; once an hour is enough for a warehouse.

updated_since answers what changed. To read what is booked for tonight, filter on the service date instead:

Terminal window
curl -G "https://api.useservice.app/v1/reservations" \
-H "Authorization: Bearer $SERVICE_API_KEY" \
--data-urlencode "date=2026-09-21" \
--data-urlencode "limit=100"

date[gte] and date[lte] take a range, both ends included, and GET /v1/holds accepts the same three. Without updated_since the list is sorted by date, then id, newest first. The filter is named date although the field on each booking is service_date: ?service_date= is refused with a 400 naming it, so a typo never returns every date looking like a filtered answer.

Reads and writes draw on two separate budgets, chosen by HTTP method: GET, HEAD and OPTIONS spend the read budget, and every other method spends the write one. A sync is almost entirely GET, so it spends read quota and leaves the write budget — the smaller of the two by a wide margin — untouched for whatever else your integration does.

That matters more than it sounds. A polling loop that stays inside the read budget cannot starve a create, and a burst of creates cannot stall the sync. When a response carries RateLimit-Resource, read it to know which of the two the other headers describe; the numbers, the burst behaviour and the 429 shape are on Rate limits.

Single-resource GETs carry an ETag. Send it back as If-None-Match and an unchanged resource answers 304 Not Modified with no body:

Terminal window
curl -i "https://api.useservice.app/v1/reservations/resv_8xKQ2m4Vd0pErJ7sN1aZ9bQ?expand=guest" \
-H "Authorization: Bearer $SERVICE_API_KEY" \
-H 'If-None-Match: W/"resv_8xKQ2m4Vd0pErJ7sN1aZ9bQ-1719515575000000-guest-guest_data"'

The ETag varies on the expand set, and if you cache per resource id you need to cache per (id, expand) instead. The reason is a bug worth understanding rather than working around: an ETag that ignored expand meant a client holding the plain body could revalidate while asking for ?expand=guest and be told 304. A 304 says “the body you hold is current”, so the client would conclude the guest object does not exist — a wrong answer that looks exactly like a correct one.

The set is sorted before it enters the key, so expand=guest,events and expand=events,guest share a validator.

The ETag also varies on the guest data. A body that carries the booking’s contact details and notes ends in -guest_data, so a client that moves to a key with guests:read is answered 200 with the full body, never 304 for the copy it holds without them. A request with no expand, for a body without guest data, produces the same ETag this API has always produced.

Expanding objects covers what each resource can expand and the reduced page size that comes with expansion on a list.

Code When What to do
insufficient_scope, param: "expand" 403 on any read reaching a guest The key lacks guests:read. Drop the parameter and sync bookings without contact details, or ask the restaurant for a key that carries it.
insufficient_scope, no param 403 The key cannot call the endpoint at all. Re-sending changes nothing.
bad_request, param: "updated_since" 400 The timestamp is not ISO-8601.
bad_request, param: "service_date" 400 The list filter is date, or date[gte] and date[lte].
bad_request, param: "expand" 400 An unknown relationship. A typo never silently ships un-expanded data.
rate_limited 429 Honour Retry-After, then back off with jitter. Check RateLimit-Resource to see which budget you exhausted.
not_found 404 A resource that belongs to another restaurant answers 404, not 403. One key, one restaurant.
plan_required 403 API access is a Premium entitlement. The key is valid; the restaurant’s plan is not.

A demo prints the webhook body. Five things separate it from a sync a restaurant group can run.

It verifies every signature, including the ones it is about to ignore. The URL is public. An unsigned body is a stranger’s opinion about your CRM.

It is idempotent on both sides. Deduplicate webhooks on event.id, upsert the updated_since feed by resource id, and the two mechanisms stop fighting over the same booking. You can then run the sweep as often as you like.

It stores the watermark it actually finished. Not “now”, and not the last updated_at it read — the last page it committed. Resume from slightly before that, and accept the repeats.

It reconciles on a schedule, not only after an incident. A sweep that runs once an hour finds an imported booking, a silently-lapsed guaranteed request and a delivery you dropped, all without anybody noticing they were missing. A sweep that runs when somebody complains finds them a week late.

It holds the scopes it uses and no more. A warehouse that reports on covers does not need guests:read. Asking for it anyway makes the restaurant’s guest list your problem to protect, for no feature.