URL: /code/apps/scheduling/book · Id: `page:/code/apps/scheduling/book`

# Meeting.book

> Book the call a buyer picked, at a time both calendars hold free.

`POST https://api.sb/{your-domain}/meetings/book`

Book the call a buyer picked, at a time both calendars hold free.

- **Action class:** None
- **Approval:** Runs on its own
- **Emits:** `Meeting.booked`
- **Sites:** Google Calendar, Outlook
- **Price:** Free

```text title="Run it · Ask"
Book Rosa Alvarez for Tuesday at 2 p.m. Central, 30 minutes, on how Northgate tracks renewals today.
```

```ts title="Run it · SDK"
import { $ } from 'startup-builder'

const result = await $.try.Meeting.book({ contact: 'contact_Rq2wN8bX5t', at: '2026-11-03T20:00:00Z' })
```

```json title="Run it · Response"
{
  "api": "https://api.sb/leaseline.app",
  "meeting": {
    "$type": "Meeting",
    "contact": "https://api.sb/leaseline.app/contacts/contact_Rq2wN8bX5t",
    "at": "2026-11-03T20:00:00Z",
    "agenda": "How Northgate Rentals tracks rent and lease renewals today, 30 minutes"
  },
  "action": {
    "operation": "Meeting.book",
    "mode": "try",
    "status": "done"
  }
}
```

