It Said Booked. The Calendar Says Nothing.


On 8 July 2026 a dental practice running a Retell agent logged a streaming timeout, "2500ms timeout reached for first token". On the next turn the model emitted five identical Book_Appointment tool calls in one response. Same patient, same slot, distinct tool call IDs, all inside the same millisecond. Four of them committed. The practice's own fallback logic then went looking for other slots, and the caller finished the day with seven appointment records across three different times for the one slot they had agreed to. Retell staff published the root cause in the thread: an availability query with a full-month window returned a payload large enough to be truncated and push the model's context to roughly 20,000 tokens.
Nothing in that story is a date-parsing bug. The date was right. This is the leg after it, between the tool call and the row in the calendar, and it produces three symptoms that a caller experiences as the same thing: they were told they had an appointment and they did not have one.
Key takeaways
- The spoken confirmation is not wired to the write. On both major platforms, what the caller hears after a failed booking is decided by the model unless you configure otherwise.
- Vapi's docs acknowledge this directly and prescribe prompt text against it. The behaviour is documented, not a bug report.
- Google Calendar and Microsoft Graph will both create an event on top of an existing one. Neither refuses a conflicting booking on a user's calendar.
- A second availability check immediately before the write narrows the race. It does not close it, and it is the fix almost every guide recommends.
- An idempotency key and a slot lock solve different problems and are routinely confused. One stops your retry becoming three bookings; the other stops two callers taking one slot.
Why does the agent say booked when nothing was created?
Because on both major platforms the confirmation sentence and the write are separate systems, and the sentence has a fallback that the write does not. Vapi's OpenAPI spec is explicit about the chain. When a tool returns an error, the assistant speaks the tool result's own message if one exists, or a request-failed message from the tool config if one exists, and if neither exists, "a response generated by the model".
That last branch is the whole bug. ToolMessageFailed says it plainly: "If this message is not provided, the model will be requested to respond." Most teams never configure a request-failed message, so a language model that has just been asked to continue a conversation in which the caller agreed to a time produces the most likely next sentence. The most likely next sentence is a confirmation.
Retell's equivalent is the same shape from the other direction. Its custom function docs say that when the function fails, Retell hands the raw error to the LLM and the prompt decides what to say.
Three ways the confirmation gets ahead of the write
| Configuration | What the caller hears | Source |
|---|---|---|
No request-failed message set (Vapi) |
Whatever the model generates, which trends to a confirmation | ToolMessageFailed.type, Vapi OpenAPI |
request-complete message set (Vapi) |
The fixed success line, spoken instead of asking the model | ToolMessageComplete.type, "It's an exclusive OR" |
Tool set to async: true (Vapi) |
The success line, fired immediately, with no server response at all | ToolMessageComplete, and ToolMessageFailed "is never triggered for async tool calls" |
The async row is the sharpest of the three. With async: true the assistant "will move forward without waiting for your server to respond", the completion message fires before anything has happened, and the failure message can never fire. An async booking tool cannot tell a caller the booking failed. It is structurally incapable of it.
Retell adds one default worth knowing: Talk After Action Completed is on out of the box, so the agent keeps talking as soon as the function returns rather than waiting for the caller. If your endpoint returned a 200 with a body the agent misread, that is the moment the false confirmation is spoken.
Does Vapi know about this?
Yes, and it publishes a remedy. Vapi's response-handling page for API Request tools tells you to "Add explicit failure behavior to the assistant's system prompt", states that "The assistant must not claim that an order succeeded unless the tool returned a successful response", and supplies example prompt text: "For a timeout, connection failure, or server error, apologize and explain that the order was not confirmed." Its verification checklist includes the line "The assistant did not claim success after a failed request."
Read that as what it is. A platform does not write a checklist item for a failure nobody hits. The same page's retry guidance groups "Create an order, charge a card, send a message, or book an appointment" into a single row and says: "Do not retry unless the destination API provides idempotency or duplicate protection", because "Retrying this POST could create two orders when the first request succeeds but its response is lost or delayed."
Academic work published in 2026 names the general pattern false success, and the framing is useful: a run should be judged by the tool-observed state and downstream artefacts, not by the agent's narration.
Does returning HTTP 200 fix it?
Only for one of Vapi's two tool types, and following the advice on the wrong one destroys your only failure signal. This is the most repeated piece of wrong guidance on this whole problem.
Vapi's own comparison page sets the contracts side by side. For a Function tool, "For synchronous tool-specific failures, return HTTP 200 with a string in the matching results[].error", and its troubleshooting page is blunter: "Always return HTTP 200, even for errors. Any other status code is ignored completely." For an API Request tool, the opposite holds: "A non-2xx response or invalid JSON fails the request", and "Return a 2xx status only when the requested action succeeds."
So the rule everyone quotes, always return 200, is correct for Function tools and actively wrong for API Request tools. Return 200 from an API Request booking tool when the booking failed and you have told the platform it succeeded. Every failure is then reported to the model as a success, and the model says so.
Why does the same slot get booked twice?
Usually not for the reason the guides give. The standard explanation is two callers racing for one slot, and that is a real class of bug with a real name: CWE-367, Time-of-check Time-of-use. Its definition is resource-agnostic, so "check the slot is free, then create the booking" is a textbook instance.
But when we went looking for first-hand incident reports rather than vendor pages, the two-concurrent-callers story was asserted almost entirely by marketing and comparison content, with no call ID, reporter or incident attached. Every first-hand report we found had a different mechanism: one caller and five parallel tool calls from a single LLM turn; a GoHighLevel reschedule implemented as a second create; a GoHighLevel agent with no check for the contact's existing appointment; a Cal.com issue where pending bookings are invisible to availability and the confirm handler makes no atomic claim.
The practical reading is that your duplicate is more likely to be one caller counted twice than two callers counted once. Check that before you build for concurrency.
Does a second availability check before the write fix it?
No. It narrows the window and cannot close it, and it is the fix recommended by both the top-ranking Vapi forum thread on double-booking and the best-ranking independent build guide.
The reason is structural. Nothing in the Google Calendar API makes a freebusy read and a subsequent events.insert atomic, and Google describes the system as globally distributed. Moving the check closer to the write shrinks the gap from seconds to milliseconds. A gap of milliseconds still admits the exact failure above, where five tool calls arrived at one endpoint concurrently and all five passed a read-then-write guard.
There is a second hole in the check itself. freebusy only reports events that block time, so anything written with transparency: "transparent" is invisible to it, and Google's freebusy.query can return HTTP 200 while silently failing for an individual calendar, with the per-calendar result carrying its own errors array.
What does each calendar do when you book an occupied slot?
Most of them accept it. This is the table nobody publishes, and it is the first thing to establish before you design anything.
| Backend | Refuses a conflicting booking? | Client-supplied idempotency key? |
|---|---|---|
| Google Calendar, user calendar | No. No conflict parameter exists on events.insert |
Only indirectly, via a client-supplied Event.id |
| Google Calendar, room resource | Yes, rooms auto-decline overlaps, live since 15 April 2019 | Same as above |
| Microsoft Graph, user mailbox | No | Yes, a real one, transactionId |
| Exchange resource mailbox | Yes, via the resource-booking attendant | Same as above |
| Cal.com | Yes, HTTP 400 with "User either already has booking at this time or is not available" | No, its key is server-derived from start, end and user |
| Calendly | Not documented. No 409 in its declared responses | No. Its OpenAPI spec declares no request headers at all |
| GoHighLevel | Not specified. The endpoint documents only 200, 400 and 401 | No |
Two entries deserve a note. Vapi's native Google Calendar integration exposes google.calendar.availability.check and google.calendar.event.create as two independent tools with no transactional link, no conflict detection on create and no idempotency key. And Cal.com does offer a genuine slot-hold, POST /v2/slots/reservations, defaulting to five minutes, but a reservation is consumed by the availability listing only. The create-booking path never consults it.
The Google event ID caveat we got wrong ourselves
Google's own documentation disagrees with itself here, and we repeated the optimistic half in the relative-dates post, now corrected. The create-events guide says a client-supplied ID "prevents duplicate event creation if the operation fails at some point after it is successfully executed in the Calendar backend". The API reference, on the same field, says: "Due to the globally distributed nature of the system, we cannot guarantee that ID collisions will be detected at event creation time."
Use a derived event ID anyway, because a detected collision returns a clean 409, "The requested identifier already exists". Just do not treat it as a guarantee, and reconcile afterwards.
Is an idempotency key the same as a slot lock?
No, and conflating them is why teams fix one symptom and keep the other. They solve different problems and you generally need both.
An idempotency key makes one logical request safe to repeat. Stripe's implementation is the reference: it caches the status code and body of the first request under the key, including failures, and replays that exact response. Send the same key with different parameters and it errors rather than silently replaying. Keys are retained 24 hours. Retell recommends the same discipline for its webhooks, naming event plus call_id as the key, since lifecycle events fire at most once per call. This is the fix for one caller's retry becoming three bookings.
A slot lock makes one resource impossible to claim twice. The database-level version removes the check-then-act window entirely: PostgreSQL's own documentation gives an exclusion constraint on a range type as the answer, and its worked example is a room-reservation table. This is the fix for two callers taking one slot.
Why does the CRM contact have a phone number but no name?
Because the three values travel on different rails and arrive at different times. In Retell this is documented down to the field names, and the distinction is what almost every forum answer misses.
| Mechanism | When it is written | Where it lands |
|---|---|---|
| Dynamic variables supplied before the call | Before the call connects | retell_llm_dynamic_variables |
| Extract Dynamic Variable tool, during the call | Mid-call, when the agent invokes it | collected_dynamic_variables |
| Post Call Extraction | After the call ends, by a separate model | call_analysis.custom_analysis_data |
The phone number is not any of those. On the inbound webhook from_number "will always show up in payload", before a call object or a call ID exists, and Retell's CRM sync matches contacts on phone number as the primary key. So the number is telephony signalling and the name is a post-call inference, which is why one populates and the other does not.
Then there is the webhook you are listening to. Retell's docs say to "Listen to call_analyzed (not call_ended)" for extracted fields, because the call_ended payload excludes call_analysis entirely.
Rule out these four before you debug the mechanism
All four are documented, all four produce blank fields, and all four are faster to check than anything above.
- Your phone number is pinned to an older agent version. A user on Retell's community resolved exactly this symptom in June 2026: the new version had post-call analysis configured, the number still pointed at the previous one. Publishing a version does not repoint the number.
webhook_eventswas narrowed. It defaults tocall_started,call_endedandcall_analyzed, but it is configurable, andcall_analyzedcan simply stop arriving.- An agent-level webhook URL is set. It fully suppresses the account-level webhook for that agent. They do not both fire.
- The field is there and your automation is reading the wrong path. One reported case had the extracted fields present at the correct nested path while an n8n Code node emitted "Not Provided" for every one of them.
There is one genuinely documented empty case, and it is narrow. Retell will not populate custom Post Call Extraction fields "for calls that were not connected or where no conversation took place". It is not a call-duration threshold.
What we run, and why
Five rules, each one aimed at a specific mechanism above rather than at the symptom.
- The confirmation sentence is generated from the booking system's response, never by the model. This is the same rule as the one in the guardrails guide, applied to the one tool where being wrong is most visible.
- Never
asyncon a write. An async tool cannot report failure, by design. - A
request-failedmessage is configured on every booking tool, so the fallback branch is never the model's imagination. - The write carries a derived idempotency key built from the call ID and the resolved slot, so a retry lands on the same row. The date resolution that produces that slot is a separate problem, covered in Monday the 20th was a Wednesday.
- A reconciliation job reads the calendar back and compares it against calls marked successful. This is the outcome layer from the evaluation guide, and it is the only check that catches a write that failed silently.
One caveat on the last one. Retell documents that if the caller interrupts while a custom function is in flight, the request is "not cancelled" and "runs to completion (up to the timeout), so any action it takes still happens". Barge-in during a booking write does not undo the booking. Reconcile for that too.
Frequently asked questions
How do I tell which of the three failures I have?
Read the platform's tool log, not the transcript. If a tool call exists and returned an error while the transcript says booked, it is the confirmation-path bug. If no booking tool call appears at all while the agent spoke a confirmation, the model skipped the tool, which is a documented occurrence: one Retell user reported an agent that "repeatedly called check_availability_cal several times while saying 'I am proceeding with the booking'". If the tool succeeded and the row is missing, the failure is downstream of the platform.
Should I just add "do not confirm unless the tool succeeded" to the prompt?
Do it, because Vapi prescribes exactly that, but do not stop there. A prompt instruction competes with the model's next-token pull towards a confirmation, and it loses often enough to matter. The structural fix is a configured request-failed message, which removes the model from that branch entirely.
Is double-booking worth engineering for on a small line? Check which duplicate you actually have first. If your incidents are one caller and several writes, an idempotency key fixes them and a slot lock adds nothing. If two callers genuinely collide, you need a constraint in the booking system, because neither Google Calendar nor Microsoft Graph will refuse the second write on a user's calendar.
Does Twilio guarantee at-least-once webhook delivery? Not in its voice webhook documentation, despite how often that is repeated. What Twilio actually documents is a connection-override retry count, defaulting to 1 with a range of 0 to 5, with the retry policy selectable across 4xx, 5xx, connect and TLS failure, read timeout, or all of them. Build for duplicates anyway, since your own retries will produce them.