# 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).