Books a time both calendars show free and sends the invite from your domain. [The hub’s definition of `Meeting.book`](https://startupbuilder.dev/business/nouns/meeting/book).</HubDefinition>

## Inputs
| Name | Type | Default | Meaning |
| --- | --- | --- | --- |
| `contact` | `Contact` | Required | Who to meet. |
| `at` | `datetime` | Required | When. |

## Outputs
| Field | Type | What `null` means |
| --- | --- | --- |
| `meeting` | `Meeting` | Never null. |
| `meeting.at` | `datetime` | Never null: the start sent, in UTC. |
| `meeting.agenda` | `text` | Null when the reply that asked for the call named no topic. |

## Effects
**Action class**

None: no approval rule names it, so it runs within its seat’s Permissions and Limits

**Modes**

- `try`: Checks both calendars at `at` and returns the Meeting as it would be booked; no invite goes out.
- `send`: Books it at once and sends the invite.
- `do`: Books it at once and sends the invite.

**Idempotency**

Only with a key: the same `Idempotency-Key` and input return the first Action, and the same key with other input returns `409`. A second booking with the same Contact at the same `at` returns the first Meeting.

**Saved or shared**

The Meeting is saved on the Contact and moves its open deal forward. A move in either calendar moves the Meeting.

**Emits**

`Meeting.booked` with `{ meeting, contact, at }`

**Performed by**

[Sales Engineer](https://startupbuilder.dev/agents/sales/sales-engineer)

**Price**

Free on your own account: the platform charges nothing for the call.

## On each Venue
| Site | Fields | Formats |
| --- | --- | --- |
| [Google Calendar](#google-calendar) | `at`, `contact`, `agenda`, `link` | None. |
| [Outlook](#outlook) | `at`, `contact`, `agenda` | None. |

### Google Calendar
Through [Google Calendar](/code/integrations/google-calendar).

| Our field | Google Calendar’s field |
| --- | --- |
| `Meeting.at` | `event.start.dateTime and event.end.dateTime` (Each with its timeZone) |
| `Meeting.contact` | `event.attendees[].email` |
| `Meeting.agenda` | `event.description` |
| `Meeting.link` | `event.conferenceData.createRequest` (Adds a video link to the invite) |

- **Free and busy times.** Read live before every offer and never stored, so an offer is never stale by more than the minutes it takes to reply.
- **Bookable hours.** Only inside the hours you set and never over a busy block on any calendar you connected.
- **One invite.** Each Meeting sends one invite from your calendar; a move updates it, so the buyer never holds two.

### Outlook
Through [Outlook](/code/integrations/outlook).

| Our field | Outlook’s field |
| --- | --- |
| `Meeting.at` | `event.start and event.end` (Each with its timeZone) |
| `Meeting.contact` | `event.attendees[].emailAddress` |
| `Meeting.agenda` | `event.body` |

- **Bookings.** Only inside your bookable hours and never over a busy block on any connected calendar.

## What try does
| Account | What try does |
| --- | --- |
| [Google Calendar](/code/integrations/google-calendar#try) | Reads your free and busy times for real, picks the slot, and returns the invite it would send; nothing is written to your calendar. |
| [Outlook](/code/integrations/outlook#try) | Renders the exact email or invite and returns it with the time it would go; nothing reaches Outlook and no draft or event is saved. |

## Refuses when
| Check | Error | Fix | Then call |
| --- | --- | --- | --- |
| A slot the calendar does not show free, or a line that presents the Product as available | [`availability`](/code/refusals#check-availability) | Offer only free calendar slots, and fix any line that says it ships; it pauses until fixed. | [`Meeting.book`](/code/apps/scheduling/book) |

Every call through an account can also meet [the shared account refusals](/code/refusals#accounts): [`grant-missing`](/code/refusals#grant-missing), [`needs-reauth`](/code/refusals#needs-reauth), [`account-not-connected`](/code/refusals#account-not-connected), [`over-mandate`](/code/refusals#over-mandate), [`quota-reached`](/code/refusals#quota-reached), [`venue-rejected`](/code/refusals#venue-rejected).

```json
{
  "type": "https://startupbuilder.dev/errors/availability",
  "title": "Says it is available",
  "status": 422,
  "detail": "A slot the calendar does not show free, or a line that presents the Product as available.",
  "fix": "Offer only free calendar slots, and fix any line that says it ships; it pauses until fixed.",
  "next": {
    "verb": "Meeting.book",
    "input": {}
  },
  "instance": "https://api.sb/leaseline.app/actions/action_1"
}
```

## When it waits
Returns in one response; nothing waits.

## Example

**Ask**

```text
Book Rosa Alvarez for Tuesday at 2 p.m. Central, 30 minutes, on how
Northgate tracks renewals today.
```

**API**

```bash
curl -X POST "https://api.sb/leaseline.app/meetings/book?mode=try" \
  -H "Authorization: Bearer $SB_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"contact":"contact_Rq2wN8bX5t","at":"2026-11-03T20:00:00Z"}'
```

The response is the one in the Run it window above.

**CLI**

```bash
npx startup-builder do Meeting.book --try --data '{"contact":"contact_Rq2wN8bX5t","at":"2026-11-03T20:00:00Z"}'
```

**MCP**

```json
{
  "name": "do",
  "arguments": {
    "mode": "try",
    "code": "return await $.Meeting.book({ contact: 'contact_Rq2wN8bX5t', at: '2026-11-03T20:00:00Z' })"
  }
}
```

**SDK**

```ts
import { $ } from 'startup-builder'

const result = await $.try.Meeting.book({
  contact: 'contact_Rq2wN8bX5t',
  at: '2026-11-03T20:00:00Z',
})
```

To retry, send the same `Idempotency-Key`: the retry returns the first answer for $0. A new key reads again.

A `sk_test_` key answers these the same way every time, at $0 ([every test value](/code/data#test-values)).

| Test value | Always returns | What it is for |
| --- | --- | --- |
| `contact_test_busy` | Refuses with [`availability`](/code/refusals#check-availability) | Its test calendar is busy every weekday from 2:00 to 3:00 p.m. Central, so a booking then refuses and names the next free slot. |
| `maxCost: 0.0001` | Refuses with [`max-cost`](/code/refusals#max-cost) | Any priced call with a maxCost of 0.0001 is refused before it spends: the price is above the cap. |
| `rate-limited.test` | Refuses with [`rate-limited`](/code/refusals#rate-limited) | Any call naming this hostname answers 429 with Retry-After, so your retry logic runs. |
| `sources-busy.test` | Refuses with [`sources-busy`](/code/refusals#sources-busy) | Any call naming this hostname answers 503 as if every route were busy, and nothing is spent. |

## Used by
- **Services:** [Booked first calls](/capabilities/reach-out/booked-first-calls)
- **Jobs:** [Reach out](/capabilities/reach-out)
- **Integrations:** [Google Calendar](/code/integrations/google-calendar) · [Outlook](/code/integrations/outlook)

## Related
None.