Four steps, in this order, every time.
1. Read availability with a version token. Fetch free slots and ask the calendar for a token or last-modified value that describes the state you read. Google Calendar exposes this through its API sync tokens and etag values; most scheduling providers offer something similar. Store the token alongside the proposed slots. If the token is missing, you cannot detect a stale read, and the hold in step 2 becomes a guess.
2. Place a short hold. Because step 1 is a read, it guarantees nothing. Two callers can see the same empty slot. A hold marks the slot unavailable for a short window, say 120 seconds, while the agent finishes collecting the caller's name, phone number and reason. Holds live in your own database with a slot_id, hold_id, expires_at and the request that created them. Holds are cheap and self-cleaning: anything past expires_at is ignored on read and swept by a job.
3. Commit with an idempotency key. The commit request carries a key derived from the conversation and the intent, for example a hash of the agent conversation ID plus the chosen slot. The booking service records the key before calling the calendar. If the calendar call times out and the agent retries, the service sees the key, returns the recorded result, and does not create a second event. Stripe documents this pattern clearly for payments: a client generates a unique key, retries with the same key, and the server returns the original response instead of executing again in the Stripe API reference. Booking is the same problem with a different noun. The key is subject to the same rules Stripe states: keys are meant for a single retry of one operation, not for reuse across different bookings.
4. Confirm and write back. After the calendar confirms the event, persist the event ID and ETag, send the confirmation, and record the full request and response. The confirmation message is not decoration: it is your evidence that the agent and the calendar agree.
The same shape applies to cancellations, with the key used in reverse. A cancel request carries a cancel key, the service marks its own record cancelled before calling the calendar, and a retry with the same key finds the record and skips the duplicate call. If you have ever wondered why an API is worth designing around rather than around a screen, the short version is that an API is a defined contract between two programs as AWS describes APIs; a contract you control is what lets you enforce idempotency, holds and confirmations in one place instead of in twelve prompt variations.
A minimal commit handler in pseudo-code:
def commit_booking(request):
key = request.idempotency_key
record = store.get_by_key(key)
if record: # a retry, not a new booking
return record.result
hold = store.get_hold(request.hold_id)
if not hold or hold.expires_at < now():
raise SlotExpired
store.reserve(key, hold.slot_id) # write the key BEFORE the call
event = calendar.create_event( # may fail or time out
start_utc=hold.start,
end_utc=hold.end,
tz=hold.iana_zone,
idempotency_key=key,
)
store.complete(key, event.id, event.etag)
return event
The ordering matters. Writing the key before the external call means a crash mid-call leaves a record you can inspect, not a missing booking. A record stuck in reserved state past a timeout is exactly what the reconciliation job is for.