# Calendly DZ — Integration API (v1)

Everything a third-party SaaS needs to embed scheduling: read and write event
types, compute real availability, create and manage bookings, and receive
signed webhooks when things change.

- **Base URL** — `https://your-host/api/v1`
- **Auth** — organization API key (`X-API-Key` or `Authorization: Bearer`)
- **Format** — JSON in, JSON out; UTF-8; all timestamps ISO-8601 with offset
- **Scoping** — a key can only ever see and touch its own organization
- **Several products, one organization** — one key per product; every booking
  is stamped with the key that made it, so each product filters to its own
  traffic and can subscribe a webhook that hears only that (§9)

> **Is an API key required?** Yes, for every `/api/v1` call — there is no
> anonymous or public tier. Substitute `your-host` with your own deployment
> (for `calendar.syctra.com`, the base URL is
> `https://calendar.syctra.com/api/v1`); see [`deploy/README.md`](deploy/README.md)
> for standing that host up behind nginx and TLS. The only endpoints that work
> without a key are the guest-facing booking pages and the embed widget, which
> are meant to be reached by a browser rather than by your server.

----

## Table of contents

1. [Quick start](#1-quick-start)
2. [Authentication](#2-authentication)
3. [Scopes](#3-scopes)
4. [Conventions](#4-conventions)
5. [Errors](#5-errors)
6. [Rate limits](#6-rate-limits)
7. [Endpoints](#7-endpoints)
   - [Meta](#71-meta)
   - [Users](#72-users)
   - [Event types](#73-event-types)
   - [Availability](#74-availability)
   - [Bookings](#75-bookings)
   - [Webhooks](#76-webhooks)
8. [Webhooks in depth](#8-webhooks-in-depth)
9. [Booking flow recipes](#9-booking-flow-recipes)
   - [Several products on one organization](#several-products-on-one-organization)
10. [Paid events](#10-paid-events)
11. [Embedding the booking page](#11-embedding-the-booking-page)
12. [Internationalisation](#12-internationalisation)
13. [Client examples](#13-client-examples)
14. [Going to production](#14-going-to-production)

---

## 1. Quick start

```bash
# 1. Verify the key and see what it can reach
curl -H "X-API-Key: $CALENDLY_KEY" https://your-host/api/v1/me

# 2. Find a bookable host and their event types
curl -H "X-API-Key: $CALENDLY_KEY" https://your-host/api/v1/event-types

# 3. Ask for real open slots on a date
curl -H "X-API-Key: $CALENDLY_KEY" \
  "https://your-host/api/v1/availability?event_type_id=evt_123&date=2026-09-15&timezone=Europe/Paris"

# 4. Book one of the returned `start` values verbatim
curl -X POST -H "X-API-Key: $CALENDLY_KEY" -H 'Content-Type: application/json' \
  -d '{
        "event_type_id": "evt_123",
        "start_at": "2026-09-15T09:00:00.000+01:00",
        "guest": { "name": "Amina B.", "email": "amina@example.com", "timezone": "Europe/Paris", "locale": "fr" }
      }' \
  https://your-host/api/v1/bookings
```

### Try it against the demo account

The repository ships a fully populated demo organisation. Build it with
`npm run db:seed:demo` in `backend/`, then:

| | |
|---|---|
| **Sign in** | `demo@calendly-dz.com` / `Demo2026!` |
| **API key** | `ck_live_demo00000001.demo_01d532bf49f14ea51fbaf91a294c4652` |
| **Scopes** | `read`, `write`, `webhooks` — plan is ENTERPRISE |

```bash
export CALENDLY_KEY=ck_live_demo00000001.demo_01d532bf49f14ea51fbaf91a294c4652
curl -H "X-API-Key: $CALENDLY_KEY" http://localhost:7971/api/v1/me
```

It contains ~270 bookings across 120 days of history and 75 days ahead, 12 event
types covering all four categories (including paid, group and collective), named
schedules, two routing forms with submissions, and five workflows with run
history. The seed is deterministic, so these values are stable across rebuilds.

> **Always book a `start` string returned by `/availability`.** It is the exact
> instant the scheduling engine validated, offsets included. Re-deriving it
> yourself is the most common source of `slot_unavailable`.

> **If the event type declares `intake_fields`**, answer the required ones in
> `intake_responses` (keyed by each field's `name`) or step 4 returns
> `400 invalid_intake`. Most of the demo event types ask for a `subject`.

---

## 2. Authentication

Create keys in the dashboard: **Integrations → API keys**. API access requires a
plan with `api_access` (PRO or ENTERPRISE); on other plans every `/api/v1`
request returns `403 api_not_enabled`.

Minting one from your own backend instead of the UI takes a session token and
the organization id — useful when you provision workspaces programmatically:

```bash
# 1. Sign in as an org admin.
TOKEN=$(curl -s -X POST https://your-host/api/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"admin@example.com","password":"…"}' | jq -r .access_token)

# 2. Mint the key. `key` in the response is the full value — the only time it
#    is ever returned.
curl -s -X POST https://your-host/api/api-keys \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"org_id":"org_1","name":"My SaaS","scopes":["read","write","webhooks"]}'
```

A key looks like `ck_live_a1b2c3d4e5f6.<secret>`. The full value is shown
**once, at creation** — only a SHA-256 hash is stored, so it cannot be recovered
later. Lose it and you mint a new one.

Send it either way:

```http
X-API-Key: ck_live_a1b2c3d4e5f6.xxxxxxxxxxxxxxxxxxxxxxxx
```
```http
Authorization: Bearer ck_live_a1b2c3d4e5f6.xxxxxxxxxxxxxxxxxxxxxxxx
```

Revoking a key in the dashboard takes effect immediately — the next request
returns `401 revoked_api_key`.

**Handling keys**

- Server-side only. A key in browser or mobile code is a public key.
- One key per product or integration. Beyond being able to revoke one without
  breaking the rest, the key is how bookings are attributed: everything a key
  creates carries its id, `GET /bookings?api_key_id=me` is that product's
  traffic, and a webhook created with `"scope": "key"` hears only it. See
  [Several products on one organization](#several-products-on-one-organization).
- Grant the narrowest scope that works (see below).
- Rotate by creating the new key, deploying it, then revoking the old one.

---

## 3. Scopes

| Scope | Grants |
|---|---|
| `read` | Every `GET`: org context, users, event types, availability, bookings |
| `write` | Create/update/delete event types, create/cancel/reschedule bookings, set availability rules |
| `webhooks` | Full webhook management, including delivery logs |

`write` does **not** imply `read`; grant both if the integration needs both.
A request missing a scope returns `403 insufficient_scope` and names what was
required versus granted.

---

## 4. Conventions

**Time.** Every timestamp is ISO-8601 with an explicit offset
(`2026-09-15T09:00:00.000+01:00`). Pass `timezone` (an IANA name such as
`Africa/Algiers`) to have slot *display* fields rendered for your user; the
absolute instant never changes.

**Lists** share one envelope:

```json
{ "object": "list", "data": [ … ], "total": 128, "limit": 25, "offset": 0, "has_more": true }
```

Page with `limit` (1–100, default 25) and `offset`. Every object carries an
`object` field (`booking`, `event_type`, `user`, `webhook`, …) so you can route
on type.

**Money.** `price_dzd` is the authoritative amount. Stripe has no DZD, so card
charges settle in EUR using `price_eur` (or `price_dzd / 145` when unset).

**IDs** are opaque strings. Do not parse them.

---

## 5. Errors

Non-2xx responses always carry the same shape:

```json
{ "error": { "code": "slot_unavailable", "message": "That time is no longer available.",
             "available_slots": ["2026-09-15T09:30:00.000+01:00"] } }
```

Branch on `code`; `message` is for humans and may be reworded.

| Status | Code | Meaning |
|---|---|---|
| 400 | `validation_failed` | Bad request body; see `fields[]` |
| 400 | `invalid_intake` | Intake answers failed the event type's schema; see `fields[]` |
| 400 | `already_cancelled` | Booking is already cancelled or a no-show |
| 400 | `invalid_url` | Webhook URL rejected (non-public or non-HTTPS in production) |
| 400 | `invalid_schedule` | The pinned schedule belongs to a different user |
| 400 | `unknown_event` | Webhook event name not recognised; `supported_events[]` lists valid ones |
| 400 | `invalid_scope` | Webhook `scope` must be `key` or `organization` |
| 400 | `missing_parameter` | A required query parameter is absent (e.g. neither `date` nor `month`) |
| 400 | `invalid_month` | `month` is not `YYYY-MM` |
| 400 | `invalid_rule` | An availability rule is malformed (bad weekday, or times not `HH:mm` with start before end) |
| 401 | `missing_api_key` | No key presented |
| 401 | `invalid_api_key` | Key not recognised |
| 401 | `revoked_api_key` / `expired_api_key` | Key no longer usable |
| 403 | `insufficient_scope` | Key lacks a required scope |
| 403 | `api_not_enabled` | Plan does not include API access |
| 403 | `quota_exceeded` | Plan limit hit (event types, or bookings this month) |
| 403 | `feature_not_enabled` | The plan does not include the feature (e.g. paid event types) |
| 404 | `event_type_not_found` / `booking_not_found` / `user_not_found` / `webhook_not_found` | Not found **in this organization** |
| 409 | `slot_unavailable` | Time was taken between your read and your write |
| 409 | `payment_required` | Event type is paid; use the checkout flow |
| 409 | `event_type_inactive` | The event type is no longer accepting bookings |
| 429 | `rate_limited` | Too many requests |
| 5xx | `internal_error` | Retry with backoff |

**`slot_unavailable` is normal, not exceptional.** Availability is a snapshot;
somebody can book between your read and your write. The response includes up to
twelve fresh `available_slots` — offer those rather than re-querying blindly.

---

## 6. Rate limits

120 requests per minute per API key. Standard headers are returned:

```http
RateLimit-Limit: 120
RateLimit-Remaining: 117
RateLimit-Reset: 43
```

On `429`, wait for `RateLimit-Reset` seconds. Prefer the month view of
`/availability` over 30 single-date calls.

---

## 7. Endpoints

### 7.1 Meta

#### `GET /api/v1/me` — scope `read`

Verify a key and inspect the organization and plan behind it. The cheapest
health check for your integration.

```json
{
  "object": "api_key_context",
  "api_key": { "prefix": "ck_live_a1b2c3d4e5f6", "name": "Production backend",
               "scopes": ["read","write","webhooks"], "last_used_at": "2026-08-31T09:12:00.000Z" },
  "organization": { "id": "org_1", "name": "Agence Alger", "slug": "agence-alger",
                    "timezone": "Africa/Algiers", "default_language": "fr",
                    "primary_color": "#0EA5E9",
                    "counts": { "members": 4, "event_types": 6, "bookings": 312 } },
  "plan": { "name": "PRO", "max_users": 20, "max_event_types": 50,
            "max_bookings_per_month": 1000,
            "features": { "calendar_sync": true, "stripe_payments": true,
                          "custom_branding": true, "api_access": true } }
}
```

#### `GET /api/v1/webhook-events` — scope `read`

Every subscribable event name. Use it to build a subscription UI rather than
hardcoding the list.

---

### 7.2 Users

#### `GET /api/v1/users` — scope `read`

Bookable hosts in the organization. Paginated.

```json
{ "object": "list", "total": 4, "limit": 25, "offset": 0, "has_more": false,
  "data": [{ "object": "user", "id": "usr_1", "name": "Yacine Boukhalfa",
             "email": "yacine@example.com", "username": "yacine-boukhalfa",
             "timezone": "Africa/Algiers", "language": "fr", "role": "ORG_ADMIN",
             "is_active": true, "scheduling_url": "https://your-host/yacine-boukhalfa" }] }
```

---

### 7.3 Event types

#### `GET /api/v1/event-types` — scope `read`

Query: `user_id`, `is_active` (`true`/`false`), `limit`, `offset`.

#### `GET /api/v1/event-types/{id}` — scope `read`

```json
{
  "id": "evt_123", "object": "event_type", "org_id": "org_1", "user_id": "usr_1",
  "name": "Consultation 30 min", "slug": "consultation-30min",
  "description": "Premier rendez-vous", "duration_minutes": 30,
  "category": "ONE_ON_ONE", "color": "#0EA5E9", "is_active": true,
  "scheduling_url": "https://your-host/yacine-boukhalfa/consultation-30min",
  "pricing": { "is_paid": false, "price_dzd": null, "price_eur": null,
               "currency": "DZD", "refund_policy": "FULL" },
  "booking_rules": {
    "buffer_before_minutes": 0, "buffer_after_minutes": 15,
    "min_notice_hours": 24, "max_future_days": 60,
    "max_bookings_per_day": null, "max_per_slot": 1,
    "slot_interval_minutes": null, "requires_confirmation": false
  },
  "schedule_id": null,
  "location": { "type": "virtual", "value": null, "lat": null, "lng": null },
  "intake_fields": [{ "name": "reason", "label": "Motif", "type": "textarea", "required": true }],
  "created_at": "2026-08-01T10:00:00.000Z", "updated_at": "2026-08-20T14:30:00.000Z"
}
```

**What the booking rules actually do**

| Field | Effect |
|---|---|
| `duration_minutes` | Meeting length, and the default gap between start times |
| `slot_interval_minutes` | Overrides that gap (60-min event, 30-min interval ⇒ overlapping starts) |
| `buffer_before/after_minutes` | Pads the meeting on each side. Two meetings clash when their **padded** ranges overlap |
| `min_notice_hours` | Earliest bookable start, from now. `0` means "up to the last second" and is honoured |
| `max_future_days` | Booking horizon |
| `max_bookings_per_day` | Hard daily cap for the host |
| `max_per_slot` | Seats. `> 1` turns a slot into a group session |
| `requires_confirmation` | New bookings land `PENDING` until the host confirms |
| `schedule_id` | Which named schedule governs this event type. `null` uses the host's default |

**Event type categories**

| `category` | Who is offered, and who attends |
|---|---|
| `ONE_ON_ONE` | The owner alone |
| `GROUP` | The owner, with `max_per_slot` seats per time |
| `ROUND_ROBIN` | **Union** of the host pool — a time is offered if *any* host is free, and the booking is assigned to the least-loaded one |
| `COLLECTIVE` | **Intersection** of the host pool — a time is offered only if *every* host is free, and all of them attend |

For `ROUND_ROBIN` and `COLLECTIVE`, the pool is configured per event type. A slot
returned by `/availability` carries `host_ids` naming the hosts it applies to. A
collective booking records every host; `GET /bookings/{id}` returns the organiser
as `host_id`, and the full attendee list is what the calendar invite reflects.

#### `POST /api/v1/event-types` — scope `write`

Required: `user_id`, `name`, `duration_minutes` (5–1440). Everything else
defaults. `slug` is derived from `name` and de-duplicated automatically.

```bash
curl -X POST -H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
  -d '{ "user_id": "usr_1", "name": "Atelier collectif", "duration_minutes": 90,
        "max_per_slot": 8, "min_notice_hours": 48, "requires_confirmation": true,
        "intake_fields": [{ "name": "company", "label": "Société", "type": "text", "required": false }] }' \
  https://your-host/api/v1/event-types
```

#### `PATCH /api/v1/event-types/{id}` — scope `write`

Sparse update; omitted fields are untouched.

#### `DELETE /api/v1/event-types/{id}` — scope `write`

**Soft** delete — sets `is_active: false` so existing bookings keep resolving
their event type. The link stops accepting new bookings.

#### Round-robin host pool

A `ROUND_ROBIN` event type is taken by a pool of members rather than one host.
Availability is the **union** of the pool's free time — a start time is offered
when at least one member is free — and each booking is assigned to the free
member with the fewest bookings on that event type, so load stays balanced.

An event type with an empty pool falls back to its owner, so the link never
dead-ends.

##### `GET /api/v1/event-types/{id}/hosts` — scope `read`

```json
{ "object": "event_type_hosts", "event_type_id": "evt_123", "category": "ROUND_ROBIN",
  "effective_hosts": ["usr_1", "usr_2"],
  "data": [{ "object": "event_type_host", "user_id": "usr_1", "name": "Yacine Boukhalfa",
             "email": "yacine@example.com", "priority": 10, "is_active": true }] }
```

##### `PUT /api/v1/event-types/{id}/hosts` — scope `write`

Replaces the pool. Accepts plain user ids or objects with `priority` (higher
wins ties) and `is_active`.

```bash
curl -X PUT -H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
  -d '{ "hosts": [ { "user_id": "usr_1", "priority": 10 }, { "user_id": "usr_2" } ] }' \
  https://your-host/api/v1/event-types/evt_123/hosts
```

Every id must belong to the key's organization, or the call returns
`400 user_not_found` naming the outsiders.

When you read availability for a round-robin event type, each slot gains a
`host_ids` array naming who could take it:

```json
{ "start": "2026-09-15T09:00:00.000+01:00", "display_start": "09:00",
  "seats_total": 1, "seats_remaining": 1, "host_ids": ["usr_1", "usr_2"] }
```

You do not choose the host when booking — `POST /bookings` assigns the least
loaded free member and returns them as `host_id`.

---

### 7.4 Availability

#### `GET /api/v1/availability` — scope `read`

The endpoint that matters. It runs the full engine: weekly rules, date
overrides, buffers, notice, horizon, seats, daily caps, existing bookings, and
busy time mirrored from connected Google / Outlook calendars.

Query: `event_type_id` (required), then **either** `date=YYYY-MM-DD` **or**
`month=YYYY-MM`, plus optional `timezone`.

```json
{
  "object": "availability", "event_type_id": "evt_123", "date": "2026-09-15",
  "timezone": "Europe/Paris", "host_timezone": "Africa/Algiers",
  "slots": [
    { "start": "2026-09-15T09:00:00.000+01:00", "end": "2026-09-15T09:30:00.000+01:00",
      "start_host": "2026-09-15T09:00:00.000+01:00",
      "display_start": "10:00", "display_end": "10:30",
      "seats_total": 1, "seats_remaining": 1 }
  ]
}
```

`display_start` / `display_end` are pre-rendered in the requested `timezone` —
convenient for a UI, never for booking. Book `start`.

The month form returns one payload for a whole calendar:

```json
{ "object": "availability", "month": "2026-09",
  "available_dates": ["2026-09-15", "2026-09-16"],
  "days": { "2026-09-15": [ … ], "2026-09-16": [ … ] } }
```

Use it to grey out dates that have no openings — one request instead of thirty.

#### `GET /api/v1/users/{id}/availability-rules` — scope `read`

```json
{ "object": "availability_rules", "user_id": "usr_1", "timezone": "Africa/Algiers",
  "weekly": [{ "id": "r1", "day_of_week": 1, "start_time": "09:00", "end_time": "17:00", "is_active": true }],
  "date_overrides": [{ "id": "o1", "date": "2026-09-20", "is_day_off": true, "start_time": null, "end_time": null }] }
```

`day_of_week` is `0` = Sunday … `6` = Saturday.

#### Named schedules

A host can keep several sets of hours — "Working hours", "Weekend", "Q4 launch"
— and each event type points at one. The endpoints below operate on the host's
**default** schedule, which is what an event type uses when its `schedule_id` is
`null`. To target a specific schedule, set `schedule_id` on the event type.

Date overrides belong to a schedule too, so a holiday closed on one schedule
leaves the others untouched.

#### `GET /api/v1/users/{id}/schedules` — scope `read`

Discover a host's schedules, so you know which `schedule_id` to pin.

```json
{
  "object": "list", "total": 3,
  "data": [
    { "object": "schedule", "id": "sch_1", "name": "Heures de bureau",
      "timezone": "Africa/Algiers", "is_default": true, "event_type_count": 4,
      "weekly": [{ "day_of_week": 1, "start_time": "09:00", "end_time": "17:00", "is_active": true }],
      "date_overrides": [{ "date": "2026-09-21", "is_day_off": true, "start_time": null, "end_time": null }] }
  ]
}
```

Set `schedule_id` on an event type (`POST`/`PATCH /event-types`) to pin it;
`null` falls back to the host's default. A schedule belonging to a different
user is rejected with `400 invalid_schedule`.

#### `PUT /api/v1/users/{id}/availability-rules` — scope `write`

**Replaces** the whole weekly schedule. Send every day you want to keep.
Multiple rows for one weekday create split shifts (mornings and afternoons).

```bash
curl -X PUT -H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
  -d '{ "weekly": [
        { "day_of_week": 0, "start_time": "09:00", "end_time": "12:00" },
        { "day_of_week": 0, "start_time": "14:00", "end_time": "17:00" },
        { "day_of_week": 1, "start_time": "09:00", "end_time": "17:00" } ] }' \
  https://your-host/api/v1/users/usr_1/availability-rules
```

Times must be `HH:mm` with start before end, or the call returns
`400 invalid_rule`.

---

### 7.5 Bookings

#### `GET /api/v1/bookings` — scope `read`

Filters: `status` (comma-separated), `event_type_id`, `user_id`, `guest_email`,
`starts_after`, `starts_before`, `api_key_id`, `embed_domain`, `order`
(`asc`/`desc`), `limit`, `offset`.

**One organisation, several products.** Put a different API key in each of your
products and every booking a key creates is stamped with it. `api_key_id=me`
returns only what *this* key created; `api_key_id=none` returns what came
through the hosted booking page or the dashboard; any other value is another
key of the same organisation. `embed_domain=shop.example.com` returns bookings
made through the embed widget on that site. Nothing here crosses organisations —
a key only ever sees its own organisation, whatever it filters on.

```bash
curl -H "X-API-Key: $KEY" \
  "https://your-host/api/v1/bookings?status=CONFIRMED,PENDING&starts_after=2026-09-01T00:00:00Z&limit=50"
```

#### `GET /api/v1/bookings/{id}` — scope `read`

```json
{
  "object": "booking", "id": "bkg_1", "org_id": "org_1",
  "event_type_id": "evt_123", "host_id": "usr_1", "status": "CONFIRMED",
  "guest": { "name": "Amina B.", "email": "amina@example.com", "phone": null,
             "timezone": "Europe/Paris", "locale": "fr" },
  "intake_responses": { "reason": "Première consultation" },
  "start_at": "2026-09-15T08:00:00.000Z", "end_at": "2026-09-15T08:30:00.000Z",
  "notes": null, "cancellation_reason": null,
  "payment": { "required": false, "status": null, "amount_dzd": null },
  "links": { "cancel": "https://your-host/booking/cancel/tok_c",
             "reschedule": "https://your-host/booking/reschedule/tok_r" },
  "api_key_id": "key_1", "api_key": { "id": "key_1", "name": "Shop checkout" },
  "embed_domain": null,
  "created_at": "2026-08-31T10:00:00.000Z", "updated_at": "2026-08-31T10:00:00.000Z"
}
```

Statuses: `PENDING`, `CONFIRMED`, `CANCELLED`, `RESCHEDULED`, `NO_SHOW`.
The `links` are guest-facing self-service URLs — safe to put in your own emails.

`api_key_id` is the key that created the booking, `null` for the hosted page and
the dashboard. `api_key` is `{ id, name }` on list, detail and create responses
and on `booking.created` payloads, `null` when there is no key, and absent on the
other webhook payloads (route on `api_key_id`, which is always there).
`embed_domain` is the hostname of the page the embed widget was on, when that is
how the guest arrived, else `null`. `api_key_id` and `embed_domain` are on every
webhook payload, so a receiver can tell products apart without a lookup.

#### `POST /api/v1/bookings` — scope `write`

```json
{
  "event_type_id": "evt_123",
  "start_at": "2026-09-15T09:00:00.000+01:00",
  "guest": { "name": "Amina B.", "email": "amina@example.com",
             "phone": "+213600000000", "timezone": "Europe/Paris", "locale": "fr" },
  "intake_responses": { "reason": "Première consultation" },
  "notes": "Referred by partner portal"
}
```

Returns `201` with the booking. Behind the scenes this takes a per-host lock and
re-validates the slot against the live engine, so two concurrent calls for the
same time cannot both succeed — the loser gets `409 slot_unavailable`.

`intake_responses` keys are the `name` of each entry in the event type's
`intake_fields`. Required-but-missing or wrongly-typed answers return
`400 invalid_intake` with a `fields[]` breakdown.

Status on success is `CONFIRMED`, or `PENDING` when the event type sets
`requires_confirmation`.

#### `POST /api/v1/bookings/{id}/cancel` — scope `write`

Body: `{ "reason": "optional, shown to the guest" }`. Cancels, releases the
slot, removes synced calendar events, queues a refund when the booking was paid,
and emits `booking.cancelled`. Cancelling twice returns `400 already_cancelled`.

#### `POST /api/v1/bookings/{id}/reschedule` — scope `write`

Body: `{ "start_at": "…", "timezone": "…" }`. Validated under the same lock as a
new booking, but the booking's own current time stays offerable.

---

### 7.6 Webhooks

| Method | Path | Scope |
|---|---|---|
| `GET` | `/api/v1/webhooks` | `webhooks` |
| `POST` | `/api/v1/webhooks` | `webhooks` |
| `PATCH` | `/api/v1/webhooks/{id}` | `webhooks` |
| `DELETE` | `/api/v1/webhooks/{id}` | `webhooks` |
| `GET` | `/api/v1/webhooks/{id}/deliveries` | `webhooks` |
| `POST` | `/api/v1/webhooks/{id}/test` | `webhooks` |

```bash
curl -X POST -H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
  -d '{ "url": "https://api.yoursaas.com/hooks/calendly",
        "events": ["booking.created","booking.cancelled","booking.rescheduled"],
        "description": "Production sync" }' \
  https://your-host/api/v1/webhooks
```

The response contains `secret` — **this is the only time it is returned.** Store
it; it is what verifies every delivery.

**Scope.** Add `"scope": "key"` and the endpoint receives only the bookings this
key created — one product's traffic, nothing from your other products or the
hosted page. The default, `"organization"`, delivers everything in the
organisation, including `event_type.*` and `availability.*` events, which a
key-scoped endpoint never receives (they are not about a booking). The response
echoes `scope` and `api_key_id`. Revoking a key deactivates its key-scoped webhooks.

`GET …/deliveries` is your debugging surface: status, attempt count, response
code, error and the exact payload sent. Filter with `?status=failed`.
`POST …/test` sends a signed test delivery so you can prove your endpoint works
before real traffic arrives.

---

## 8. Webhooks in depth

### Events

| Event | Fires when |
|---|---|
| `booking.created` | Any booking is created (API, public page, or paid checkout completing) |
| `booking.confirmed` | Booking becomes `CONFIRMED` (immediately, or on host approval) |
| `booking.cancelled` | Guest, host or API cancels |
| `booking.rescheduled` | Time changes; payload adds `previous_start_at` |
| `booking.no_show` | Host marks the guest absent |
| `booking.payment_succeeded` | Stripe confirmed payment |
| `booking.payment_refunded` | Refund processed |
| `event_type.created` | An event type is created |
| `event_type.updated` | An event type is modified |
| `event_type.deleted` | An event type is deactivated |
| `availability.updated` | A host's weekly schedule is replaced |

A booking that confirms immediately emits **both** `booking.created` and
`booking.confirmed`. Handle that, or key on one of the two.

### Envelope

```json
{
  "id": "evt_9f2c1a7b3d4e5f6a7b8c9d0e",
  "type": "booking.created",
  "created_at": "2026-08-31T10:00:00.000Z",
  "data": { "object": "booking", "id": "bkg_1", "api_key_id": "key_1", "embed_domain": null, … }
}
```

`data` for a `booking.*` event is the same shape as `GET /bookings/{id}`, origin
fields included — a receiver shared by several products can route on
`data.api_key_id` (or `data.embed_domain`) without a lookup.

### Headers

```http
POST /hooks/calendly HTTP/1.1
Content-Type: application/json
User-Agent: CalendlyDZ-Webhooks/1
X-Calendly-Signature: t=1756634400,v1=5257a869e7ec…
X-Calendly-Event: booking.created
X-Calendly-Delivery: dlv_abc123
```

### Verifying the signature

The signature is `HMAC-SHA256("<timestamp>.<raw body>", secret)`. The timestamp
is inside the signed material, so a captured request cannot be replayed later.

**Verify against the raw request body, before any JSON parsing.** Re-serialising
changes the bytes and the signature will never match.

```js
const crypto = require('crypto');

function verify(secret, rawBody, header, toleranceSeconds = 300) {
  const parts = Object.fromEntries(
    String(header || '').split(',').map((p) => p.split('=').map((x) => x.trim()))
  );
  if (!parts.t || !parts.v1) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - Number(parts.t)) > toleranceSeconds) return false;

  const expected = crypto.createHmac('sha256', secret)
    .update(`${parts.t}.${rawBody}`).digest('hex');
  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(parts.v1, 'hex');
  return a.length === b.length && crypto.timingSafeEqual(a, b); // constant time
}

// Express: keep the raw body for this route only.
app.post('/hooks/calendly', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verify(process.env.CALENDLY_WEBHOOK_SECRET, req.body, req.headers['x-calendly-signature'])) {
    return res.status(401).end();
  }
  const event = JSON.parse(req.body.toString('utf8'));
  enqueue(event);        // do the work off the request path
  res.json({ received: true });   // ack fast
});
```

```python
import hmac, hashlib, time

def verify(secret: str, raw_body: bytes, header: str, tolerance: int = 300) -> bool:
    parts = dict(p.strip().split("=", 1) for p in (header or "").split(",") if "=" in p)
    if "t" not in parts or "v1" not in parts:
        return False
    if abs(int(time.time()) - int(parts["t"])) > tolerance:
        return False
    expected = hmac.new(
        secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])
```

### Delivery semantics

- **Success** is any `2xx`. Anything else is a failure.
- **Retries**: up to 5 attempts with exponential backoff starting at 5 s.
- **Timeout**: 10 s per attempt.
- **At-least-once**: a slow ack can produce a duplicate. Deduplicate on the
  envelope `id`, or make handlers idempotent.
- **Not ordered**: use `created_at` and the booking's own `updated_at` to
  resolve out-of-order arrivals; never assume sequence.
- Return `200` immediately and process asynchronously. Slow handlers cause
  timeouts, which cause retries, which cause duplicates.
- **Who hears what**: an organization-scoped webhook receives every event in
  the organization. A key-scoped one (`"scope": "key"`) receives `booking.*`
  events for bookings its key created, and nothing else — `event_type.*` and
  `availability.*` are not about a booking, so it never sees them.

### URL rules

In production a webhook URL must be `https://` and publicly resolvable.
Loopback and private ranges (`127.0.0.0/8`, `10/8`, `172.16/12`, `192.168/16`,
`169.254/16`, `::1`, `fd00::/8`) are rejected with `400 invalid_url` — they are
an SSRF vector into internal services. In development any `http://` host,
including `localhost`, is allowed.

---

## 9. Booking flow recipes

### Show a calendar and take a booking

```js
const H = { 'X-API-Key': process.env.CALENDLY_KEY, 'Content-Type': 'application/json' };
const BASE = 'https://your-host/api/v1';

// 1. One call fills the whole month.
const month = await fetch(
  `${BASE}/availability?event_type_id=${id}&month=2026-09&timezone=${tz}`, { headers: H }
).then(r => r.json());

renderCalendar({ enabledDates: month.available_dates });

// 2. Slots for the clicked date are already in hand.
const slots = month.days[pickedDate] ?? [];

// 3. Book the exact `start` string.
const res = await fetch(`${BASE}/bookings`, {
  method: 'POST', headers: H,
  body: JSON.stringify({
    event_type_id: id,
    start_at: chosenSlot.start,
    guest: { name, email, timezone: tz, locale: 'fr' },
    intake_responses: answers,
  }),
});

if (res.status === 409) {
  const { error } = await res.json();
  // Someone beat us to it — offer the fresh alternatives it returned.
  return showAlternatives(error.available_slots);
}
const booking = await res.json();
```

### Keep your own database in sync

Subscribe to `booking.created`, `booking.confirmed`, `booking.cancelled`,
`booking.rescheduled`, then upsert on `data.id`:

```js
async function handle(event) {
  const b = event.data;
  switch (event.type) {
    case 'booking.created':
    case 'booking.confirmed':
    case 'booking.rescheduled':
      await db.bookings.upsert({ where: { calendly_id: b.id }, create: map(b), update: map(b) });
      break;
    case 'booking.cancelled':
      await db.bookings.update({ where: { calendly_id: b.id }, data: { status: 'CANCELLED' } });
      break;
  }
}
```

Reconcile nightly with
`GET /bookings?starts_after=…&starts_before=…` so a permanently failed delivery
cannot silently drift your copy.

### Several products on one organization

The same organization can sit behind any number of your products — a shop, a
support portal, a partner app — each with its own key. Nothing about the
organization is duplicated: one set of hosts, event types and availability, and
each product sees the slice it created.

```bash
# 1. One key per product (dashboard: Integrations → API keys, or §2).
#    Name them after the product; the name shows up on every booking.
SHOP=ck_live_…    # "Shop checkout"
PORTAL=ck_live_…  # "Support portal"

# 2. Each product books as itself. The booking is stamped with the key that made it.
curl -X POST -H "X-API-Key: $SHOP" -H 'Content-Type: application/json' \
  -d '{ "event_type_id": "evt_123", "start_at": "…", "guest": { … } }' \
  https://your-host/api/v1/bookings
#    → { "id": "bkg_9", "api_key_id": "key_shop", "api_key": { "id": "key_shop", "name": "Shop checkout" }, … }

# 3. Each product lists only its own.
curl -H "X-API-Key: $SHOP"   "https://your-host/api/v1/bookings?api_key_id=me"
curl -H "X-API-Key: $PORTAL" "https://your-host/api/v1/bookings?api_key_id=me"
#    ...and the hosted booking page + dashboard, which belong to no product:
curl -H "X-API-Key: $SHOP"   "https://your-host/api/v1/bookings?api_key_id=none"

# 4. Each product hears only its own. scope=key binds the webhook to the calling key.
curl -X POST -H "X-API-Key: $SHOP" -H 'Content-Type: application/json' \
  -d '{ "url": "https://shop.example.com/hooks/calendly",
        "events": ["booking.created","booking.cancelled","booking.rescheduled"],
        "scope": "key" }' \
  https://your-host/api/v1/webhooks
```

The same lens is in the dashboard — **Bookings → Source** filters by product,
and every row shows which product (or which embedding site) it came from.

Bookings made through the embed widget on a product's own site carry no key
(the browser has none) but do carry the host page's hostname as `embed_domain`,
so `GET /bookings?embed_domain=shop.example.com` is that site's traffic. See
[§11](#11-embedding-the-booking-page).

What this is, and is not:

- **A lens, not a wall.** Keys of one organization are the same tenant. Any of
  them can list the whole organization (no filter) or another key's traffic
  (`api_key_id=<its id>`). That is deliberate: they are your products.
- **The wall is the organization.** Nothing crosses it — not a listing, not a
  lookup by id, not a webhook. If two products must never see each other's
  guests, give them separate organizations; a person can belong to several and
  switch between them in the dashboard.
- **Origin is permanent.** Revoking a key keeps its id and name on the bookings
  it made (history stays readable); its key-scoped webhooks are switched off.
- **Cancellations and reschedules follow the booking's origin**, whoever
  performs them. A booking the shop created that a host cancels from the
  dashboard is still delivered to the shop's key-scoped webhook.

### Provision a host from your own signup

```js
// 1. Which hosts exist?
const { data: users } = await get('/users');

// 2. Give the new host a schedule.
await put(`/users/${users[0].id}/availability-rules`, {
  weekly: [1,2,3,4,5].map(d => ({ day_of_week: d, start_time: '09:00', end_time: '17:00' })),
});

// 3. Give them something to book.
const et = await post('/event-types', {
  user_id: users[0].id, name: 'Onboarding call', duration_minutes: 45, min_notice_hours: 12,
});

// 4. Hand the link to your user.
return et.scheduling_url;
```

---

## 10. Paid events

`POST /api/v1/bookings` refuses a paid event type with `409 payment_required` —
the API will not create an unpaid booking against a paid one. Two options:

1. **Redirect** the guest to the event type's `scheduling_url`; the hosted page runs
   Stripe Checkout and confirms on payment.
2. **Create the session yourself:**

```bash
curl -X POST -H 'Content-Type: application/json' \
  -d '{ "event_type_id": "evt_paid",
        "booking_data": { "start_at": "2026-09-15T09:00:00.000+01:00",
                          "guest_name": "Amina B.", "guest_email": "amina@example.com",
                          "timezone": "Europe/Paris" } }' \
  https://your-host/api/stripe/checkout
```

```json
{ "url": "https://checkout.stripe.com/c/pay/cs_test_…", "session_id": "cs_test_…",
  "booking_id": "bkg_2", "amount": 20, "currency": "EUR", "amount_dzd": 2900 }
```

The slot is validated **before** the session is created, and the booking is held
`PENDING`/unpaid until `checkout.session.completed` arrives — at which point it
becomes `CONFIRMED` and emits `booking.confirmed` plus
`booking.payment_succeeded`. Abandoned checkouts never hold a seat permanently.

Cancelling a paid booking queues a refund following the event type's
`refund_policy`:

| Policy | Behaviour |
|---|---|
| `FULL` | Refunds everything captured |
| `PARTIAL` | Refunds `PARTIAL_REFUND_PERCENT` of the captured amount (default 50%) |
| `NONE` | No refund |

A completed refund emits `booking.payment_refunded`, whose payload adds a
`refund` object (`id`, `amount` in minor units, `currency`, `policy`).

---

## 11. Embedding the booking page

Load one script and the booking page drops into your site in any of three
shapes. It is dependency-free, works on any stack, and sizes itself.

```html
<script src="https://your-host/embed.js" async></script>
```

### Inline — the calendar sits in your page

```html
<div data-calendly-dz-inline
     data-url="https://your-host/fr/yacine-boukhalfa/consultation-30min"
     style="min-width:320px;height:700px"></div>
<script src="https://your-host/embed.js" async></script>
```

The iframe reports its height as the visitor moves through the steps, so the
container grows and shrinks instead of scrolling inside itself.

### Popup — a button opens it over your page

```html
<a data-calendly-dz-popup
   data-url="https://your-host/fr/yacine-boukhalfa/consultation-30min"
   href="#">Book a meeting</a>
<script src="https://your-host/embed.js" async></script>
```

The modal traps focus, closes on Escape or a backdrop click, and locks the page
behind it from scrolling.

### Badge — a floating button on every page

```html
<script src="https://your-host/embed.js" async
        data-badge-url="https://your-host/fr/yacine-boukhalfa/consultation-30min"
        data-badge-text="Book a meeting"
        data-badge-color="#0EA5E9"
        data-badge-position="bottom-right"></script>
```

### Programmatic API

```js
CalendlyDZ.initInline({
  url: 'https://your-host/fr/yacine-boukhalfa/consultation-30min',
  parentElement: document.getElementById('booking'),
  prefill: { name: 'Amina B.', email: 'amina@example.com', phone: '+213600000000' },
  utm: { source: 'pricing', campaign: 'q4' },   // bare keys get the utm_ prefix
  locale: 'ar',                                  // swaps the locale path segment
});

CalendlyDZ.initPopup({ url, prefill });
CalendlyDZ.initBadge({ url, text: 'Book', color: '#0EA5E9', position: 'bottom-left' });
CalendlyDZ.closePopup();
```

`prefill` fills the guest fields; `locale` rewrites `/fr/…` to `/en/…` or
`/ar/…`, and `ar` renders the whole page right-to-left.

Every embed also tags the iframe with `embed_domain=<hostname of your page>`,
and a booking made through it records that hostname. Embed the same event type
on several sites and `GET /api/v1/bookings?embed_domain=…` (or the dashboard's
Source filter) tells them apart — see
[Several products on one organization](#several-products-on-one-organization).

### Reacting to a booking

```js
CalendlyDZ.on('booking.completed', function (booking) {
  // { booking_id, status, start_at, event_type: { username, slug } }
  window.dataLayer?.push({ event: 'booking_completed', booking_id: booking.booking_id });
});

CalendlyDZ.on('popup.closed', function () { /* … */ });
```

Messages arrive from the iframe tagged `source: "calendly-dz"`; anything else on
the `message` channel is ignored. **This event is for analytics and UI, not for
fulfilment** — a visitor can close the tab before it fires. Use the
`booking.created` webhook as the source of truth.

### Notes

- The embed URL must be an absolute `http(s)` URL. A relative path or a
  `javascript:`/`data:` URL is rejected rather than loaded.
- Embedded pages drop the outer chrome; your site provides the surrounding
  design.
- `embed=1` and `embed_domain=<your host>` are appended automatically.

## 12. Internationalisation

The platform speaks **French (`fr`)**, **English (`en`)** and **Arabic (`ar`,
right-to-left)**.

- `guest.locale` on a booking picks the language for that guest's confirmation
  and reminder emails. Unset, it falls back to the host's language.
- `timezone` (IANA) controls the `display_*` fields and what the guest sees.
- User and organization records carry their own `language` for dashboard UI.

```json
{ "guest": { "name": "أمينة ب.", "email": "amina@example.com",
             "timezone": "Africa/Algiers", "locale": "ar" } }
```

---

## 13. Client examples

### Node / TypeScript

```ts
export class CalendlyDZ {
  constructor(
    private key: string,
    private base = 'https://your-host/api/v1',
  ) {}

  private async call<T>(path: string, init: RequestInit = {}): Promise<T> {
    const res = await fetch(`${this.base}${path}`, {
      ...init,
      headers: { 'X-API-Key': this.key, 'Content-Type': 'application/json', ...init.headers },
    });

    if (res.status === 429) {
      // Respect the server's own reset hint rather than guessing.
      const wait = Number(res.headers.get('RateLimit-Reset') ?? 5);
      await new Promise((r) => setTimeout(r, wait * 1000));
      return this.call<T>(path, init);
    }

    const body = await res.json().catch(() => ({}));
    if (!res.ok) {
      const err = new Error(body?.error?.message ?? res.statusText) as Error & { code?: string; status: number; body?: unknown };
      err.code = body?.error?.code;
      err.status = res.status;
      err.body = body;
      throw err;
    }
    return body as T;
  }

  me = () => this.call('/me');
  eventTypes = (q: Record<string, string> = {}) =>
    this.call(`/event-types?${new URLSearchParams(q)}`);
  availability = (eventTypeId: string, q: Record<string, string>) =>
    this.call(`/availability?${new URLSearchParams({ event_type_id: eventTypeId, ...q })}`);
  book = (payload: unknown) =>
    this.call('/bookings', { method: 'POST', body: JSON.stringify(payload) });
  cancel = (id: string, reason?: string) =>
    this.call(`/bookings/${id}/cancel`, { method: 'POST', body: JSON.stringify({ reason }) });
}
```

### Python

```python
import requests

class CalendlyDZ:
    def __init__(self, key: str, base: str = "https://your-host/api/v1"):
        self.base = base
        self.s = requests.Session()
        self.s.headers.update({"X-API-Key": key, "Content-Type": "application/json"})

    def _call(self, method: str, path: str, **kw):
        r = self.s.request(method, f"{self.base}{path}", timeout=30, **kw)
        if not r.ok:
            err = r.json().get("error", {})
            raise RuntimeError(f"{r.status_code} {err.get('code')}: {err.get('message')}")
        return r.json()

    def me(self):
        return self._call("GET", "/me")

    def availability(self, event_type_id: str, **params):
        return self._call("GET", "/availability",
                          params={"event_type_id": event_type_id, **params})

    def book(self, **payload):
        return self._call("POST", "/bookings", json=payload)
```

### PHP

```php
<?php
function calendly(string $method, string $path, ?array $body = null): array {
    $ch = curl_init("https://your-host/api/v1{$path}");
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_TIMEOUT        => 30,
        CURLOPT_HTTPHEADER     => [
            'X-API-Key: ' . getenv('CALENDLY_KEY'),
            'Content-Type: application/json',
        ],
        CURLOPT_POSTFIELDS => $body ? json_encode($body) : null,
    ]);
    $res  = curl_exec($ch);
    $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    $json = json_decode($res, true) ?? [];
    if ($code >= 400) {
        throw new RuntimeException($json['error']['message'] ?? "HTTP {$code}");
    }
    return $json;
}
```

---

## 14. Going to production

- [ ] Key stored in a secret manager, never in source or a client bundle
- [ ] Separate keys per environment and per product — a product's webhooks created with `"scope": "key"` so it never receives another product's guests
- [ ] Narrowest scopes that work
- [ ] `slot_unavailable` handled by offering the returned alternatives
- [ ] `429` handled with backoff on `RateLimit-Reset`
- [ ] Webhook endpoint verifies the HMAC **on the raw body**, in constant time
- [ ] Webhook handler acks within 10 s and works asynchronously
- [ ] Handlers idempotent on the envelope `id` (delivery is at-least-once)
- [ ] Nightly reconciliation against `GET /bookings` for missed deliveries
- [ ] Month-view availability instead of per-day loops
- [ ] `GET /api/v1/me` wired into your integration health check
- [ ] Key rotation rehearsed: create → deploy → revoke
- [ ] The platform itself deployed behind TLS — see [`deploy/README.md`](deploy/README.md)
- [ ] Demo organisation removed (its API key is published in this document)

---

## Appendix — endpoint index

| Method | Path | Scope |
|---|---|---|
| `GET` | `/api/v1/me` | `read` |
| `GET` | `/api/v1/webhook-events` | `read` |
| `GET` | `/api/v1/users` | `read` |
| `GET` | `/api/v1/users/{id}/schedules` | `read` |
| `GET` | `/api/v1/users/{id}/availability-rules` | `read` |
| `PUT` | `/api/v1/users/{id}/availability-rules` | `write` |
| `GET` | `/api/v1/event-types` | `read` |
| `GET` | `/api/v1/event-types/{id}` | `read` |
| `GET` | `/api/v1/event-types/{id}/hosts` | `read` |
| `PUT` | `/api/v1/event-types/{id}/hosts` | `write` |
| `POST` | `/api/v1/event-types` | `write` |
| `PATCH` | `/api/v1/event-types/{id}` | `write` |
| `DELETE` | `/api/v1/event-types/{id}` | `write` |
| `GET` | `/api/v1/availability` | `read` |
| `GET` | `/api/v1/bookings` | `read` |
| `GET` | `/api/v1/bookings/{id}` | `read` |
| `POST` | `/api/v1/bookings` | `write` |
| `POST` | `/api/v1/bookings/{id}/cancel` | `write` |
| `POST` | `/api/v1/bookings/{id}/reschedule` | `write` |
| `GET` | `/api/v1/webhooks` | `webhooks` |
| `POST` | `/api/v1/webhooks` | `webhooks` |
| `PATCH` | `/api/v1/webhooks/{id}` | `webhooks` |
| `DELETE` | `/api/v1/webhooks/{id}` | `webhooks` |
| `GET` | `/api/v1/webhooks/{id}/deliveries` | `webhooks` |
| `POST` | `/api/v1/webhooks/{id}/test` | `webhooks` |
