# 42min — API (full text)
> The 42min Public REST API, covering authentication (PATs, OAuth 2.1 + PKCE), scopes, rate limits, idempotency, and a reference for every /v1 endpoint.
---
# API overview
URL: https://42min.us/help/api/overview
# API overview
The 42min Public REST API lets you read and write the same data you see in
the [dashboard](/360) (event types, availability, slots, and bookings) from
your own systems. It is JSON over HTTPS, versioned at `/v1`, authenticated with
either a [personal access token](/help/api/authentication#personal-access-tokens-pats)
or an [OAuth 2.1 access token](/help/api/authentication#oauth-2-1--pkce).
Want an AI assistant (Claude, ChatGPT, …) to work with your 42min account
instead of writing code? 42min also has a built-in MCP server. See
[AI assistants (MCP)](/help/ai-assistants/overview).
## Base URL
```
https://api.42min.us
```
All paths in this reference are relative to that origin. Every request must use
HTTPS; plain `http://` is refused at the edge.
## OpenAPI specification
Every `/v1` resource, both ways of authenticating, the error envelope, and
the eight webhook events are described in one OpenAPI 3.1 file:
```
https://api.42min.us/openapi.yaml
```
Feed it to a client generator, import it into Postman, or hand it to a coding
agent. The same file is versioned in the public
[42min-developers](https://github.com/42min-us/42min-developers) repository
on GitHub, next to a working webhook receiver example you can copy.
## Versioning
The current major version is **`v1`**. It appears in every resource path:
```
GET https://api.42min.us/v1/me
```
Within a major version, additive changes (new fields, new endpoints, new optional
parameters) are made without bumping the version. Breaking changes ship as a
new major version. Discovery endpoints are unversioned and live under
[`/.well-known/`](/help/api/discovery).
## Response envelope
Every successful JSON response is wrapped in a small envelope:
```json
{
"data": { /* the resource, or a list */ },
"meta": {
"request_id": "req_…",
"next_cursor": "…",
"has_more": false
}
}
```
- `data` is the resource for single-resource responses, or an array for list
endpoints.
- `meta.request_id` is unique per request and is the value to quote when
reporting an issue.
- `meta.next_cursor` and `meta.has_more` appear only on paginated list
endpoints.
A handful of OAuth and discovery endpoints (the `/v1/oauth/*` family and
`/.well-known/*`) return the spec-defined raw bodies, which are **not** wrapped.
## Errors
Errors share one shape. See [Error envelope](/help/api/errors):
```json
{
"error": {
"code": "insufficient_scope",
"message": "This action requires the 'bookings:create' scope",
"details": { "required_scope": "bookings:create" },
"request_id": "req_…"
}
}
```
## Pagination
List endpoints (`/v1/event-types`, `/v1/bookings`) use **cursor pagination**.
Pass `limit` (default 20, max 100) and follow `meta.next_cursor` until
`meta.has_more` is `false`. Cursors are opaque base64url strings, so don't parse
them.
```bash
curl -H "Authorization: Bearer $TOKEN" \
"https://api.42min.us/v1/bookings?limit=50&cursor=eyJpZCI6Ii4uLiJ9"
```
## Identifiers
- **Event types** can be addressed by **UUID** *or* by `username/event-slug`
(the same path segments as the public booking page).
- **Bookings** are always addressed by their UUID (`uid`).
- All timestamps are ISO 8601 with offset, typically UTC (`…Z`).
- All durations are integers in **minutes**.
## What's in scope
| Resource | Read | Create | Mutate |
|---|---|---|---|
| [`/v1/_ping`](/help/api/ping) | ✓ | — | — |
| [`/v1/me`](/help/api/me) | ✓ | — | — |
| [`/v1/event-types`](/help/api/event-types) | ✓ | — | — |
| [`/v1/slots`](/help/api/slots) | ✓ | — | — |
| [`/v1/bookings`](/help/api/bookings) | ✓ | ✓ | cancel · reschedule · PATCH |
| [`/v1/webhooks`](/help/api/webhooks) | ✓ | ✓ | PATCH · DELETE · rotate-secret · test |
Webhooks have two sides: **managing** subscriptions over the API
([`/v1/webhooks`](/help/api/webhooks)) and **receiving** the signed callbacks
(verification, retries, auto-pause; see
[API & webhooks](/help/team-and-admin/api-and-webhooks)).
## Conventions
- **Authentication**: bearer tokens in the `Authorization` header. See
[Authentication](/help/api/authentication).
- **Scopes**: every endpoint declares a required scope; see
[Scopes](/help/api/scopes).
- **Rate limits**: 120 rpm authenticated, 30 rpm anonymous; see
[Rate limits](/help/api/rate-limits).
- **Idempotency**: the four booking writes (`POST /v1/bookings`, cancel,
reschedule, and `PATCH`) require an `Idempotency-Key` and replay the first
result on a retry. Webhook writes don't take the header, so a retried
`POST /v1/webhooks` creates a second subscription. See
[Idempotency](/help/api/idempotency).
- **Optimistic locking**: PATCH requires a matching ETag via `If-Match`; see
[Optimistic locking](/help/api/optimistic-locking).
## Where to mint credentials
- **Personal access tokens** are minted in
[Admin Center → API](/360/api) on the **API keys** tab.
- **OAuth clients** are registered on the **OAuth apps** tab of the same page.
## Without writing code
If you would rather wire 42min to other tools than call the API yourself, there
is an [n8n community node](/help/integrations/n8n) that covers the same
endpoints, plus triggers for every webhook event. It runs inside your own n8n,
so booking data goes straight from 42min to your server.
---
# Authentication
URL: https://42min.us/help/api/authentication
# Authentication
Every request to `/v1/*` must carry a bearer token:
```
Authorization: Bearer
```
Two token kinds are accepted:
| Prefix | Type | Use for |
|---|---|---|
| `42min_pat_…` | Personal access token (PAT) | Server-side scripts and integrations that act as **your own account**. |
| `42min_at_…` | OAuth 2.1 access token | A **third-party app** acting on behalf of a 42min user. |
There is no other auth surface: cookies, session JWTs, and Basic auth are not
accepted on `/v1/*`. Missing or malformed credentials return `401 invalid_token`
with a `WWW-Authenticate: Bearer …` header.
## What a token can reach
A token acts as its user. Whatever the member behind it can see and change in
42min, the token can see and change through the API, and nothing more:
| The token's user is | Bookings the token reaches |
|---|---|
| Member | The bookings they host |
| [Manager](/help/team-and-admin/manager-role) | Their own, plus those hosted by members of the groups they manage |
| Admin or owner | Every booking in the account |
A booking outside that scope is reported as `404 booking_not_found`, on reads
and writes alike, so an app connected by a regular member cannot tell whether
a colleague's booking exists. The same rule decides what a
[webhook subscription](/help/api/webhooks#what-a-subscription-receives)
receives. Scopes narrow this further; they never widen it. The
[AI assistant](/help/ai-assistants/permissions-and-security#what-the-assistant-sees-your-role)
follows the same table.
## Personal access tokens (PATs)
PATs are long-lived bearer tokens scoped to your own account. Use them for
internal scripts, cron jobs, server-side automations, anywhere the caller *is*
you.
### Token format
```
42min_pat__
```
- `42min_pat_`: fixed prefix that identifies the token family.
- ``: 12-character public lookup id (Crockford base32). Used only to
find the row; not a secret.
- ``: 32-character secret. Hashed at rest with HMAC-SHA256 keyed by
a server pepper. **Shown once at creation**, so store it immediately.
### Mint a PAT
1. Open [Admin Center → API](/360/api) and switch to the **API keys** tab. You
need the **Admin** role.
2. Click **New API key**. Give it a name, pick the [scopes](/help/api/scopes)
you want it to hold, and optionally set an expiry.
3. Copy the token that appears: it is shown **once** and never again. The
dashboard only ever displays the last four characters afterward.
Limits:
- **42 active keys** per account.
- Key names are unique per account (case-insensitive).
- Adding scopes to an existing key requires re-confirming your account
password.
```bash
curl -H "Authorization: Bearer 42min_pat_ABCDEFGHJKMN_PQRSTVWXYZ23456789ABCDEFGHJK" \
https://api.42min.us/v1/me
```
### Rotation and revocation
- **Regenerate** issues a new secret on the same key id; the old secret is
revoked immediately.
- **Delete** revokes a key. Once revoked, requests with that token return
`401 token_revoked`.
- Expired keys return `401 token_expired`.
PATs are owned by the user who created them and inherit that user's account
membership. If the user is removed from the account, the keys they own stop
working.
## OAuth 2.1 + PKCE
For **third-party applications** that act on behalf of a 42min user, use the
OAuth 2.1 authorization code flow with **PKCE (S256)**. PKCE is mandatory for
every client, public and confidential alike.
Discovery is published at
[`/.well-known/oauth-authorization-server`](/help/api/discovery).
| Endpoint | Path |
|---|---|
| Authorization | `GET https://api.42min.us/v1/oauth/authorize` |
| Token | `POST https://api.42min.us/v1/oauth/token` |
| Revocation | `POST https://api.42min.us/v1/oauth/revoke` |
| Introspection | `POST https://api.42min.us/v1/oauth/introspect` |
### Register an OAuth client
1. In [Admin Center → API](/360/api), open the **OAuth apps** tab. Admin role
required.
2. Click **New OAuth app** and fill in:
- **Name**: shown to users on the consent screen.
- **Client type**: `confidential` (server-side, can hold a secret) or
`public` (browser/mobile, uses PKCE only).
- **Redirect URIs**: exact match required at authorize time. `https://`
only, except `http://localhost` and `http://127.0.0.1` for development.
- **Allowed scopes**: the superset of [scopes](/help/api/scopes) the app
can ever request. Per-grant scopes must be a subset of this list.
3. Save. You get back:
- `client_id`: `42min_<24 chars>`, not secret.
- `client_secret`: `42min_cs_<48 chars>`, **shown once** (confidential
clients only).
- **Rotate secret** issues a new one and revokes the old one immediately.
### Dynamic Client Registration
The hosted deployment also accepts RFC 7591 registrations at
`POST /v1/oauth/register`, advertised as `registration_endpoint` in the
[discovery document](/help/api/discovery). AI assistants connecting over
[MCP](/help/ai-assistants/connecting-your-assistant) use it to register
themselves before sending you to the consent screen, which is why connecting
one never involves a client ID. A self-registered client is held to a
tighter policy than an app created in the Admin Center:
- **Scopes come from an allowlist**: `user:read`, `slots:read`,
`event_types:read`, and the two `mcp:scheduling:*` aliases. Asking for
anything else, `bookings:create` for example, fails with
`400 invalid_client_metadata`, and the error lists what is allowed.
- **Redirect URIs** follow the rules above (`https://` only, except
`http://localhost` and `http://127.0.0.1`), with at most 20 per client.
- **Consent says where the app came from.** The consent screen marks a
self-registered client as an **Unverified app** and shows the hostname it
redirects to.
- **Unused registrations expire.** A client that still has no token 30 days
after registering is deleted.
- **Rate limit**: 5 registrations per minute per IP. See
[Rate limits](/help/api/rate-limits).
The response carries `client_id`, plus a `client_secret` unless
`token_endpoint_auth_method` is `none`. An app that needs write scopes on
`/v1` is registered in the Admin Center instead.
### The full flow
```text
your app ──► 1. /v1/oauth/authorize ──► 302 to 42min.us/oauth/consent
│
▼
user logs in & approves on consent screen
│
▼
your app ◄── 2. 302 redirect_uri?code=42min_ac_…&state=…
your app ──► 3. POST /v1/oauth/token (code + code_verifier)
your app ◄── 4. { access_token: "42min_at_…", refresh_token: "42min_rt_…", expires_in: 3600 }
```
#### 1. Build the PKCE verifier and challenge
Generate a random `code_verifier` (43–128 URL-safe characters) and derive
`code_challenge = BASE64URL(SHA256(code_verifier))`. Persist the verifier in
the user's session, and you need it at step 3.
#### 2. Send the user to `/v1/oauth/authorize`
```
https://api.42min.us/v1/oauth/authorize
?response_type=code
&client_id=42min_…
&redirect_uri=https://yourapp.example.com/callback
&scope=event_types:read%20slots:read%20bookings:write
&code_challenge=
&code_challenge_method=S256
&state=
```
Required parameters:
- `response_type=code`: the only response type supported.
- `client_id`
- `redirect_uri`: must exactly match one of the URIs registered on the client.
- `code_challenge` and `code_challenge_method=S256`; anything else is rejected.
- `scope`: space-separated list of scopes; must be a subset of the client's
`allowed_scopes`.
- `state`: opaque value echoed back on redirect. Use it to bind the response
to the user's session and to defend against CSRF.
42min redirects to its in-product consent screen, where the user signs in (if
needed) and approves or denies the request. After the decision, 42min redirects
to your `redirect_uri`:
```
https://yourapp.example.com/callback?code=42min_ac_…&state=
```
On denial:
```
https://yourapp.example.com/callback?error=access_denied&state=
```
Authorization codes live for **10 minutes** and are single-use.
#### 3. Exchange the code at `/v1/oauth/token`
```bash
curl -X POST https://api.42min.us/v1/oauth/token \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode grant_type=authorization_code \
--data-urlencode code=42min_ac_… \
--data-urlencode redirect_uri=https://yourapp.example.com/callback \
--data-urlencode client_id=42min_… \
--data-urlencode code_verifier= \
--data-urlencode client_secret=42min_cs_… # confidential clients only
```
Confidential clients may authenticate with HTTP Basic instead of body
parameters: `Authorization: Basic base64(client_id:client_secret)`.
Response:
```json
{
"access_token": "42min_at_…",
"refresh_token": "42min_rt_…",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "event_types:read slots:read bookings:create bookings:cancel bookings:reschedule bookings:update"
}
```
- **Access tokens** live for **1 hour**.
- **Refresh tokens** live for **60 days** and are **rotated on every use**.
- The `scope` claim reflects the *expanded* set (aliases like `bookings:write`
are unrolled). See [Scopes](/help/api/scopes).
#### 4. Refresh
```bash
curl -X POST https://api.42min.us/v1/oauth/token \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode grant_type=refresh_token \
--data-urlencode refresh_token=42min_rt_… \
--data-urlencode client_id=42min_… \
--data-urlencode client_secret=42min_cs_…
```
You get a **new** access token *and* a **new** refresh token. **Discard the
old refresh token immediately**. See reuse detection below.
### Refresh token reuse detection
Refresh tokens are one-shot. Every successful refresh marks the presented
refresh token as consumed and issues a new pair. If a previously-consumed (or
revoked) refresh token is ever presented again, the entire **grant family**
(every access and refresh token issued from the same authorization code) is
revoked immediately. The caller gets:
```json
{
"error": "invalid_grant",
"message": "Refresh token has already been used; the session has been revoked"
}
```
This protects against the case where an attacker captures a refresh token: the
moment the legitimate client or the attacker uses it a second time, the session
is killed and the user must re-authorize.
The same protection applies to **authorization codes**: replay of a consumed
code revokes the whole grant family with `error: invalid_grant`,
`message: "Authorization code already used"`.
### Revocation
```bash
curl -X POST https://api.42min.us/v1/oauth/revoke \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode token=42min_rt_… \
--data-urlencode client_id=42min_… \
--data-urlencode client_secret=42min_cs_…
```
Per RFC 7009 the endpoint always returns `200 OK`, even for unknown tokens.
Revoking a refresh token also revokes every access token in the same grant
family; revoking an access token revokes only that token.
### Introspection (confidential clients)
```bash
curl -X POST https://api.42min.us/v1/oauth/introspect \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode token=42min_at_… \
--data-urlencode client_id=42min_… \
--data-urlencode client_secret=42min_cs_…
```
Returns `{ "active": true, "scope": "…", "client_id": "…", "exp": …, … }` for
tokens **owned by the calling client**. Tokens belonging to a different client
(or expired/revoked/unknown tokens) return `{ "active": false }`, so there is no
information leak across clients.
### Client types and token-endpoint auth
- **Confidential** clients authenticate with `client_secret_basic` or
`client_secret_post`.
- **Public** clients (mobile, SPAs) authenticate with PKCE only:
`token_endpoint_auth_method=none`.
### Token lifetimes at a glance
| Token | TTL | Rotated on refresh |
|---|---|---|
| Authorization code | 10 minutes (single use) | — |
| Access token | 1 hour | — |
| Refresh token | 60 days | yes |
| PAT | unlimited unless `expiresAt` is set | — |
## The MCP endpoint uses the same tokens
The [AI-assistant (MCP) endpoint](/help/ai-assistants/overview) at `/mcp`
accepts the same two token kinds:
- **PATs** work as-is. Pass one as the bearer token in your MCP client's
configuration. Read-only assistant access is PAT-only: mint a key holding
just `:read` scopes.
- **OAuth tokens** for MCP are normally obtained by the assistant itself:
it discovers the authorization server, sends you to the 42min consent
screen, and receives a token bound to the MCP endpoint. A token issued for
`/v1/*` isn't valid on `/mcp` and vice versa. If you register your own
OAuth app and want it to connect assistants, enable **MCP full access**
on the app, and it grants the `mcp:scheduling:*` aliases described in
[Scopes](/help/api/scopes).
Setup steps per assistant are in
[Connecting your assistant](/help/ai-assistants/connecting-your-assistant).
## Choosing PAT vs. OAuth
- Building a **personal script** or an integration that acts as a specific
user: **PAT**.
- Building a **product that other 42min users install** to give your app
access to their account: **OAuth**.
- Connecting an **AI assistant** (Claude, ChatGPT, …), usually neither by
hand: paste the MCP endpoint into the assistant and approve with OAuth; use
a PAT only for read-only access or clients without OAuth support. See
[AI assistants](/help/ai-assistants/overview).
PATs are simpler but inherit one user's identity. OAuth is more work but gives
each end-user their own consent screen, their own scope set, and their own
revocation control.
---
# Scopes
URL: https://42min.us/help/api/scopes
# Scopes
Every endpoint declares a required scope. The bearer token must hold that
scope or the request fails with **`403 insufficient_scope`** and a
`WWW-Authenticate: Bearer error="insufficient_scope" scope=""` header.
Scopes are granted when:
- the user **approves the OAuth consent screen** for a third-party client,
- the admin **selects scopes when minting a PAT**, or
- the user **connects an [AI assistant](/help/ai-assistants/overview)**, and the
MCP consent screen grants the two `mcp:scheduling:*` aliases below.
## Active scopes
| Scope | What it grants |
|---|---|
| `user:read` | Read the authenticated user's profile (`/v1/me`). |
| `users:read` | List and search organization members. (MCP only.) |
| `event_types:read` | List and read event types. |
| `event_types:create` | Create event types. (Reserved; no public write endpoints yet.) |
| `event_types:update` | Modify event types. (MCP only.) |
| `event_types:delete` | Delete event types. (Reserved.) |
| `slots:read` | Check slot availability. |
| `bookings:read` | List and read bookings. |
| `bookings:create` | Create bookings. |
| `bookings:cancel` | Cancel bookings. |
| `bookings:reschedule` | Reschedule bookings. |
| `bookings:update` | Patch booking metadata, responses, and attendee name. |
| `webhooks:read` | List and read webhook subscriptions, and their recent deliveries (`/v1/webhooks`). |
| `webhooks:write` | Create, edit, delete, rotate the secret of, and test webhooks. |
| `availability:read` | Read schedules. (MCP only.) |
| `availability:write` | Modify schedules. (MCP only.) |
| `meetings:read` | Read the host-side meetings view. (MCP only.) |
| `meetings:write` | Mark meetings as no-show. (MCP only.) |
| `series:read` | List and read recurring meeting series. |
| `series:write` | Create recurring series, and pause, resume, end, edit, or change their host. |
| `contacts:read` | Search contacts and read meeting history. (MCP only.) |
| `single_use_links:read` | List secret links. (MCP only.) |
| `single_use_links:write` | Generate and delete secret links. (MCP only.) |
| `roundtables:read` | List and read roundtables (group scheduling polls). (MCP only.) |
| `roundtables:write` | Create and publish roundtables. (MCP only.) |
"Reserved" scopes are recognized by the auth server but nothing consumes them
yet, but they exist so apps can request the right surface ahead of the endpoints
landing. "MCP only" scopes are consumed by the
[MCP server's tools](/help/ai-assistants/what-your-assistant-can-do) (the
`/mcp` endpoint for [AI assistants](/help/ai-assistants/overview)); no `/v1/*`
REST endpoint reads them yet.
## Alias scopes
Some commonly-bundled scopes are exposed as **aliases**. When a token is
granted with an alias, the alias is **expanded at grant time** into its
constituent granular scopes, and runtime checks always read the expanded set.
| Alias | Expands to |
|---|---|
| `event_types:write` | `event_types:create`, `event_types:update`, `event_types:delete` |
| `bookings:write` | `bookings:create`, `bookings:cancel`, `bookings:reschedule`, `bookings:update` |
| `mcp:scheduling:read` | `user:read`, `event_types:read`, `slots:read`, `bookings:read`, `meetings:read`, `contacts:read`, `availability:read`, `single_use_links:read`, `roundtables:read`, `users:read` |
| `mcp:scheduling:write` | everything in `mcp:scheduling:read`, plus `event_types:update`, `bookings:create`, `bookings:cancel`, `bookings:reschedule`, `meetings:write`, `availability:write`, `single_use_links:write`, `roundtables:write` |
The two `mcp:scheduling:*` aliases are what the
[AI-assistant consent screen](/help/ai-assistants/permissions-and-security)
grants. They are OAuth-only: they never appear in the PAT scope picker, and
are only valid for tokens addressed to the MCP endpoint.
Practical consequence: a token granted `bookings:write` carries four granular
scopes, and that's the set you'll see in `meta.request_id` audit logs, in the
`/v1/_ping` response, and in the `scope` claim of an issued OAuth token.
## Reserved scopes (not yet active)
These appear in discovery but no endpoint currently consumes them:
```
routing_forms:read routing_forms:create routing_forms:update
routing_forms:delete routing_forms:write teams:read
teams:write calendars:read calendars:write
analytics:read
```
## Required scope per endpoint
| Method · Path | Scope |
|---|---|
| `GET /v1/_ping` | *none* (auth only) |
| `GET /v1/me` | `user:read` |
| `GET /v1/event-types` | `event_types:read` |
| `GET /v1/event-types/:idOrSlug` | `event_types:read` |
| `GET /v1/slots` | `slots:read` |
| `GET /v1/slots/check` | `slots:read` |
| `GET /v1/bookings` | `bookings:read` |
| `GET /v1/bookings/:uid` | `bookings:read` |
| `POST /v1/bookings` | `bookings:create` |
| `POST /v1/bookings/:uid/cancel` | `bookings:cancel` |
| `POST /v1/bookings/:uid/reschedule` | `bookings:reschedule` |
| `PATCH /v1/bookings/:uid` | `bookings:update` |
| `GET /v1/webhooks` | `webhooks:read` |
| `GET /v1/webhooks/:id` | `webhooks:read` |
| `GET /v1/webhooks/:id/deliveries` | `webhooks:read` |
| `POST /v1/webhooks` | `webhooks:write` |
| `PATCH /v1/webhooks/:id` | `webhooks:write` |
| `DELETE /v1/webhooks/:id` | `webhooks:write` |
| `POST /v1/webhooks/:id/rotate-secret` | `webhooks:write` |
| `POST /v1/webhooks/:id/test` | `webhooks:write` |
## Requesting scopes
**OAuth:** include them on `/v1/oauth/authorize` as a space-separated `scope`
parameter. The set must be a subset of the client's registered `allowed_scopes`,
otherwise `400 invalid_scope`. The user can narrow further from the consent
screen; granted scopes are the user's choice intersected with the request.
**PAT:** select scopes in the **New API key** dialog in
[Admin Center → API](/360/api). Expanding the scope set on an existing key
requires re-confirming your account password; narrowing does not.
## Insufficient scope
```json
{
"error": {
"code": "insufficient_scope",
"message": "This action requires the 'bookings:create' scope",
"details": { "required_scope": "bookings:create" },
"request_id": "req_…"
}
}
```
The remedy is always: mint or re-authorize a token that holds the required
scope. Scopes cannot be elevated mid-session.
---
# Rate limits
URL: https://42min.us/help/api/rate-limits
# Rate limits
The API caps requests per fixed **60-second window**. Every response, success
or error, carries the current bucket's counters:
```
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 117
X-RateLimit-Reset: 1715701260
```
- `Limit`: requests allowed in the current window.
- `Remaining`: what you have left in this window.
- `Reset`: unix timestamp (seconds) when the window rolls over.
## Default buckets
| Caller | Limit | Keyed by |
|---|---|---|
| **Authenticated PAT** | 120 rpm | PAT id |
| **Authenticated OAuth** | 120 rpm | `(client_id, account_id)` |
| **Anonymous** | 30 rpm | client IP |
Buckets are independent, so a single account holding two PATs gets `2 × 120` rpm
across them.
## Per-endpoint overrides
OAuth public endpoints have their own dedicated buckets, keyed by IP, so a
spike on one endpoint can't starve the others:
| Endpoint | Limit |
|---|---|
| `GET /v1/oauth/authorize` | 30 rpm / IP |
| `POST /v1/oauth/token` | 60 rpm / IP |
| `POST /v1/oauth/revoke` | 60 rpm / IP |
| `POST /v1/oauth/introspect` | 120 rpm / IP |
| `POST /v1/oauth/register` | 5 rpm / IP |
## Going over the limit
The next request after the bucket is exhausted returns **`429 rate_limited`**:
```json
{
"error": {
"code": "rate_limited",
"message": "Rate limit exceeded; retry in 27s.",
"details": { "bucket": "pat", "limit": 120, "window_seconds": 60 },
"request_id": "req_…"
}
}
```
Headers:
```
Retry-After: 27
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1715701260
```
Wait at least `Retry-After` seconds before retrying.
## Recommendations
- Read `X-RateLimit-Remaining` proactively and slow your batch jobs down as
it approaches 0, and don't wait for the 429.
- On `429`, back off until the window resets. Reset is a hard boundary, not
a sliding penalty.
- For bulk reads, prefer `limit=100` (the max) and follow `meta.next_cursor`
rather than re-issuing the same query with smaller pages.
- Buckets are **fail-open** if our rate-limit cache hiccups, and the headers
remain best-effort during such an outage.
## Anonymous calls
A few endpoints accept unauthenticated requests (`/.well-known/*`, OAuth
public endpoints). They share the 30 rpm-per-IP anonymous bucket. Any real
integration should be authenticated and will use the higher per-token quota.
---
# Idempotency
URL: https://42min.us/help/api/idempotency
# Idempotency
Every **write** endpoint on `/v1/bookings` requires an `Idempotency-Key`
header. The key lets you safely retry a request after a network failure
without creating a duplicate booking or applying the same mutation twice.
## When it's required
| Endpoint | Required? |
|---|---|
| `POST /v1/bookings` | yes |
| `POST /v1/bookings/:uid/cancel` | yes |
| `POST /v1/bookings/:uid/reschedule` | yes |
| `PATCH /v1/bookings/:uid` | yes |
Calling one of these without the header returns
**`400 missing_idempotency_key`**.
## Picking a key
Any caller-supplied string up to **255 characters**. Use something unique
per *logical* operation, and a UUID is the safest default:
```
Idempotency-Key: 1f0a3b6e-7c1d-4a3e-9e91-9b6c0f8b18f4
```
Reuse the **same** key on retries of the same operation. Use a **different**
key for a different operation, even when the body looks similar.
## What "replay" means
Each `(user, endpoint, Idempotency-Key)` triple is remembered for **24
hours**: the key belongs to the member behind the token and to the one
endpoint it was sent to. Within that window:
- **Same key, same body** → the original response is replayed, including its
HTTP status code. The work is **not** executed twice.
- **Same key, different body** → **`409 idempotency_key_conflict`**. The
server fingerprints the request body and refuses to associate one key with
two different requests.
- **Same key, request still in flight** → **`409 idempotency_in_progress`**
with `Retry-After: 1`. The first attempt is mid-execution; retry shortly.
- **First attempt errored** → the key reservation is **released** so a retry
can proceed with the same key. Only successful responses are cached.
- **Same key, another member or another endpoint** → a **new request**. A
colleague in the same account reusing your key gets their own, freshly
authorized result, never a replay of yours, and the same key sent to cancel
after a reschedule is a cancel, not a replay of the reschedule.
Body comparison uses a canonical JSON fingerprint, so key order and whitespace
don't matter.
## What it does not protect against
- **Bumping resource versions.** Idempotency is request-level; optimistic
locking via [`If-Match`](/help/api/optimistic-locking) is what guards
concurrent edits.
- **Different keys, same intent.** If you retry with a fresh key the server
has no way to know it's a retry, so you'll create a second booking.
## Example
```bash
# First attempt
curl -X POST https://api.42min.us/v1/bookings \
-H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: 1f0a3b6e-7c1d-4a3e-9e91-9b6c0f8b18f4" \
-H "Content-Type: application/json" \
-d '{"event_type_id":"…","start":"2026-05-20T15:00:00Z","attendee":{"email":"a@b.com"}}'
# → 201 { "data": { "uid": "abc…", … }, "meta": { "request_id": "req_…" } }
# Network failure on the way back? Retry with the SAME key:
curl -X POST https://api.42min.us/v1/bookings \
-H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: 1f0a3b6e-7c1d-4a3e-9e91-9b6c0f8b18f4" \
-H "Content-Type: application/json" \
-d '{"event_type_id":"…","start":"2026-05-20T15:00:00Z","attendee":{"email":"a@b.com"}}'
# → 201 (replayed) — the SAME booking, no duplicate.
```
## Best practices
- Generate the key **before** sending the request and store it alongside the
pending operation in your own system.
- Use a **fresh key** for each new operation, and never reuse one across
semantically different requests.
- On `409 idempotency_in_progress`, sleep briefly and retry; the original
request will land or fail within seconds.
- On `409 idempotency_key_conflict`, your client has a bug: you're reusing a
key for two different requests. Mint a new key.
---
# Optimistic locking (ETag / If-Match)
URL: https://42min.us/help/api/optimistic-locking
# Optimistic locking
Mutations on existing bookings use **optimistic concurrency control** keyed by
a monotonically-increasing `version` integer. The current version travels as
an `ETag` on reads; PATCH callers must echo it back on `If-Match`.
## How it works
1. `GET /v1/bookings/:uid` returns the booking and an `ETag` header:
```
ETag: "3"
```
The number is the booking's current `version`. It also appears in the
response body as `data.version`.
2. `PATCH /v1/bookings/:uid` requires that ETag on `If-Match`:
```
If-Match: "3"
```
3. If the booking's current version is still `3`, the patch applies and the
version is bumped to `4`. The response carries `ETag: "4"`.
4. If someone else mutated the booking in between (so the current version is
now `4`), the server returns **`409 version_conflict`** and your patch is
not applied:
```json
{
"error": {
"code": "version_conflict",
"message": "The If-Match version does not match the current booking version",
"details": {},
"request_id": "req_…"
}
}
```
Re-`GET` the booking, apply your change on top of the fresh version, and
retry.
## Error matrix
| Condition | HTTP | Code |
|---|---|---|
| `If-Match` missing | `428 Precondition Required` | `missing_if_match` |
| `If-Match` not a valid integer | `400 Bad Request` | `invalid_if_match` |
| Version no longer current | `409 Conflict` | `version_conflict` |
Weak ETags are accepted, so `If-Match: W/"3"` is treated the same as
`If-Match: "3"`. The version starts at `1` on creation and increments on every
successful `PATCH`, `cancel`, or `reschedule`.
## What about cancel and reschedule?
`POST /v1/bookings/:uid/cancel` and `POST /v1/bookings/:uid/reschedule` do
**not** require `If-Match`, since they are idempotent operations whose intent is
unambiguous regardless of intervening edits. They still bump the version, so a
later PATCH after a cancel must use the post-cancel ETag.
## Example: read-modify-write
```bash
# 1) GET to learn the current version
curl -i -H "Authorization: Bearer $TOKEN" \
https://api.42min.us/v1/bookings/abc-def-…
# ETag: "3"
# { "data": { "uid": "abc-def-…", "version": 3, "metadata": { "crm_id": "C-7" }, … } }
# 2) PATCH with that ETag
curl -X PATCH https://api.42min.us/v1/bookings/abc-def-… \
-H "Authorization: Bearer $TOKEN" \
-H "If-Match: \"3\"" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"metadata":{"crm_id":"C-8","stage":"qualified"}}'
# → 200, ETag: "4"
```
## Don'ts
- Don't try to PATCH `start`, `end`, `timezone`, `status`, `event_type_id`,
`uid`, or `version`, which return **`422 field_immutable`** regardless of
the ETag. To move a booking, use **reschedule**; to end it, use **cancel**.
- Don't cache an ETag indefinitely. The version may bump from webhooks,
workflows, or the dashboard at any moment.
- Don't try to compare ETags. They are integers, but treat them as opaque
tokens. The only safe operation is to echo them back on `If-Match`.
---
# Errors
URL: https://42min.us/help/api/errors
# Errors
Every error response (including auth failures, rate-limit responses, and
validation errors) has this shape:
```json
{
"error": {
"code": "insufficient_scope",
"message": "This action requires the 'bookings:create' scope",
"details": { "required_scope": "bookings:create" },
"request_id": "req_…"
}
}
```
- **`code`**: stable machine identifier. Switch on this, not on `message`.
- **`message`**: human-readable explanation. Wording may change.
- **`details`**: endpoint-specific extra fields (e.g. the offending parameter
name, or the required scope). May be empty `{}`.
- **`request_id`**: unique per request. **Quote this** when reporting an
issue.
The HTTP status is set per RFC 7231, so switch on it for routing (`5xx` ⇒ retry,
`4xx` ⇒ fix the request) and use `code` for granular handling.
## Common error codes
### Authentication (`401`)
| Code | Meaning |
|---|---|
| `invalid_token` | Missing, malformed, or unknown bearer token. |
| `token_expired` | Token is past its expiry. |
| `token_revoked` | PAT or OAuth token has been revoked. |
These responses also include `WWW-Authenticate: Bearer realm="42min",
error="…"`, useful for clients that follow the RFC 6750 challenge protocol.
### Authorization (`403`)
| Code | Meaning |
|---|---|
| `insufficient_scope` | Token is valid but missing the required scope. `details.required_scope` names which one. |
| `forbidden` | Generic refusal: auth and scope are fine, but the actor isn't allowed to do this. |
### Validation (`400` / `422`)
| Code | Meaning |
|---|---|
| `validation_error` | Request body failed validation. |
| `invalid_request` | The request itself is malformed (bad headers, bad shape). |
| `invalid_query_param` | A query parameter is missing or malformed. `details.param` names which one. |
| `attendee_email_invalid` | `attendee.email` failed format check. |
| `attendee_phone_required` | `attendee.sms_opt_in` is `true` but `attendee.phone` is missing. |
| `attendee_phone_invalid` | `attendee.sms_opt_in` is `true` but `attendee.phone` isn't a valid international number (starting with `+`). |
| `invalid_if_match` | `If-Match` is not a valid integer. |
| `field_immutable` | A PATCH tried to write a field that can only change via dedicated endpoints (e.g. `start`). `details.fields` lists them. |
| `event_type_disallows_reschedule` | The event type has `disableRescheduling`. |
### Conflict / state (`409`)
| Code | Meaning |
|---|---|
| `slot_unavailable` | Requested slot is no longer free. |
| `event_type_inactive` | Event type's status is not `on`. |
| `booking_in_past` | Booking starts (or started) in the past. |
| `booking_already_cancelled` | Reschedule attempted on a non-confirmed booking. |
| `version_conflict` | `If-Match` doesn't match the current booking version. |
| `idempotency_in_progress` | Same `Idempotency-Key` still being processed. `Retry-After: 1`. |
| `idempotency_key_conflict` | Same key, different body within the 24h window. |
| `pat_name_taken` | Another PAT in this account has the same name. |
| `pat_limit_exceeded` | Account has 42 active PATs already. |
### Not found (`404`)
| Code | Meaning |
|---|---|
| `not_found` | Generic. |
| `event_type_not_found` | Event type doesn't exist or is in another account. |
| `booking_not_found` | Booking doesn't exist, was hard-deleted, or its UID is malformed. |
| `interaction_not_found` | OAuth consent interaction expired or doesn't exist. |
We deliberately collapse "wrong tenant" into `not_found`, since a 404 doesn't leak
the existence of resources in other accounts.
### Precondition / payload (`412` / `413` / `415` / `428`)
| Code | Meaning |
|---|---|
| `missing_if_match` | PATCH without `If-Match`. (`428 Precondition Required`.) |
| `missing_idempotency_key` | Write without `Idempotency-Key`. (`400`.) |
| `invalid_idempotency_key` | `Idempotency-Key` longer than 255 chars. (`400`.) |
### Rate-limiting (`429`)
| Code | Meaning |
|---|---|
| `rate_limited` | Bucket exhausted. `Retry-After` header set; see [Rate limits](/help/api/rate-limits). |
### OAuth (`400` / `401`)
| Code | Meaning |
|---|---|
| `invalid_client` | Bad `client_id` / `client_secret`, or unknown client. |
| `invalid_grant` | Bad auth code, bad refresh token, PKCE failure, redirect-URI mismatch, or replay detected. |
| `invalid_scope` | Requested scope not allowed for this client, or unknown scope name. |
| `invalid_redirect_uri` | redirect URI is not registered or fails scheme rules. |
| `unsupported_grant_type` | `grant_type` is not `authorization_code` or `refresh_token`. |
| `unsupported_response_type` | `response_type` is not `code`. |
| `invalid_client_metadata` | DCR request rejected (scope not in allowlist, etc.). |
| `access_denied` | User declined on the consent screen. Returned via redirect, not a JSON body. |
### Server (`5xx`)
| Code | Meaning |
|---|---|
| `internal_error` | Something went wrong server-side. Quote `request_id` to support. |
| `slot_lock_timeout` | Could not acquire the per-slot lock when creating/rescheduling a booking. `Retry-After: 1`. (`503`.) |
## Recommended client handling
1. **`5xx` or network failure**: retry with the same `Idempotency-Key` after
a brief delay (exponential backoff, capped). Writes are safe to retry as
long as the key stays the same.
2. **`429`**: wait for `Retry-After` or `X-RateLimit-Reset`, then retry.
3. **`401 token_expired`**: refresh (OAuth) or rotate (PAT). For OAuth, if
the refresh attempt itself returns `invalid_grant` with "session has been
revoked", start the authorize flow from scratch.
4. **`409 version_conflict`**: re-`GET` and reapply your patch on the new
version.
5. **`409 slot_unavailable`**: the slot was taken between your check and
your create. Pick another slot (the booking page or `/v1/slots` will reflect
the new state).
6. **`4xx` other**: surface to the user; usually a bug in the request.
Always log `request_id` from `error.request_id` (or
`meta.request_id` on success), which is the fastest way for support to trace what
happened.
---
# Discovery
URL: https://42min.us/help/api/discovery
# Discovery
Two RFC-defined discovery documents are published at the API origin. Use them
to bootstrap OAuth clients without hard-coding endpoint URLs.
| Document | Path | Spec |
|---|---|---|
| Authorization server | `/.well-known/oauth-authorization-server` | RFC 8414 |
| Protected resource | `/.well-known/oauth-protected-resource` | RFC 9728 |
Both are unauthenticated `GET` endpoints and return raw JSON (no
`{ "data": …, "meta": … }` envelope).
## Authorization-server metadata
```bash
curl https://api.42min.us/.well-known/oauth-authorization-server
```
```json
{
"issuer": "https://api.42min.us",
"authorization_endpoint": "https://api.42min.us/v1/oauth/authorize",
"token_endpoint": "https://api.42min.us/v1/oauth/token",
"revocation_endpoint": "https://api.42min.us/v1/oauth/revoke",
"introspection_endpoint": "https://api.42min.us/v1/oauth/introspect",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": [
"client_secret_basic",
"client_secret_post",
"none"
],
"revocation_endpoint_auth_methods_supported": [
"client_secret_basic",
"client_secret_post",
"none"
],
"introspection_endpoint_auth_methods_supported": [
"client_secret_basic",
"client_secret_post"
],
"scopes_supported": ["user:read", "event_types:read", "…"],
"registration_endpoint": "https://api.42min.us/v1/oauth/register"
}
```
Notes:
- `response_types_supported` and `grant_types_supported` are intentionally
short: only **authorization code** and **refresh token** grants are
supported. Implicit, password, and client-credentials grants are not.
- `code_challenge_methods_supported` is **`["S256"]`** only. Plain PKCE
challenges are rejected.
- `registration_endpoint` is present because **Dynamic Client Registration**
is enabled on the hosted `api.42min.us` deployment. AI assistants connecting
over MCP rely on it to register themselves before sending you to the
consent screen. Self-registered clients are held to a tighter policy than
apps created in [Admin Center → API](/360/api); see
[Dynamic Client Registration](/help/api/authentication#dynamic-client-registration).
- `scopes_supported` is the union of active and reserved scopes. See
[Scopes](/help/api/scopes).
## Protected-resource metadata
```bash
curl https://api.42min.us/.well-known/oauth-protected-resource
```
```json
{
"resource": "https://api.42min.us",
"authorization_servers": ["https://api.42min.us"],
"scopes_supported": ["user:read", "event_types:read", "…"],
"bearer_methods_supported": ["header"]
}
```
`bearer_methods_supported: ["header"]` is the only accepted form, and bearer
tokens travel in `Authorization: Bearer …`, never as query parameters or form
fields on the resource endpoints.
## Why use discovery
- **One-time client setup.** Read the document at boot, cache the endpoint
URLs for the lifetime of the process. You won't need to update your code if
any of the endpoint paths change.
- **Standard OAuth libraries.** Most libraries can pull the endpoints from
the discovery document automatically.
- **Scope enumeration.** `scopes_supported` is the authoritative list, so match
it against your UI's checkboxes.
---
# GET /v1/_ping
URL: https://42min.us/help/api/ping
# GET `/v1/_ping`
A trivial smoke endpoint. Returns the auth context derived from your token —
useful for confirming a credential works without touching real data.
| | |
|---|---|
| **Method** | `GET` |
| **URL** | `https://api.42min.us/v1/_ping` |
| **Scope** | *none* — any valid token works |
| **Auth** | Required |
## Headers
```
Authorization: Bearer
```
## Query parameters
None.
## Response
```json
{
"data": {
"ok": true,
"auth_type": "pat",
"account_id": "01HXXX…",
"scopes": ["bookings:read", "slots:read"],
"client_id": "42min_…"
},
"meta": { "request_id": "req_…" }
}
```
- `auth_type`: `"pat"` or `"oauth"`.
- `account_id`: the 42min account (organization) the token is bound to.
- `scopes`: the expanded scope set carried by the token (aliases unrolled).
- `client_id`: present only for OAuth tokens.
## curl
```bash
curl -H "Authorization: Bearer $TOKEN" https://api.42min.us/v1/_ping
```
## Common errors
- `401 invalid_token`: token missing, malformed, or unknown.
- `401 token_expired`: token has expired (PAT with `expiresAt`, or access
token past its hour).
- `401 token_revoked`: token has been revoked.
- `429 rate_limited`: bucket exhausted; see [Rate limits](/help/api/rate-limits).
---
# GET /v1/me
URL: https://42min.us/help/api/me
# GET `/v1/me`
Return the profile of the user the token authenticates, plus a summary of
their 42min account (organization).
| | |
|---|---|
| **Method** | `GET` |
| **URL** | `https://api.42min.us/v1/me` |
| **Scope** | `user:read` |
| **Auth** | Required |
## Headers
```
Authorization: Bearer
```
## Query parameters
None.
## Response
```json
{
"data": {
"user": {
"id": "01HXXX…",
"username": "ada",
"email": "ada@example.com",
"name": "Ada Lovelace",
"first_name": "Ada",
"last_name": "Lovelace",
"timezone": "Europe/London",
"locale": "en",
"avatar_url": "https://…"
},
"organization": {
"id": "01HYYY…",
"name": "Acme",
"plan": "Pro"
}
},
"meta": { "request_id": "req_…" }
}
```
Field notes:
- `username` is the user's slug (`https://42min.us/`).
- `name` is `first_name + " " + last_name`, trimmed. `null` if both are empty.
- `organization.plan` is the name of the active plan, or `null` if no plan
record is attached.
## curl
```bash
curl -H "Authorization: Bearer $TOKEN" https://api.42min.us/v1/me
```
## Common errors
- `403 insufficient_scope`: token doesn't carry `user:read`.
- `404 not_found`: the user behind the token has been deleted (rare —
revocation usually invalidates the token first).
---
# /v1/event-types
URL: https://42min.us/help/api/event-types
# `/v1/event-types`
Read access to the account's event types. There are no public write endpoints
yet. Create, update, and delete are scoped (`event_types:create`,
`event_types:update`, `event_types:delete`) but not yet implemented; manage
event types from the [Event Types](/360/events) page in the meantime.
Even though there's no write API, you can still **track changes in real
time**: editing, toggling on/off, or deleting an event type in the dashboard
emits the [`event_type.updated` / `event_type.deleted`
webhooks](/help/api/webhooks#event-names). The `event_type.updated` payload is
byte-identical to [`GET /v1/event-types/:idOrSlug`](#get-v1event-typesidorslug)
below (locations, `questions[]`, limits and all), so an integration can keep
its own mirror of your catalog in sync without polling.
---
## GET `/v1/event-types`
List event types in the authenticated account.
| | |
|---|---|
| **Method** | `GET` |
| **URL** | `https://api.42min.us/v1/event-types` |
| **Scope** | `event_types:read` |
| **Auth** | Required |
### Query parameters
| Param | Type | Notes |
|---|---|---|
| `limit` | integer | Page size. Default `20`, max `100`. |
| `cursor` | string | Opaque cursor from a prior response's `meta.next_cursor`. |
| `sort` | string | `created_at_desc` (default), `created_at_asc`, or `title_asc`. |
| `user_id` | UUID | Filter to event types owned by one user. |
| `slug` | string | Filter by exact slug. |
| `active` | boolean | `true` → status=on; `false` → status=off. Omit to include both. |
### Response
```json
{
"data": [
{
"id": "01HXXX…",
"slug": "intro-call",
"title": "Intro call",
"description": "30-minute introduction.",
"duration_minutes": 30,
"active": true,
"scheduling_type": "individual",
"hosts": [
{ "user_id": "01H…", "username": "ada", "name": "Ada Lovelace" }
],
"url": "https://42min.us/ada/intro-call",
"color": "#7c3aed",
"buffer_before_minutes": 0,
"buffer_after_minutes": 0,
"minimum_notice_hours": 4,
"future_limit_days": 60,
"timezone": "Europe/London",
"created_at": "2026-04-01T10:12:34.000Z",
"updated_at": "2026-05-02T08:00:00.000Z"
}
],
"meta": {
"request_id": "req_…",
"next_cursor": "eyJpZCI6Ii4uLiJ9",
"has_more": true
}
}
```
Field notes:
- `scheduling_type`: `individual` (1:1), `group` (one host, many invitees),
or `collective` (round-robin distribution among a team).
- `hosts`: one entry for a 1:1 type; for round-robin types, the team members
that can be assigned.
- `url`: the live booking page on the marketing host. `null` if no host
username is set.
- `minimum_notice_hours` is rounded from minutes; the underlying field is
per-minute.
### curl
```bash
curl -H "Authorization: Bearer $TOKEN" \
"https://api.42min.us/v1/event-types?active=true&limit=50"
```
---
## GET `/v1/event-types/:idOrSlug`
Return one event type with the full detail shape: locations, questions, and
limits.
| | |
|---|---|
| **Method** | `GET` |
| **URL** | `https://api.42min.us/v1/event-types/{id-or-slug}` |
| **Scope** | `event_types:read` |
| **Auth** | Required |
### Path parameter
- `{id-or-slug}`: either a UUID, or `username/event-slug` (URL-decoded by
the router; pass `username%2Fevent-slug` in URLs).
### Response
```json
{
"data": {
"id": "01HXXX…",
"slug": "intro-call",
"title": "Intro call",
"description": "30-minute introduction.",
"duration_minutes": 30,
"active": true,
"scheduling_type": "individual",
"hosts": [{ "user_id": "01H…", "username": "ada", "name": "Ada Lovelace" }],
"url": "https://42min.us/ada/intro-call",
"color": "#7c3aed",
"buffer_before_minutes": 5,
"buffer_after_minutes": 5,
"minimum_notice_hours": 4,
"future_limit_days": 60,
"timezone": "Europe/London",
"locations": [
{ "type": "google_meet", "label": "Google Meet", "value": null }
],
"questions": [
{ "id": "01Q…", "key": "01Q…", "label": "Phone number", "type": "short_text", "required": false }
],
"max_bookings_per_day": 8,
"max_bookings_per_week": null,
"max_bookings_per_month": null,
"requires_confirmation": false,
"allow_reschedule": true,
"allow_cancel": true,
"created_at": "2026-04-01T10:12:34.000Z",
"updated_at": "2026-05-02T08:00:00.000Z"
},
"meta": { "request_id": "req_…" }
}
```
Field notes:
- `locations[].type` is one of `google_meet`, `microsoft_teams`, `custom_url`,
`phone`, `physical`, `ask_invitee`.
- `questions[].key` and `id` are the same value today, and `id` is reused as the
stable key for booking-form responses.
- `requires_confirmation` is always `false`; the feature isn't modeled yet.
### curl
```bash
# By UUID
curl -H "Authorization: Bearer $TOKEN" \
https://api.42min.us/v1/event-types/01HXXX…
# By username/slug
curl -H "Authorization: Bearer $TOKEN" \
https://api.42min.us/v1/event-types/ada%2Fintro-call
```
### Common errors
- `400 invalid_query_param`: path segment is neither a UUID nor
`username/event-slug`.
- `404 event_type_not_found`: no such event type in this account.
---
# /v1/slots
URL: https://42min.us/help/api/slots
# `/v1/slots`
Read-only availability surface. Use **list** for "show me everything in a
window" and **check** for "is this exact time still bookable?".
---
## GET `/v1/slots`
List available slots for an event type over a window.
| | |
|---|---|
| **Method** | `GET` |
| **URL** | `https://api.42min.us/v1/slots` |
| **Scope** | `slots:read` |
| **Auth** | Required |
### Query parameters
| Param | Type | Notes |
|---|---|---|
| `event_type_id` | UUID | Either this **or** `username` + `event_slug` is required. |
| `username` | string | Owner's username. |
| `event_slug` | string | Event-type slug. |
| `start` | ISO 8601 | Window start (inclusive). **Required.** |
| `end` | ISO 8601 | Window end (exclusive). **Required.** Max **31 days** after `start`. |
| `timezone` | IANA tz | Timezone the engine should compute in. Defaults to the event type's schedule timezone, then `UTC`. |
### Response
```json
{
"data": {
"event_type_id": "01HXXX…",
"timezone": "Europe/London",
"computed_at": "2026-05-14T09:00:00.000Z",
"slots": [
{ "start": "2026-05-20T10:00:00.000Z", "end": "2026-05-20T10:30:00.000Z", "available": true },
{ "start": "2026-05-20T10:30:00.000Z", "end": "2026-05-20T11:00:00.000Z", "available": true }
]
},
"meta": { "request_id": "req_…" }
}
```
- All slots returned have `available: true`; busy times are simply omitted.
- `slots[].start` / `end` are ISO 8601 in UTC.
- `computed_at` is the server time the result was generated, useful for
detecting staleness between list and create.
### curl
```bash
curl -H "Authorization: Bearer $TOKEN" \
"https://api.42min.us/v1/slots?event_type_id=01HXXX…&start=2026-05-20T00:00:00Z&end=2026-05-21T00:00:00Z&timezone=Europe/London"
```
### Common errors
- `400 invalid_query_param`: `start`/`end` missing, malformed, or window
longer than 31 days.
- `404 event_type_not_found`: unknown id/slug or wrong account.
---
## GET `/v1/slots/check`
Check whether a specific slot is bookable **right now**.
| | |
|---|---|
| **Method** | `GET` |
| **URL** | `https://api.42min.us/v1/slots/check` |
| **Scope** | `slots:read` |
| **Auth** | Required |
### Query parameters
| Param | Type | Notes |
|---|---|---|
| `event_type_id` | UUID | Or `username` + `event_slug`. Required. |
| `username` | string | |
| `event_slug` | string | |
| `start` | ISO 8601 | Required. |
| `end` | ISO 8601 | Optional; defaults to `start + event_type.duration_minutes`. Must be strictly after `start`. |
| `timezone` | IANA tz | Used only when searching for `next_available`. |
### Response
Slot is available:
```json
{
"data": { "available": true, "duration_minutes": 30 },
"meta": { "request_id": "req_…" }
}
```
Slot is not available, and `reason` tells you which check failed:
```json
{
"data": {
"available": false,
"reason": "slot_busy",
"next_available": "2026-05-20T11:00:00.000Z"
},
"meta": { "request_id": "req_…" }
}
```
Possible `reason` values:
| Reason | Meaning |
|---|---|
| `event_type_inactive` | The event type's status is not `on`. |
| `in_past` | `start` is in the past. |
| `outside_minimum_notice` | `start` is sooner than the event type's `minimum_notice` allows. |
| `outside_future_limit` | `start` is past the event type's `future_limit_days` horizon. |
| `slot_busy` | A conflicting booking, calendar event, or buffer covers the slot. |
`next_available` is a **best-effort** lookup of the next free slot within
**7 days** after the requested `end`. It may be `null`.
### curl
```bash
curl -H "Authorization: Bearer $TOKEN" \
"https://api.42min.us/v1/slots/check?event_type_id=01HXXX…&start=2026-05-20T10:00:00Z"
```
### Common errors
- `400 invalid_query_param`: `start` missing or invalid, or `end` not after
`start`.
- `404 event_type_not_found`.
### When to use which
- **Listing a calendar UI** → `GET /v1/slots`. One round-trip per visible
range; cache for a few seconds.
- **Just before creating a booking** → `GET /v1/slots/check`. Combine with
the `Idempotency-Key`'d `POST /v1/bookings` to handle the race when the slot
is taken between check and create.
---
# /v1/bookings
URL: https://42min.us/help/api/bookings
# `/v1/bookings`
Read, create, cancel, reschedule, and patch bookings. This is the write-heavy
surface, and every write needs an
[`Idempotency-Key`](/help/api/idempotency), and `PATCH` needs an
[`If-Match`](/help/api/optimistic-locking) ETag.
A booking's UID is its UUID, and every endpoint that takes `:uid` rejects
non-UUIDs with `404 booking_not_found`.
---
## GET `/v1/bookings`
List the bookings the token's user
[can see](/help/api/authentication#what-a-token-can-reach): their own for a
regular member, their groups' for a manager, the whole account for an admin or
owner. A `host_user_id` outside that scope returns an empty page, and a `:uid`
outside it is `404 booking_not_found`.
| | |
|---|---|
| **Method** | `GET` |
| **URL** | `https://api.42min.us/v1/bookings` |
| **Scope** | `bookings:read` |
| **Auth** | Required |
### Query parameters
| Param | Type | Notes |
|---|---|---|
| `limit` | integer | Default `20`, max `100`. |
| `cursor` | string | Opaque cursor from a prior `meta.next_cursor`. |
| `sort` | string | `start_at_desc` (default), `start_at_asc`, `created_at_desc`, `updated_at_asc`, `updated_at_desc`. |
| `event_type_id` | UUID | Filter to one event type. |
| `host_user_id` | UUID | Filter to one host. |
| `attendee_email` | string | Exact-match filter (case-sensitive). |
| `status` | string | Comma-separated. Typical values: `confirmed`, `canceled`. |
| `start_date` | ISO 8601 | Lower bound on `start_at` (inclusive). |
| `end_date` | ISO 8601 | Upper bound on `start_at` (inclusive). |
| `updated_since` | ISO 8601 | Only bookings whose `updated_at` is at or after this time. Pair with `sort=updated_at_asc` for catch-up reconciliation; see below. |
| `include_cancelled` | boolean | When `status` isn't given, set `false` to exclude `canceled`. Defaults to including them. |
### Response
```json
{
"data": [
{
"uid": "01H…",
"version": 1,
"event_type_id": "01HXXX…",
"event_type_slug": "intro-call",
"title": "Intro call",
"status": "confirmed",
"start_at": "2026-05-20T10:00:00.000Z",
"end_at": "2026-05-20T10:30:00.000Z",
"timezone": "Europe/London",
"host": { "user_id": "01H…", "username": "ada", "email": "ada@example.com" },
"attendees": [{ "email": "bob@example.com", "name": "Bob Builder", "timezone": "Europe/Berlin" }],
"guests": [{ "email": "carol@example.com" }],
"location": { "type": "google_meet", "url": "https://meet.google.com/…", "value": null },
"metadata": { "crm_id": "C-7" },
"responses": null,
"calendar_sync_status": "synced",
"calendar_event_id": "abc123…",
"rescheduled_from_uid": null,
"cancelled_at": null,
"cancellation_reason": null,
"no_show_at": null,
"no_show_reason": null,
"created_at": "2026-05-14T09:00:00.000Z",
"updated_at": "2026-05-14T09:00:00.000Z"
}
],
"meta": { "request_id": "req_…", "next_cursor": null, "has_more": false }
}
```
Field notes:
- `responses` (the booking-form answers) is `null` on list responses, so fetch
the detail to read them.
- `calendar_sync_status` is `synced` / `pending` / `failed` /
`not_applicable` (no calendar accounts attached).
- `rescheduled_from_uid` references the prior booking when this one is the
result of a reschedule.
#### Catch-up reconciliation
If a [webhook](/help/api/webhooks) was paused (auto-paused after repeated
delivery failures, or paused by you) you'll have missed `booking.*` events.
To resync without guessing, sweep by modification time:
```
GET /v1/bookings?updated_since=&sort=updated_at_asc&limit=100
```
then follow `meta.next_cursor` to the end. `updated_at_asc` keeps the order
stable while you page. One caveat: an internal calendar-sync retry also bumps
`updated_at`, so a booking can resurface in this sweep with no user-facing
change, so make your reconciliation idempotent and that's harmless.
### curl
```bash
curl -H "Authorization: Bearer $TOKEN" \
"https://api.42min.us/v1/bookings?status=confirmed&start_date=2026-05-01T00:00:00Z&limit=100"
# Catch-up after a webhook pause
curl -H "Authorization: Bearer $TOKEN" \
"https://api.42min.us/v1/bookings?updated_since=2026-05-18T00:00:00Z&sort=updated_at_asc&limit=100"
```
---
## GET `/v1/bookings/:uid`
Return one booking with `responses` populated. Sets `ETag` for the version.
| | |
|---|---|
| **Method** | `GET` |
| **URL** | `https://api.42min.us/v1/bookings/{uid}` |
| **Scope** | `bookings:read` |
| **Auth** | Required |
### Headers (response)
```
ETag: "3"
```
### Response
Same shape as list items, with `responses` filled in:
```json
{
"data": {
"uid": "01H…",
"version": 3,
"responses": { "phone": "+44 123 4567 8901", "company": "Acme" },
"…": "…"
},
"meta": { "request_id": "req_…" }
}
```
### curl
```bash
curl -i -H "Authorization: Bearer $TOKEN" \
https://api.42min.us/v1/bookings/01H…
```
### Common errors
- `404 booking_not_found`: unknown UID, or UID is not a UUID (collapsed
into the same 404 to avoid leaking format details).
---
## POST `/v1/bookings`
Create a booking.
| | |
|---|---|
| **Method** | `POST` |
| **URL** | `https://api.42min.us/v1/bookings` |
| **Scope** | `bookings:create` |
| **Auth** | Required |
| **Idempotency-Key** | Required |
### Headers
```
Authorization: Bearer
Content-Type: application/json
Idempotency-Key:
```
### Body
Either `event_type_id` **or** (`username` + `event_slug`) is required.
```json
{
"event_type_id": "01HXXX…",
"username": "ada",
"event_slug": "intro-call",
"start": "2026-05-20T10:00:00Z",
"timezone": "Europe/Berlin",
"attendee": {
"email": "bob@example.com",
"name": "Bob Builder",
"first_name": "Bob",
"last_name": "Builder",
"phone": "+49 30 12345678",
"timezone": "Europe/Berlin",
"sms_opt_in": false
},
"guests": [{ "email": "carol@example.com" }],
"responses": { "phone": "+49 30 12345678" },
"metadata": { "crm_id": "C-7" },
"utm_source": "web",
"utm_medium": "cta",
"utm_campaign": "spring-launch"
}
```
Field reference:
| Field | Type | Notes |
|---|---|---|
| `event_type_id` | UUID | One of {`event_type_id`} or {`username`+`event_slug`} required. |
| `username` / `event_slug` | string | Alternative to `event_type_id`. |
| `start` | ISO 8601 | Required. Booking length is `event_type.duration_minutes`. |
| `timezone` | IANA tz | Optional. Falls back to `attendee.timezone`, then `UTC`. |
| `attendee.email` | string | Required. Max 254 chars, RFC-shaped. |
| `attendee.name` | string | If omitted, built from `first_name` + `last_name`, else falls back to email. |
| `attendee.first_name` / `last_name` | string | Max 127 each. |
| `attendee.phone` | string | Max 64. |
| `attendee.sms_opt_in` | boolean | Defaults to `false`. The attendee agrees to get text messages about this booking; workflow SMS steps text only attendees who agreed. Needs `attendee.phone` as a valid number in international format, starting with `+`, which is then stored in E.164 form. |
| `guests[]` | array | Strings (emails) or `{email, name?}` objects. |
| `responses` | object | Booking-form answers, keyed by question id. |
| `metadata` | object | Free-form JSON. Stored alongside the booking; UTM params are merged in. |
| `utm_source` / `utm_medium` / `utm_campaign` | string | Max 255 each. Merged into `metadata`. |
### Response
`201 Created` with the full booking detail (same shape as `GET /v1/bookings/:uid`).
Initial `version` is `1`.
### curl
```bash
curl -X POST https://api.42min.us/v1/bookings \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"event_type_id": "01HXXX…",
"start": "2026-05-20T10:00:00Z",
"timezone": "Europe/Berlin",
"attendee": { "email": "bob@example.com", "name": "Bob Builder" }
}'
```
### Common errors
- `400 validation_error`: required field missing or malformed.
- `400 attendee_email_invalid`: bad email format / length > 254.
- `400 attendee_phone_required`: `attendee.sms_opt_in` is `true` but `attendee.phone` is missing.
- `400 attendee_phone_invalid`: `attendee.sms_opt_in` is `true` but `attendee.phone` isn't a valid number in international format.
- `400 missing_idempotency_key`: header missing.
- `404 event_type_not_found`: bad id/slug.
- `409 event_type_inactive`: event type has `status != on`.
- `409 slot_in_past`: `start` is in the past.
- `409 slot_unavailable`: slot is taken or otherwise blocked.
- `409 idempotency_key_conflict`: same `Idempotency-Key` was used with a
different body in the last 24h.
- `503 slot_lock_timeout`: couldn't acquire the per-slot lock. `Retry-After: 1`.
---
## POST `/v1/bookings/:uid/cancel`
Cancel a confirmed booking. Idempotent: calling it on an already-cancelled
booking returns the current state, not an error.
| | |
|---|---|
| **Method** | `POST` |
| **URL** | `https://api.42min.us/v1/bookings/{uid}/cancel` |
| **Scope** | `bookings:cancel` |
| **Auth** | Required |
| **Idempotency-Key** | Required |
### Body
```json
{ "reason": "Schedule conflict" }
```
| Field | Type | Notes |
|---|---|---|
| `reason` | string | Optional. Max 1024 chars. Surfaced in calendar removals and notifications. |
### Response
`200 OK` with the updated booking. Version is bumped.
### curl
```bash
curl -X POST https://api.42min.us/v1/bookings/01H…/cancel \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"reason":"Schedule conflict"}'
```
### Common errors
- `404 booking_not_found`: unknown UID.
- `409 booking_in_past`: booking already started or finished.
---
## POST `/v1/bookings/:uid/reschedule`
Move a confirmed booking to a new start time. The duration is preserved.
| | |
|---|---|
| **Method** | `POST` |
| **URL** | `https://api.42min.us/v1/bookings/{uid}/reschedule` |
| **Scope** | `bookings:reschedule` |
| **Auth** | Required |
| **Idempotency-Key** | Required |
### Body
```json
{
"start": "2026-05-22T15:00:00Z",
"timezone": "Europe/Berlin",
"reason": "Invitee requested a later time"
}
```
| Field | Type | Notes |
|---|---|---|
| `start` | ISO 8601 | Required. Booking ends at `start + duration_minutes`. |
| `timezone` | IANA tz | Optional. Falls back to the booking's current timezone. |
| `reason` | string | Optional. Max 1024. |
### Response
`200 OK` with the updated booking. Version is bumped.
### curl
```bash
curl -X POST https://api.42min.us/v1/bookings/01H…/reschedule \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"start":"2026-05-22T15:00:00Z","timezone":"Europe/Berlin"}'
```
### Common errors
- `400 validation_error`: `start` missing or unparseable.
- `404 booking_not_found`.
- `409 booking_already_cancelled`: booking isn't `confirmed`.
- `409 booking_in_past`: original start is in the past.
- `409 slot_unavailable`: new slot is taken.
- `422 event_type_disallows_reschedule`: the event type opts out of
rescheduling.
- `503 slot_lock_timeout`: could not acquire the new-slot lock.
---
## PATCH `/v1/bookings/:uid`
Patch a booking's mutable fields. Requires
[optimistic locking](/help/api/optimistic-locking).
| | |
|---|---|
| **Method** | `PATCH` |
| **URL** | `https://api.42min.us/v1/bookings/{uid}` |
| **Scope** | `bookings:update` |
| **Auth** | Required |
| **Idempotency-Key** | Required |
| **If-Match** | Required (`ETag` from the most recent GET) |
### Body
```json
{
"metadata": { "stage": "qualified", "owner": "ada" },
"responses": { "phone": "+49 …" },
"attendee_name": "Bob D. Builder"
}
```
| Field | Type | Notes |
|---|---|---|
| `metadata` | object | Shallow-merged into the existing metadata. Pass `null` for a key to clear it on your side and re-send. |
| `responses` | object | Replaces the booking-form answers wholesale. |
| `attendee_name` | string | Max 255 chars; stored as the primary attendee's name. |
Any other field returns **`422 field_immutable`** with `details.fields` naming
the rejected keys. To change `start` use **reschedule**; to end the booking
use **cancel**.
### Response
`200 OK` with the updated booking detail. Response headers include
`ETag: ""`.
### Fires a webhook
A successful PATCH emits the **`booking.updated`**
[webhook](/help/api/webhooks#event-names) (subscribe with `booking_updated`).
The payload is the full booking plus a **`changed_fields`** array naming
exactly which of `metadata` / `responses` / `attendee_name` this call
changed. This is the only event that covers these silent edits; no
`booking.rescheduled` or `booking.canceled` fires for a PATCH.
### curl
```bash
curl -X PATCH https://api.42min.us/v1/bookings/01H… \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "If-Match: \"3\"" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"metadata":{"stage":"qualified"},"attendee_name":"Bob D. Builder"}'
```
### Common errors
- `400 validation_error`: `metadata`/`responses` must be objects (not
arrays/scalars); `attendee_name` must be ≤ 255 chars.
- `404 booking_not_found`.
- `409 version_conflict`: `If-Match` doesn't match the current `version`.
Re-GET and retry.
- `422 field_immutable`: body included a field that can't be patched.
- `428 missing_if_match`: header missing.
---
# /v1/series
URL: https://42min.us/help/api/series
# `/v1/series`
A recurring series books one repeating pattern, weekly or monthly, as a run of
ordinary meetings. This page covers reading, creating, and managing the
series itself: pausing it, resuming it, ending it, editing its pattern, and
moving it to a new host.
The occurrences a series creates are ordinary bookings, not a separate
resource. List them with [`GET /v1/bookings`](/help/api/bookings) and a
`series_id` filter, and read or act on any one of them with the regular
booking endpoints, `uid` for `uid`.
A series' UID is its UUID, and every endpoint that takes `:uid` rejects a
malformed or unknown one with the same `404 series_not_found`, so the
response never confirms which case it was.
---
## GET `/v1/series`
List recurring series, narrowed to what you may see.
| | |
|---|---|
| **Method** | `GET` |
| **URL** | `https://api.42min.us/v1/series` |
| **Scope** | `series:read` |
| **Auth** | Required |
### Query parameters
| Param | Type | Notes |
|---|---|---|
| `limit` | integer | Default `20`, max `100`. |
| `cursor` | string | Opaque cursor from a prior `meta.next_cursor`. |
| `status` | string | Comma-separated. Allowed values: `active`, `paused`, `ended`. |
| `host_user_id` | UUID | Filter to one host. |
| `event_type_id` | UUID | Filter to one event type. |
What you see depends on your role: your own series as a user, the series of
the people you manage as a manager, and the whole organization as an admin
or owner. A `host_user_id` outside that set comes back as an empty page,
not an error, so it never confirms whether the host exists.
### Response
```json
{
"data": [
{
"uid": "01H…",
"version": 1,
"event_type": { "id": "01HXXX…", "slug": "weekly-lesson", "name": "Weekly lesson" },
"host": { "user_id": "01H…", "username": "ada", "email": "ada@example.com" },
"attendee": { "name": "Bob Builder", "email": "bob@example.com", "phone": null },
"frequency": "weekly",
"interval_weeks": 1,
"weekdays": ["mon", "wed"],
"time": "18:30",
"timezone": "Europe/Lisbon",
"starts_on": "2026-09-14",
"count": 24,
"status": "active",
"location_url": null,
"notify_invitee": true,
"source": "api",
"created_at": "2026-09-10T09:00:00.000Z",
"updated_at": "2026-09-10T09:00:00.000Z",
"ended_at": null
}
],
"meta": { "request_id": "req_…", "next_cursor": null, "has_more": false }
}
```
Field notes:
- `version` is the series' revision, bumped by every action on it. It's what
you send back as `If-Match` on `PATCH`.
- `source` says where the series came from: `public` (an invitee, from the
booking page), `internal` (a host or admin, from the dashboard), `api`, or
`mcp`.
- Occurrences aren't included here; see `GET /v1/bookings?series_id={uid}`.
### curl
```bash
curl -H "Authorization: Bearer $TOKEN" \
"https://api.42min.us/v1/series?status=active&limit=50"
```
---
## GET `/v1/series/:uid`
Return one series. Sets `ETag` for the revision.
| | |
|---|---|
| **Method** | `GET` |
| **URL** | `https://api.42min.us/v1/series/{uid}` |
| **Scope** | `series:read` |
| **Auth** | Required |
### Headers (response)
```
ETag: "3"
```
### Response
Same shape as a list item.
### curl
```bash
curl -i -H "Authorization: Bearer $TOKEN" \
https://api.42min.us/v1/series/01H…
```
### Common errors
- `404 series_not_found`: unknown UID, or the UID is not a UUID (collapsed
into the same 404 to avoid leaking format details).
---
## POST `/v1/series`
Create a recurring series: books up to `count` occurrences of one repeating
pattern in a single call.
| | |
|---|---|
| **Method** | `POST` |
| **URL** | `https://api.42min.us/v1/series` |
| **Scope** | `series:write` |
| **Auth** | Required |
**Requires an [`Idempotency-Key`](/help/api/idempotency).** This sends real
invitee email and writes to a real calendar, so retry a failed attempt with
the same key rather than reissuing the call.
### Headers
```
Authorization: Bearer
Content-Type: application/json
Idempotency-Key:
```
### Body
Either `event_type_id` **or** (`username` + `event_slug`) is required.
```json
{
"host_user_id": "8b7c1e6f-1111-4222-8333-444455556666",
"event_type_id": "1c2d3e4f-5555-4666-8777-888899990000",
"attendee": {
"email": "ada@example.com",
"name": "Ada Lovelace"
},
"frequency": "weekly",
"interval_weeks": 1,
"weekdays": ["mon", "wed"],
"time": "18:30",
"timezone": "Europe/Lisbon",
"starts_on": "2026-09-14",
"count": 24
}
```
Field reference:
| Field | Type | Notes |
|---|---|---|
| `host_user_id` | UUID | Required. Whose calendar the series is booked on; must be someone you may act for. |
| `event_type_id` | UUID | One of {`event_type_id`} or {`username`+`event_slug`} required. |
| `username` / `event_slug` | string | Alternative to `event_type_id`. |
| `attendee.email` | string | Required. |
| `attendee.name` | string | If omitted, built from `first_name` + `last_name`, else falls back to email. |
| `attendee.first_name` / `last_name` | string | Optional. |
| `attendee.phone` | string | Optional. |
| `frequency` | string | Required. `weekly` or `monthly_same_day`. |
| `interval_weeks` | integer | 1 to 8, default `1`. Weekly patterns only. |
| `weekdays` | array | Required for a weekly pattern: up to 7 of `sun`…`sat`. Ignored for `monthly_same_day`. |
| `time` | string | Required. `HH:MM`, 24-hour, the wall clock in `timezone`. |
| `timezone` | IANA tz | Optional. Defaults to the host's own zone. |
| `starts_on` | date | Required. `YYYY-MM-DD`, the first civil day the pattern is walked from. Past instants are never booked. |
| `count` | integer | Required, 1 to 42. Dates the host isn't free on are skipped, not shifted, so a series can come back with fewer occurrences than requested. |
| `location_url` | string | Optional. A fixed http(s) link every occurrence carries, instead of a generated conference link. |
| `notify_invitee` | boolean | Optional, default `true`. `false` means no mail reaches the attendee for this series. |
### Response
`201 Created`:
```json
{
"data": {
"series": { "uid": "01H…", "version": 1, "…": "…" },
"occurrences": [
{ "uid": "01H…", "start_at": "2026-09-14T17:30:00.000Z", "end_at": "2026-09-14T18:00:00.000Z", "status": "confirmed" }
],
"skipped": []
},
"meta": { "request_id": "req_…" }
}
```
Field notes:
- `occurrences` lists what was actually booked, in order. Each `uid` is an
ordinary booking UID, usable on every `/v1/bookings` route.
- `skipped` lists pattern dates that weren't free (`reason: "conflict"`); the
series is still created with the rest. Compare its length against `count`
to know whether every occurrence was booked.
### curl
```bash
curl -X POST https://api.42min.us/v1/series \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"host_user_id": "8b7c1e6f-1111-4222-8333-444455556666",
"event_type_id": "1c2d3e4f-5555-4666-8777-888899990000",
"attendee": {"email": "ada@example.com", "name": "Ada Lovelace"},
"frequency": "weekly",
"weekdays": ["mon", "wed"],
"time": "18:30",
"starts_on": "2026-09-14",
"count": 24
}'
```
### Common errors
- `400 validation_error`: required field missing or malformed.
- `400 attendee_email_invalid`: bad email format.
- `400 missing_idempotency_key`: header missing.
- `403 host_not_allowed`: `host_user_id` is outside who you may act for.
- `404 event_type_not_found`: bad id/slug.
- `409 recurring_disabled`: the event type's recurring mode is `off`.
- `409 no_dates_free`: no date in the pattern was free, so nothing was
created.
- `422 count_out_of_range`: `count` is outside the allowed range.
- `503 availability_unavailable`: the host's conflict calendar couldn't be
read, so nothing was booked on a guess. Retry with the SAME
`Idempotency-Key`, honoring `Retry-After`.
---
## POST `/v1/series/:uid/pause`
Cancel every future occurrence and record each as owed, so a later `resume`
books a replacement for it. Occurrences the invitee already moved on their
own are left alone. Nothing is emailed to the attendee.
| | |
|---|---|
| **Method** | `POST` |
| **URL** | `https://api.42min.us/v1/series/{uid}/pause` |
| **Scope** | `series:write` |
| **Auth** | Required |
No body. Safe to repeat: a re-run holds whatever the first run didn't reach.
### Response
`200 OK`:
```json
{
"data": { "held": 6, "superseded": false },
"meta": { "request_id": "req_…" }
}
```
`held` is how many occurrences this call canceled and recorded as owed. See
[Superseded and busy responses](#superseded-and-busy-responses) below for
what `superseded: true` means.
### curl
```bash
curl -X POST https://api.42min.us/v1/series/01H…/pause \
-H "Authorization: Bearer $TOKEN"
```
### Common errors
- `404 series_not_found`
- `409 series_busy`: another action holds the series right now (for example,
its confirmation email is still sending). Read it again and retry.
- `409 series_ended`: terminal, nothing can change it.
---
## POST `/v1/series/:uid/resume`
Book one replacement for each occurrence a pause is holding, and reactivate
the series.
| | |
|---|---|
| **Method** | `POST` |
| **URL** | `https://api.42min.us/v1/series/{uid}/resume` |
| **Scope** | `series:write` |
| **Auth** | Required |
**Requires an [`Idempotency-Key`](/help/api/idempotency).** `If-Match` is
optional here: send the `ETag` from a prior `GET` to refuse a resume that
another change overtook, or omit it to resume whatever the series is now. A
value that doesn't parse as a valid revision answers `400 invalid_if_match`.
### Response
`200 OK`:
```json
{
"data": { "created": 4, "skipped": [], "owed": 0, "superseded": false },
"meta": { "request_id": "req_…" }
}
```
Field notes:
- `created` is how many replacement occurrences this call booked.
- `skipped` lists dates that weren't free (`reason: "conflict"`); those stay
owed.
- `owed` is how many occurrences are still waiting for a replacement. Above
zero means the series is still `paused`, and calling `resume` again with
the current revision converges on the rest.
### curl
```bash
curl -X POST https://api.42min.us/v1/series/01H…/resume \
-H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $(uuidgen)"
```
### Common errors
- `400 invalid_if_match`: `If-Match` was sent but isn't a valid revision.
- `400 missing_idempotency_key`
- `404 series_not_found`
- `409 series_busy` / `409 series_ended`
- `503 availability_unavailable`: retry with the SAME `Idempotency-Key`,
honoring `Retry-After`.
---
## POST `/v1/series/:uid/end`
Cancel every future occurrence for good and send the attendee one notice
listing them. Nothing is owed afterward, and the series can never be
resumed. Past occurrences are untouched.
| | |
|---|---|
| **Method** | `POST` |
| **URL** | `https://api.42min.us/v1/series/{uid}/end` |
| **Scope** | `series:write` |
| **Auth** | Required |
**Requires an [`Idempotency-Key`](/help/api/idempotency).** Safe to repeat: a
re-run cancels whatever the first run didn't reach.
### Response
`200 OK`:
```json
{
"data": { "canceled": 5, "superseded": false },
"meta": { "request_id": "req_…" }
}
```
### curl
```bash
curl -X POST https://api.42min.us/v1/series/01H…/end \
-H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $(uuidgen)"
```
### Common errors
- `400 missing_idempotency_key`
- `404 series_not_found`
- `409 series_busy` / `409 series_ended`
---
## POST `/v1/series/:uid/host`
Move every future occurrence, then the series itself, to another host.
| | |
|---|---|
| **Method** | `POST` |
| **URL** | `https://api.42min.us/v1/series/{uid}/host` |
| **Scope** | `series:write` |
| **Auth** | Required |
### Body
```json
{ "host_user_id": "9c8d2e7a-2222-4333-9444-555566667777" }
```
| Field | Type | Notes |
|---|---|---|
| `host_user_id` | UUID | Required. The new host; must be someone you may act for. |
### Response
`200 OK`:
```json
{
"data": { "moved": 5, "unchanged": 0, "stopped_by": null, "superseded": false },
"meta": { "request_id": "req_…" }
}
```
A transfer that can't complete (no writable calendar on the new host, or
another action taking the series over) stops the run: `moved` says how many
occurrences did move, and `stopped_by` names the reason. `stopped_by: null`
means every occurrence moved and the series' own host changed too; anything
else means it didn't, and the call can be repeated once the cause is fixed.
### curl
```bash
curl -X POST https://api.42min.us/v1/series/01H…/host \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"host_user_id":"9c8d2e7a-2222-4333-9444-555566667777"}'
```
### Common errors
- `400 validation_error`
- `403 host_not_allowed`: the new host is outside who you may act for.
- `404 series_not_found`
- `409 series_busy` / `409 series_ended`
---
## PATCH `/v1/series/:uid`
Edit a series' pattern, its fixed link, or whether the attendee is notified.
Requires [optimistic locking](/help/api/optimistic-locking).
| | |
|---|---|
| **Method** | `PATCH` |
| **URL** | `https://api.42min.us/v1/series/{uid}` |
| **Scope** | `series:write` |
| **Auth** | Required |
**`If-Match` is required**: the `ETag` from a prior `GET` of this series.
Omitting it returns `428 missing_if_match` and nothing is changed. Unlike
`PATCH /v1/bookings/:uid`, this route does not also require an
`Idempotency-Key`.
### Headers
```
Authorization: Bearer
Content-Type: application/json
If-Match: "3"
```
### Body
Send only what changes.
```json
{ "weekdays": ["tue", "thu"], "time": "09:00" }
```
| Field | Type | Notes |
|---|---|---|
| `frequency` | string | `weekly` or `monthly_same_day`. |
| `interval_weeks` | integer | 1 to 8. |
| `weekdays` | array | Up to 7 of `sun`…`sat`. |
| `time` | string | `HH:MM`, 24-hour. |
| `location_url` | string | Replaces the series' existing fixed link. A series created without one can't gain one here, and sending `null` to clear it is refused as a validation error rather than treated as "remove it". |
| `notify_invitee` | boolean | Changes whether future sends reach the attendee. |
`starts_on`, `count`, the attendee, and the event type are fixed for the
life of a series and can't be patched.
Changing `frequency`, `interval_weeks`, `weekdays`, or `time` rebuilds
the upcoming occurrences onto the new pattern's next dates, as many as
the series has upcoming: occurrences already on those dates stay, and
the rest are canceled and rebooked on the missing dates. The round is
reported under `resumed`, with zero counts when nothing had to move;
`resumed` does not tell you whether the series is active, so read
`series.status`. Changing only `location_url` or `notify_invitee`
restamps the existing occurrences in place and leaves every date
alone, so `resumed` is `null`.
### Response
`200 OK`, with headers including `ETag: ""`:
```json
{
"data": {
"series": { "uid": "01H…", "version": 2, "…": "…" },
"resumed": { "created": 3, "skipped": [], "owed": 0, "superseded": false }
},
"meta": { "request_id": "req_…" }
}
```
### curl
```bash
curl -X PATCH https://api.42min.us/v1/series/01H… \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "If-Match: \"3\"" \
-d '{"weekdays":["tue","thu"],"time":"09:00"}'
```
### Common errors
- `400 validation_error`: malformed field, such as a bad `time` format.
- `404 series_not_found`
- `409 series_busy` / `409 series_ended`
- `409 version_conflict`: `If-Match` doesn't match the current `version`.
Re-`GET` and retry.
- `422`: a well-formed but semantically rejected change, such as sending
`location_url: null` to clear an existing link.
- `428 missing_if_match`: header missing.
---
## Superseded and busy responses
Every action above can be overtaken by another one running on the same
series at the same time: two requests pausing a series at once, or an
`end` landing while a `resume` is still booking replacements. Two different
things can happen:
- **`409 series_busy`** means the call couldn't get started at all, because
something else currently holds the series (mid-send confirmation email,
or another action's very first write). Nothing changed. Read the series
again and retry.
- **`superseded: true`** in a `200` response means the call *did* start, and
its own work up to that point is real and already committed, but another
action took the series over partway through, so it stopped early. The
counts in the response (`held`, `created`, `canceled`, `moved`) are what
this call actually finished, not what it was asked to do. Read the series
again and repeat the same call; it converges on whatever is left.
`change_host` reports the same situation through `stopped_by` instead of
`superseded`, since it already has a field for "the run didn't finish."
This matters most for `resume` and a pattern-changing `PATCH`, both of which
can need several occurrence inserts to finish one call.
---
# /v1/webhooks
URL: https://42min.us/help/api/webhooks
# `/v1/webhooks`
Register and manage webhook subscriptions programmatically, the same
real-time callbacks you can set up by hand on the
[Webhooks tab](/360/api?tab=webhooks) of Admin Center → API, but driven from
your own code so an installed integration can wire up its own delivery
endpoint without an admin clicking through the dashboard.
For the delivery contract itself (the request envelope, the
`X-42min-Webhook-Signature` header, **how to verify it**, the retry schedule,
and auto-pause behavior), see
[API & webhooks](/help/team-and-admin/api-and-webhooks#webhooks). This page is
the **management** surface; that page is the **receiving** side.
---
## The per-credential sandbox
This is the one rule that makes `/v1/webhooks` behave differently from the
rest of the API:
> A credential, in the hands of one member, only ever sees and mutates the
> webhooks **that member created with it**.
Concretely:
- Webhooks created through this API by a **PAT** are owned by that token.
Webhooks created by an **OAuth** client are owned by that client *and* the
member who authorized it: the binding is the stable `client_id` plus the
user, so a token refresh doesn't orphan anything, while two members who
connected the same app in one account never see each other's subscriptions.
A *PAT*, by contrast, is bound to that specific token, so rotating the PAT
leaves its webhooks unreachable from the new token.
- Webhooks created in the **admin UI** are invisible to `/v1/webhooks`, and
webhooks created over the API don't appear in another credential's listing.
The **admin UI still sees every webhook in the account**, so the sandbox is
one-directional.
- Two different integrations that legitimately point at the same ingestion
URL are two real subscribers and both receive every matching event, because the
duplicate-URL check is scoped to your own sandbox, not the whole account.
Because of the sandbox, `GET /v1/webhooks` returning `[]` does **not** mean
the account has no webhooks, only that *this* credential, for *this* member,
created none.
## What a subscription receives
A subscription delivers what the member who created it
[can see](/help/api/authentication#what-a-token-can-reach): a regular
member's webhook receives the bookings they host, a manager's those of their
groups, an admin's or owner's every booking in the account. `event_type.updated`
is not narrowed. The admin UI's webhooks are created by admins, so they keep
receiving everything.
## Conventions specific to this resource
- **Scopes**: `webhooks:read` for the `GET`s, `webhooks:write` for every
mutation (create, update, delete, rotate-secret, test). See
[Scopes](/help/api/scopes).
- **No `Idempotency-Key`, no `If-Match`.** Unlike `/v1/bookings`, the webhook
endpoints don't take an idempotency key or an ETag, and they aren't
optimistically locked. Last write wins.
- **The signing secret is shown exactly once.** It is server-generated (you
can't supply your own) and appears as `signing_secret` **only** in the
`POST` (create) and `rotate-secret` responses. It is never returned by any
`GET`. Lost it? Rotate.
- **Account ceiling: 42 webhooks.** The cap is account-wide across every
credential *and* the admin UI combined, not 42 per credential.
### The webhook object
```json
{
"id": "01H…",
"url": "https://hooks.example.com/42min",
"events": ["booking_created", "booking_updated"],
"status": "active",
"description": "Prod CRM sync",
"paused_reason": null,
"last_delivery_at": "2026-05-18T14:03:00.000Z",
"last_delivery_ok": true,
"created_at": "2026-05-10T09:00:00.000Z",
"updated_at": "2026-05-18T14:03:00.000Z",
"signing_secret": "42min_wh_…"
}
```
- `status` is `active` or `paused`. `paused_reason` is set when 42min
auto-paused the hook (after 5 consecutive failed deliveries) and is `null`
otherwise.
- `last_delivery_ok` is `true` / `false` / `null` (never delivered yet).
- `signing_secret` is present **only** on create and rotate-secret responses.
---
## GET `/v1/webhooks`
List the webhooks this credential created.
| | |
|---|---|
| **Method** | `GET` |
| **URL** | `https://api.42min.us/v1/webhooks` |
| **Scope** | `webhooks:read` |
| **Auth** | Required |
Returns a plain array (newest first). This endpoint is **not** paginated.
```json
{
"data": [ { "id": "01H…", "url": "https://…", "events": ["booking_created"], "status": "active", "…": "…" } ],
"meta": { "request_id": "req_…" }
}
```
### curl
```bash
curl -H "Authorization: Bearer $TOKEN" \
https://api.42min.us/v1/webhooks
```
---
## GET `/v1/webhooks/:id`
Return one webhook this credential created.
| | |
|---|---|
| **Method** | `GET` |
| **URL** | `https://api.42min.us/v1/webhooks/{id}` |
| **Scope** | `webhooks:read` |
| **Auth** | Required |
### Common errors
- `404 webhook.notFound`: no such webhook **in this credential's sandbox**
(it may exist for another credential or in the admin UI; you just can't
see it).
---
## POST `/v1/webhooks`
Create a webhook subscription.
| | |
|---|---|
| **Method** | `POST` |
| **URL** | `https://api.42min.us/v1/webhooks` |
| **Scope** | `webhooks:write` |
| **Auth** | Required |
### Body
```json
{
"url": "https://hooks.example.com/42min",
"events": ["booking_created", "booking_updated", "event_type_updated"],
"description": "Prod CRM sync"
}
```
| Field | Type | Notes |
|---|---|---|
| `url` | string | Required. Must be `https://` and resolve to a public address; `http://`, `localhost`, and private/loopback IP ranges are rejected. Max 2000 chars. Normalized (fragment and trailing slash stripped) before storage. |
| `events` | string[] | Required, at least one. See [Event names](#event-names) and use the **underscore** form here. |
| `description` | string | Optional. Max 255 chars. |
### Response
`201 Created` with the webhook object **including `signing_secret`**. This is
the only `GET`-style payload that ever carries the secret on create. Store it
now; you'll need it to verify deliveries and it is never shown again.
### curl
```bash
curl -X POST https://api.42min.us/v1/webhooks \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.example.com/42min",
"events": ["booking_created", "booking_updated"],
"description": "Prod CRM sync"
}'
```
### Common errors
- `400`: `url` isn't a public HTTPS endpoint, or `events` is empty / contains
an unknown event name.
- `409 webhook.duplicateUrl`: *this credential* already has a webhook on that
URL.
- `409`: the account is at the 42-webhook ceiling.
---
## PATCH `/v1/webhooks/:id`
Update a webhook. All fields optional; only those present are changed.
| | |
|---|---|
| **Method** | `PATCH` |
| **URL** | `https://api.42min.us/v1/webhooks/{id}` |
| **Scope** | `webhooks:write` |
| **Auth** | Required |
### Body
```json
{
"url": "https://hooks.example.com/42min/v2",
"events": ["booking_created", "booking_canceled"],
"description": "Prod CRM sync (v2)",
"status": "active"
}
```
| Field | Type | Notes |
|---|---|---|
| `url` | string | Same rules as create. Changing it re-runs the HTTPS/public check and the per-sandbox duplicate check. |
| `events` | string[] | Replaces the event list wholesale (at least one). |
| `description` | string | Max 255. |
| `status` | string | `active` or `paused`. Setting it to `active` **resets the consecutive-failure counter and clears `paused_reason`**. This is how you re-enable an auto-paused hook. |
### Response
`200 OK` with the updated webhook object (no `signing_secret`).
### curl
```bash
# Resume an auto-paused webhook
curl -X PATCH https://api.42min.us/v1/webhooks/01H… \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"status":"active"}'
```
### Common errors
- `404 webhook.notFound`: not in this credential's sandbox.
- `400` / `409`: same URL validation and duplicate-URL rules as create.
---
## DELETE `/v1/webhooks/:id`
Permanently delete a webhook and its delivery history.
| | |
|---|---|
| **Method** | `DELETE` |
| **URL** | `https://api.42min.us/v1/webhooks/{id}` |
| **Scope** | `webhooks:write` |
| **Auth** | Required |
### Response
`204 No Content`, with an empty body.
### Common errors
- `404 webhook.notFound`: not in this credential's sandbox.
---
## POST `/v1/webhooks/:id/rotate-secret`
Issue a new signing secret. The old secret stops being valid **immediately**, so
update your verifier before you call this, or accept a brief window of
signature failures.
| | |
|---|---|
| **Method** | `POST` |
| **URL** | `https://api.42min.us/v1/webhooks/{id}/rotate-secret` |
| **Scope** | `webhooks:write` |
| **Auth** | Required |
### Response
`200 OK` with the webhook object **including the new `signing_secret`**, the
second and last place the secret is ever returned.
### curl
```bash
curl -X POST https://api.42min.us/v1/webhooks/01H…/rotate-secret \
-H "Authorization: Bearer $TOKEN"
```
---
## POST `/v1/webhooks/:id/test`
Enqueue a synthetic `booking.created` delivery so you can confirm your
endpoint is reachable and your signature check works. The payload's
`data.booking` is obviously fake (`"test": true`, `id: "test-booking-id"`).
| | |
|---|---|
| **Method** | `POST` |
| **URL** | `https://api.42min.us/v1/webhooks/{id}/test` |
| **Scope** | `webhooks:write` |
| **Auth** | Required |
### Response
`200 OK`:
```json
{ "data": { "ok": true, "delivery_id": "01H…" }, "meta": { "request_id": "req_…" } }
```
The delivery is queued, not synchronous, so poll
[`/deliveries`](#get-v1webhooksiddeliveries) with the returned
`delivery_id` to see the outcome.
---
## GET `/v1/webhooks/:id/deliveries`
The **50 most recent** delivery attempts for this webhook, newest first.
Older attempts are pruned after 30 days.
| | |
|---|---|
| **Method** | `GET` |
| **URL** | `https://api.42min.us/v1/webhooks/{id}/deliveries` |
| **Scope** | `webhooks:read` |
| **Auth** | Required |
### Response
```json
{
"data": [
{
"id": "01H…",
"event": "booking_created",
"attempt": 1,
"status_code": 200,
"error": null,
"delivered_at": "2026-05-18T14:03:01.000Z",
"created_at": "2026-05-18T14:03:00.000Z"
}
],
"meta": { "request_id": "req_…" }
}
```
- `status_code` is the HTTP status your endpoint returned (`null` if the
request never completed: DNS failure, timeout, connection refused).
- `error` carries the failure reason when there is one; `null` on success.
- `delivered_at` is set only once a 2xx is received; it stays `null` for
attempts that failed or are still pending.
---
## Event names
The `events` array you **send** to create/update uses the enum
(underscore) form. The `event` field in the **delivered** envelope and the
`X-42min-Webhook-Event` header use the dot form. They map one-to-one:
| Send in `events` | Delivered as | Fires when |
|---|---|---|
| `booking_created` | `booking.created` | A meeting is booked. |
| `booking_rescheduled` | `booking.rescheduled` | A booking moves to a new time. |
| `booking_canceled` | `booking.canceled` | A booking is canceled. |
| `booking_no_show` | `booking.no_show` | A booking is marked no-show. |
| `booking_updated` | `booking.updated` | `PATCH /v1/bookings/:uid` changed `metadata`, `responses`, or `attendee_name`. The payload adds `changed_fields[]` naming exactly which of those changed. This is the only path that mutates those fields silently; no `booking.rescheduled`/`booking.canceled` covers it. See [`/v1/bookings`](/help/api/bookings#patch-v1bookingsuid). |
| `event_type_updated` | `event_type.updated` | An event type is created-via-edit, edited, or toggled on/off from the [Event Types](/360/events) dashboard. Payload `data.event_type` is the **same shape as** [`GET /v1/event-types/:idOrSlug`](/help/api/event-types) (locations, `questions[]`, limits, and the rest). |
| `event_type_deleted` | `event_type.deleted` | An event type is deleted. Payload carries only `{ id, slug, deleted_at }`, because the row is gone. |
`event_type.updated` / `event_type.deleted` fire from **dashboard** changes, since
there are no public write endpoints for event types yet, but the webhooks let
an integration keep its mirror of your event-type catalog in sync.
> **Catch-up after a pause.** If a webhook was auto-paused and you missed
> `booking.*` events, reconcile with
> [`GET /v1/bookings?updated_since=…&sort=updated_at_asc`](/help/api/bookings#get-v1bookings)
> rather than guessing: it returns every booking touched since a timestamp,
> in stable ascending order, ready to page through.
---
## Where this sits
- Receiving, verifying, retries, auto-pause, the activity log →
[API & webhooks](/help/team-and-admin/api-and-webhooks#webhooks).
- Minting the PAT or OAuth client that calls this API →
[Authentication](/help/api/authentication).
- The exact scope each endpoint needs → [Scopes](/help/api/scopes).