# Recipe: book a meeting from a reply

> Use the booking API to offer open Cal.com or Calendly times in a reply, detect the prospect's pick and book it, step by step with exact routes and bodies.

Source: https://docs.ayinbox.com/api/recipes/book-meeting-from-reply/

Booking a meeting from a reply over the API takes four calls. Read open times, create an offer that returns a text block, send that block in your reply, then book the slot the prospect picks with `POST /booking/offers/:id/book`. The scheduling provider sends the invite to the contact's stored address, never to an address taken from an email.

**Calendar booking** in AY Inbox means offering real open times from your own Cal.com or Calendly account and booking the prospect's choice through that provider. AY Inbox never writes to your calendar: the provider applies your availability rules, refuses conflicts, attaches the video link and sends the invite. The product side, including draft mode and follow-ups, is in the [booking guide](/guides/booking/). The route reference is [calendar booking](/api/reference/booking/).

Before you start [#before-you-start]

| You need                | Detail                                                                                                                                                                                                       |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Booking on the instance | Every route answers `503 booking_disabled` while `AYINBOX_BOOKING` is off                                                                                                                                    |
| A booking credential    | A Cal.com API key or Calendly personal access token pasted on the connection. It is checked, sealed and never returned                                                                                       |
| A usable booking type   | `GET /booking/profiles` lists types; use one with `status: "ok"`. Event types that need host confirmation, email verification, a required question, a phone number, seats or an invitee location are refused |
| A plan                  | On the hosted service, a plan that includes the product; an offer stops with `plan_lapsed` without one                                                                                                       |
| A thread with a contact | The thread's counterpart must be a contact in the workspace, or the offer answers `400 booking_contact_mismatch`                                                                                             |

Draft mode (`PUT /booking/settings` with `{"mode": "draft"}`) is what lets AY Inbox prepare offers on its own, as drafts, when a reply asks for a call. You do not need it for this recipe: an offer you create over the API stays open when the mode is `off`.

| Call                               | Key scope                                           |
| ---------------------------------- | --------------------------------------------------- |
| Read availability                  | `READ_CONTACTS`, and `READ_UNIBOX` with `thread_id` |
| Create an offer, stop or resume it | `WRITE_UNIBOX`                                      |
| Send the reply                     | `WRITE_UNIBOX`                                      |
| Read an offer                      | `READ_UNIBOX`                                       |
| Book                               | `WRITE_CONTACTS` and `WRITE_UNIBOX`                 |

1. Read availability [#1-read-availability]

```bash
curl -s "https://api.ayinbox.com/v1/booking/availability?thread_id=THREAD_ID&limit=10" \
  -H "Authorization: Bearer aybox_..."
```

```json
{
  "profile_id": "…",
  "timezone": "Europe/Berlin",
  "timezone_source": "contact_field",
  "slots": [
    { "start_at": "2026-11-10T09:00:00Z", "end_at": "2026-11-10T09:30:00Z", "label": "Tue 10 Nov, 10:00" }
  ]
}
```

Times are read live from the provider with the booking type's minimum notice applied. `limit` defaults to 20 and goes up to 50. Pass `profile_id` to use a type other than the default, and `timezone` (an IANA zone) to label the times yourself.

`timezone_source` says where the zone came from. With a thread, the zone is resolved in this order: a zone the prospect stated (`stated`), one a teammate set (`member`), the contact's `timezone` field (`contact_field`), the offset in their email's Date header (`date_header`), their email domain's country (`email_domain`), then the campaign's or workspace's zone (`host`). The last three are guesses, and an offer built on one says so and asks the prospect to correct it.

2. Create the offer [#2-create-the-offer]

```bash
curl -s -X POST "https://api.ayinbox.com/v1/booking/offers" \
  -H "Authorization: Bearer aybox_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: offer-THREAD_ID-1" \
  -d '{"thread_id": "THREAD_ID", "slot_starts": ["2026-11-10T09:00:00Z", "2026-11-10T14:30:00Z"]}'
```

The body is `{thread_id, profile_id?, timezone?, slot_starts?}`. Leave `slot_starts` out and AY Inbox picks the times by the booking type's rules (3 per offer by default, at most 5). Every start you name must be open on the calendar, or the call answers `400 booking_slot_not_available`.

The answer is `{offer, block, slots}`. `block` is the text to put in your reply, with the times numbered and the zone named in words. **This call sends nothing.** Only one open offer may hold a thread or a contact; a second answers `409 booking_offer_open`.

3. Send it [#3-send-it]

Put the block in a reply on the same thread with [`POST /unibox/reply`](/api/reference/unibox/), from the offer's `email_account_id`:

```json
{
  "email_account_id": "OFFER_EMAIL_ACCOUNT_ID",
  "thread_id": "THREAD_ID",
  "to": ["lea@example.com"],
  "subject": "Re: Quick question",
  "body_plain": "Hi Lea, happy to.\n\nBLOCK_TEXT"
}
```

Keep the block intact. The offer moves to `offered` only when the sent reply carries every time in it. This is a real send, so a key with `WRITE_UNIBOX` puts mail on the wire.

4. Read the pick [#4-read-the-pick]

When the prospect answers, AY Inbox reads the reply against the offered times. Poll `GET /booking/offers/:id`, or list offers waiting on you with `GET /booking/offers?status=pick_pending,needs_human`.

`GET /booking/offers/:id` returns `{offer, slots, events}`:

* `offer.status` is `pick_pending` when a pick was detected, with `candidate_slot_id` naming the slot. It is `needs_human` with a `needs_human_reason` when the reply was unclear.
* Each entry in `events` has a `kind` (`inbound`, `pick_detected`, `slot_taken`, `booked` and others), `decided_by` (`deterministic`, `typed`, `model`, `member` or `api`), a `confidence` and `evidence`: the words the decision rests on.

A clear pick is "option 2", "the second one" or a full day and time naming one slot with a positive word. A day alone or a time alone is a partial pick that points at one slot. A condition ("option 2 if it's still open") or a negation ("Tuesday I'm out") goes to a person. Read `evidence` before you book on a pick your code did not see.

5. Book [#5-book]

```bash
curl -s -X POST "https://api.ayinbox.com/v1/booking/offers/OFFER_ID/book" \
  -H "Authorization: Bearer aybox_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: book-OFFER_ID" \
  -d '{"slot_id": "CANDIDATE_SLOT_ID", "send_confirmation": true}'
```

Send `{slot_id}` for an offered time, or `{start_at}` for a time the prospect proposed, which must be open on the calendar. Before posting anything, AY Inbox checks the suppression list, the contact's subscription and whether the contact already has an upcoming meeting. `send_confirmation` defaults to `true` and sends a short confirmation in the thread; if that send fails, the confirmation waits as a draft. With `false` the meeting is booked and no confirmation is sent.

| Answer                               | Meaning                                                                                           |
| ------------------------------------ | ------------------------------------------------------------------------------------------------- |
| `201 {meeting, offer, confirmation}` | Booked. `meeting` has `scheduled_for` and `join_url`                                              |
| `202 {offer, outcome: "unknown"}`    | The provider's answer is being confirmed by lookup. Nothing is posted again; read the offer later |
| `409 booking_slot_taken`             | The time is gone. `alternatives` lists open times, already written into a new draft               |
| `409 booking_new_reply`              | A reply on the offer has not been read yet; `message_id` names it. Read it first                  |
| `409 booking_attempt_in_flight`      | Another booking on this offer is being confirmed                                                  |
| `409 booking_already_booked`         | The contact already has an upcoming meeting                                                       |
| `400 booking_slot_not_available`     | The slot is not on the offer, or the time is not open on the calendar                             |
| `400 recipient_suppressed`           | The contact is suppressed or unsubscribed                                                         |

Booking attempts are unique per offer and time, so a retry, with the same `Idempotency-Key` or without one, never books the same time twice. The full list is under [calendar booking refusals](/api/error-codes/#calendar-booking-refusals).

Why booking is not an agent tool [#why-booking-is-not-an-agent-tool]

The tool registry has a `book_meeting` tool, but it is send-class: the provider emails an invitation to a real person. Send-class tools are never listed or callable on the [MCP server](/api/mcp/) or the [REST tool surface](/api/agent-tools/). There, an agent can call `get_availability`, `offer_meeting_times`, `get_meeting_offer` and `stop_meeting_offer`, and then books with `POST /booking/offers/:id/book` and a key that holds `WRITE_CONTACTS` and `WRITE_UNIBOX`. In the dashboard assistant, `book_meeting` exists and asks a person every time.

The same flow from a terminal:

```bash
ayinbox booking availability --thread THREAD_ID
ayinbox booking offer-create --thread THREAD_ID
ayinbox inbox reply ...            # with the block in the body
ayinbox booking offer OFFER_ID     # read the pick and its evidence
ayinbox booking book OFFER_ID --slot SLOT_ID --idempotency-key book-OFFER_ID
```

`booking book` prompts before it runs, like every command that sends. Why booking from replies works this way, and what to automate, is covered in [booking meetings from cold email replies](/learn/book-meetings-from-cold-email-replies/).

## Frequently asked questions

### Can I book a Calendly or Cal.com meeting from an email reply over an API?

Yes. Read open times with GET /booking/availability, create an offer with POST /booking/offers, send its block in a reply, then book the prospect's pick with POST /booking/offers/:id/book. The provider sends the invite.

### Who receives the calendar invite?

Always the contact's stored email address. Nothing in an email can change the attendee, the event type or the mailbox, and a pick only counts when it comes from the contact's own address.

### Does creating an offer send anything?

No. POST /booking/offers returns a text block and sends nothing. The offer becomes offered only when a reply carrying every time in the block is sent.

### Why can't an AI agent book through the MCP or tool API?

book_meeting is send-class, because the scheduling provider emails a real person. It is never exposed on the MCP or REST tool surfaces; an agent books with POST /booking/offers/:id/book and a key that holds the scopes.

### What happens if the time was taken before I booked it?

The call answers 409 booking_slot_taken with alternatives, open times that are already written into a new draft on the thread. Nothing is booked.
