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.
First: webhooks alone are not a record
Section titled “First: webhooks alone are not a record”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.seatedbeforereservation.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_sincetells 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.
Scopes for this job
Section titled “Scopes for this job”| 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.
Know which events fire, and which do not
Section titled “Know which events fire, and which do not”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.
Guaranteed bookings arrive out of order
Section titled “Guaranteed bookings arrive out of order”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.createdfires — long after your201— withstatusconfirmed, orpendingwhen the service asks for approval. APATCHthat takes the card requirement away fires it too. No otherreservation.*event about the booking comes before it, including thereservation.updatedaPATCHin 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.cancelledeither, because a cancellation webhook with nocreatedbefore 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.
Reconcile with updated_since
Section titled “Reconcile with updated_since”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/holdsfilters onstatusanddateinstead. A hold is short-lived by construction, so the fourhold.*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_countandcancellation_countare not part of the sync surface and do not by themselves move a guest’supdated_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.
Read one night’s bookings
Section titled “Read one night’s bookings”updated_since answers what changed. To read what is booked for tonight,
filter on the service date instead:
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.
Pay for it in the right budget
Section titled “Pay for it in the right budget”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.
Make the revalidation cheap
Section titled “Make the revalidation cheap”Single-resource GETs carry an ETag. Send it back as If-None-Match and an
unchanged resource answers 304 Not Modified with no body:
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.
The refusals this flow produces
Section titled “The refusals this flow produces”| 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. |
What a production sync does differently
Section titled “What a production sync does differently”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.