# Create Outbound Call Source: https://developer.zeeg.me/api/ai-agent/create-outbound-call POST /agents/{agentUuid}/outbound-calls Trigger an immediate or scheduled AI agent outbound phone call. Pass the destination number and any call context required by the agent. # Get Agent Call Source: https://developer.zeeg.me/api/ai-agent/get-agent-call GET /agent-calls/{callId} Get one AI voice agent call, with its collected data, analysis and transcript. # List Agent Calls Source: https://developer.zeeg.me/api/ai-agent/list-agent-calls GET /agents/{agentUuid}/calls List the calls one AI voice agent made or answered, newest first, filterable by day range, direction, status and outcome. # List AI Agents Source: https://developer.zeeg.me/api/ai-agent/list-agents GET /agents List the AI voice agents in your workspace, newest first, with their phone line, language and call totals. # Create Time-Off Periods Source: https://developer.zeeg.me/api/availability/create-time-off-periods POST /time-off Bulk-create time-off periods for one or more users (by email, slug, or UUID) to block their availability for vacations or absences. # Delete Time-Off Period Source: https://developer.zeeg.me/api/availability/delete-time-off-period DELETE /time-off/{uuid} Permanently delete a time-off period by UUID, restoring the user's availability for that date range. # Get All Availability Schedules Source: https://developer.zeeg.me/api/availability/get-all-availability-schedules GET /schedules Retrieve all availability schedules belonging to the authenticated user, including weekly hours, time zone, and date overrides. # Get Availability Schedule by UUID Source: https://developer.zeeg.me/api/availability/get-availability-schedule-by-uuid GET /schedules/{uuid} Retrieve a specific availability schedule by its UUID, with weekly working hours, time zone, and any date-specific overrides. # Get Default Availability Schedule Source: https://developer.zeeg.me/api/availability/get-default-availability-schedule GET /schedules/default Retrieve the user's default availability schedule, used when a scheduling page does not have a specific schedule assigned. # List Time-Off Periods Source: https://developer.zeeg.me/api/availability/list-time-off-periods GET /time-off Retrieve all time-off periods configured for users in your workspace, used to block availability during vacations or absences. # Update Availability Schedule by UUID Source: https://developer.zeeg.me/api/availability/update-availability-schedule-by-uuid PATCH /schedules/{uuid} Update a specific availability schedule by its UUID. Modify weekly working hours, time zone, or date-specific overrides. # Update Default Availability Schedule Source: https://developer.zeeg.me/api/availability/update-default-availability-schedule PATCH /schedules/default Update the user's default availability schedule, including weekly working hours, time zone, and date-specific overrides. # Update Time-Off Period Source: https://developer.zeeg.me/api/availability/update-time-off-period PATCH /time-off/{uuid} Update an existing time-off period by UUID — change start/end dates, time zone, or notes. # Add Attribute to CRM Object Source: https://developer.zeeg.me/api/crm/add-attribute-to-crm-object POST /crm/objects/{slug}/attributes Add a custom attribute to a Zeeg CRM object via the API. Define typed fields like text, number, select, relation, or currency with auto-generated keys ## Key auto-generation You do not set the `key` — the server derives it from `label` by converting to snake\_case (e.g. `"Product Name"` → `"product_name"`). The generated key is returned in the `201` response. Use this key to reference the attribute in subsequent `PATCH` calls and when writing record values. If another attribute with the same derived key already exists, the server appends a suffix (e.g. `product_name_2`). ## Attribute type reference Every attribute requires `label`, `type`, and `isRequired`. `isArchived` is optional and defaults to `false`. The sections below list the extra fields each type needs and what values it stores. *** ### `text` Stores a free-form string. **Extra fields** | Field | Required | Description | | - | - | - | | `isUnique` | yes | Reject duplicate values across records. | | `validation` | no | Array of format validators: `"email"`, `"url"`, or both. | **Example request** ```json theme={null} { "label": "Work Email", "type": "text", "isRequired": false, "isUnique": true, "isArchived": false, "validation": ["email"] } ``` *** ### `number` Stores a numeric value (integer or decimal). **Extra fields** | Field | Required | Description | | - | - | - | | `isUnique` | yes | Reject duplicate values across records. | *** ### `phone_number` Stores a phone number string. Displayed with a phone-number input in the UI. **Extra fields** | Field | Required | Description | | - | - | - | | `isUnique` | yes | Reject duplicate values across records. | *** ### `date` Stores a calendar date (no time component). No extra fields. *** ### `datetime` Stores a date and time with timezone. No extra fields. *** ### `checkbox` Stores a boolean (`true` / `false`). No extra fields. *** ### `select` Stores one value chosen from a fixed list. **Extra fields** | Field | Required | Description | | - | - | - | | `options` | yes | Array of 1–20 option objects. Each option: `value` (string), `color` (named color — see below). Do **not** include `id` on create. | **Allowed colors** `gray` `primary` `warning` `success` `cyan` `sky` `violet` `fuchsia` `rose` `blue` `green` `red` `pink` `black` `yellow` `orange` **Example request** ```json theme={null} { "label": "Category", "type": "select", "isRequired": false, "isArchived": false, "options": [ { "value": "Electronics", "color": "primary" }, { "value": "Clothing", "color": "success" }, { "value": "Clearance", "color": "warning" } ] } ``` *** ### `multiselect` Stores multiple values from a fixed list. Same fields as `select`. **Extra fields** Same as `select` — `options` array required. *** ### `status` Like `select` but rendered as a status badge. Change history is logged for auditing. Same fields as `select`. **Extra fields** Same as `select` — `options` array required. *** ### `rating` Stores an integer rating (1–5 stars in the UI). No extra fields. *** ### `relation` Links a record to one or more records in another CRM object. **Extra fields** | Field | Required | Description | | - | - | - | | `relationType` | yes | Cardinality: `one_to_one`, `many_to_one`, `one_to_many`, or `many_to_many`. | | `relatedObjectSlug` | yes | Slug of the target object (`people`, `companies`, or a custom object slug). Immutable after creation. | | `relatedObjectLabel` | yes | Display label shown for the relation in the UI (e.g. `"Person"`, `"Company"`). | `one_to_one` / `many_to_one` → the field accepts a single linked record.\ `one_to_many` / `many_to_many` → the field accepts multiple linked records. **Example request** ```json theme={null} { "label": "Assigned Person", "type": "relation", "isRequired": false, "isArchived": false, "relationType": "many_to_one", "relatedObjectSlug": "people", "relatedObjectLabel": "Person" } ``` *** ### `currency` Stores a numeric monetary value with display formatting. **Extra fields** | Field | Required | Description | | - | - | - | | `currency` | yes | ISO 4217 currency code (e.g. `EUR`, `USD`, `GBP`). Immutable after creation. | | `currencyDisplay` | yes | How the currency is shown: `code` (`EUR 12.00`), `name` (`Euro 12.00`), or `symbol` (`€12.00`). | | `currencyDecimal` | yes | `true` to show decimal places. | | `currencyGrouping` | yes | `true` to use a thousands separator (`1,000.00` vs `1000.00`). | `currencyDisplay`, `currencyDecimal`, and `currencyGrouping` can be changed later via `PATCH`. The `currency` code is immutable after creation. **Supported currency codes** USD, EUR, GBP, JPY, CHF, CAD, AUD, NZD, SEK, DKK, NOK, CZK, PLN, HUF, RON, BRL, MXN, CNY, SGD, THB, PHP, IDR, INR, ZAR, MYR, VND, KRW, TND, OMR, QAR, SAR, AED, BHD, KWD, JOD, LYD, IRR, TRY. **Example request** ```json theme={null} { "label": "Price", "type": "currency", "isRequired": false, "isArchived": false, "currency": "EUR", "currencyDisplay": "symbol", "currencyDecimal": true, "currencyGrouping": true } ``` *** ### `user` Links the record to one or more workspace members. Values are user IDs. No extra fields. Always supports multiple selection. # Assert a CRM Company Source: https://developer.zeeg.me/api/crm/assert-a-crm-company PUT /crm/companies Create or update a CRM company in the Zeeg CRM API, matched by domain. Upserts companies for bulk imports and sync jobs without pre-checking. Creates or updates a CRM company record using a unique attribute to find an existing match. * If a company with the same `domain` is found → it is **updated** and `200` is returned. * If no match is found → a new company is **created** and `201` is returned. ## Matching attribute The `matchingAttribute` query parameter specifies which field to use for the lookup. Currently only `domain` is supported, as it is the only unique attribute on companies. ``` PUT /v2/crm/companies?matchingAttribute=domain ``` ## Use case Assert is the recommended endpoint for bulk imports and sync jobs. Send your full company payload and let the API decide whether to create or update — no need to pre-check for duplicates. ```json theme={null} { "name": "Acme Corp", "domain": "acme.com", "websiteUrl": "https://acme.com", "primaryLocation": "Berlin, Germany", "socials": { "linkedin": "https://linkedin.com/company/acme" } } ``` # Assert a CRM Record (Upsert) Source: https://developer.zeeg.me/api/crm/assert-a-crm-record PUT /crm/{objectSlug} Upsert a CRM record into a Zeeg custom object: match on an attribute to update an existing record or create a new one. Ideal for sync and bulk imports. Creates or updates a record depending on whether a matching record already exists. This is the recommended endpoint for sync pipelines and bulk imports. * **Match found** → updates the existing record and returns `200 OK`. * **No match** → creates a new record and returns `201 Created`. ## Path parameter | Parameter | Type | Description | | - | - | - | | `objectSlug` | string | The slug of the custom CRM object to assert into (e.g. `products`, `deals`). | ## Query parameter | Parameter | Type | Required | Description | | - | - | - | - | | `matchingAttribute` | string | Yes | The attribute slug used to look up an existing record. The API searches for a record where this attribute's value exactly equals the value you send in the request body. | ## Request body A flat JSON object of attribute slug → value pairs. The value of `matchingAttribute` must be included in the body — it is both the lookup key and the new value to persist. ```json theme={null} { "sku": "DRESS-001", "price": 35, "inventory_count": 80 } ``` The entire body is applied to the record (create or update). Attributes you omit are set to `null` on create, and left unchanged on update. ## How matching works The API performs exact equality matching on the `matchingAttribute` value. Only one record is ever matched. If multiple records somehow share the same value (e.g. before you marked the attribute as unique), the first match is used. ``` PUT /v2/crm/products?matchingAttribute=sku ``` The above searches for a record where `sku = `. ## Choosing the right matching attribute Pick an attribute that uniquely identifies a record in your source system — an external ID, a product SKU, a deal number, an email address. Mark it as `isUnique` on the object schema to prevent collisions. ``` PUT /v2/crm/deals?matchingAttribute=external_id PUT /v2/crm/products?matchingAttribute=sku PUT /v2/crm/subscriptions?matchingAttribute=stripe_subscription_id ``` ## Uniqueness constraints If another attribute on the record is marked `isUnique`, the assert will fail with `400` if you try to assign a value that already belongs to a *different* record. You can always re-assert the same unique value back onto the same record without error. ## Error: matching value is empty If the value of `matchingAttribute` in the request body is `null` or an empty string, the API returns `400 Bad Request`. Always include a non-empty value for the matching attribute. ## Example: bulk product sync Your external catalog has 10,000 products. Run assert in a loop: ```json theme={null} PUT /v2/crm/products?matchingAttribute=sku { "sku": "JACKET-042", "price": 89, "inventory_count": 200, "in_stock": true } ``` First run: all products are created (201). Subsequent runs: existing products are updated with latest prices (200). No pre-check needed — the API handles it. ## Distinguishing create vs. update Check the HTTP status code in the response: | Status | Meaning | | - | - | | `201 Created` | No match was found; a new record was created. | | `200 OK` | A matching record was found and updated. | ## When to use this endpoint * **Periodic sync jobs** — push updated records from an external system on a schedule. * **Webhook-driven updates** — handle incoming webhooks that may create or update a record depending on whether it exists. * **Bulk initial import** — send all records from a legacy system without checking for duplicates first. * **Idempotent writes** — retry failed requests safely; asserting the same data again results in an update (200) with no duplicate. # Create a CRM Company Source: https://developer.zeeg.me/api/crm/create-a-crm-company POST /crm/companies Create a new company record in your Zeeg CRM with a workspace-unique domain and optional social profile links such as LinkedIn and Twitter. Creates a new CRM company record. ## Domain uniqueness `domain` must be unique within your workspace. Attempting to create a company with a `domain` that already exists will return an error. If you want to create-or-update in one call, use the [Assert a CRM Company](/api/crm/assert-a-crm-company) endpoint instead. ## Social links Pass social profile URLs as a nested `socials` object: ```json theme={null} { "name": "Acme Corp", "socials": { "linkedin": "https://linkedin.com/company/acme", "twitter": "https://twitter.com/acme" } } ``` # Create a CRM Object Source: https://developer.zeeg.me/api/crm/create-a-crm-object POST /crm/objects Create a custom CRM object in your Zeeg workspace with a unique, immutable slug. Each new object gets id, created_at, and updated_at system attributes. ## Slug rules * 3–60 characters, lowercase letters, numbers, hyphens, and underscores only * Must be unique within your workspace * **Immutable after creation** — the slug cannot be renamed The slugs `people` and `companies` are reserved for standard objects and cannot be used. ## Auto-created attributes Every new object automatically receives three system attributes that cannot be removed: | Key | Type | Description | | - | - | - | | `id` | `text` | Unique record identifier (UUID) | | `created_at` | `datetime` | Record creation timestamp | | `updated_at` | `datetime` | Last modification timestamp | Add custom attributes to the object using `POST /crm/objects/{slug}/attributes` after creation. # Create a CRM Person Source: https://developer.zeeg.me/api/crm/create-a-crm-person POST /crm/people Create a new person record in the Zeeg CRM API with first or last name, link them to an existing company by UUID, and attach social profile URLs Creates a new CRM person record. ## Name requirement At least one of `firstName` or `lastName` must be provided. ## Associating with a company Pass `companyId` with the UUID of an existing CRM company to link the person to that company. ## Social links Pass social profile URLs as a nested `socials` object: ```json theme={null} { "firstName": "Jane", "lastName": "Doe", "socials": { "linkedin": "https://linkedin.com/in/janedoe", "twitter": "https://twitter.com/janedoe" } } ``` # Create a CRM Record Source: https://developer.zeeg.me/api/crm/create-a-crm-record POST /crm/{objectSlug} Create a new record in a custom Zeeg CRM object by slug, setting typed attribute values that are validated on submit, and get back a generated record UUID Creates a new record for a custom CRM object and returns it with a generated UUID. ## Path parameter | Parameter | Type | Description | | - | - | - | | `objectSlug` | string | The slug of the custom CRM object to create a record in (e.g. `products`, `deals`). The object must exist. | ## Request body Pass a flat JSON object where each key is an attribute slug and each value is the attribute value. You only need to include attributes you want to set — omitted attributes default to `null`. ```json theme={null} { "sku": "DRESS-001", "price": 29, "inventory_count": 150, "in_stock": true, "launched_at": "2025-06-01", "category": { "id": 12 } } ``` Use [`GET /v2/crm/objects/{slug}`](/api/crm/get-a-crm-object) to discover the available attribute slugs and their types before writing records. ## Attribute types Values are validated against the type defined on the object. Passing the wrong type returns a `400` error. | Type | Expected value | Example | | - | - | - | | `text` | String, max 255 characters | `"DRESS-001"` | | `number` | Integer | `29` | | `checkbox` | Boolean | `true` or `false` | | `date` | ISO 8601 date string | `"2025-06-01"` | | `select` | Object with an `id` key matching a valid option ID | `{ "id": 12 }` | | `multiselect` | Array of objects, each with an `id` key | `[{ "id": 12 }, { "id": 13 }]` | | `relation` | Array of record UUIDs from the related object | `["c1d2e3f4-...", "a2b3c4d5-..."]` | | `user` | Array of organization member UUIDs | `["member-uuid-1"]` | Option IDs for `select` and `multiselect` attributes are returned when you fetch the object schema via [`GET /v2/crm/objects/{slug}`](/api/crm/get-a-crm-object). ## Unique attributes If an attribute is marked as `isUnique`, submitting a value that already exists on another record returns a `400` error. Check for uniqueness constraints in the object schema before creating records in bulk. ## Minimal record creation You can create a record with no attributes at all — useful when you want to allocate an ID first and fill in data later: ```json theme={null} {} ``` ## When to use this endpoint * **Single record creation** — a user fills a form and submits a new product, deal, or custom entry. * **One-off imports** — create a small number of known records programmatically. * **Initial seeding** — populate a new object with a handful of records before bulk operations. For bulk imports where records may already exist, prefer [Assert (upsert)](/api/crm/assert-a-crm-record) instead. It creates when no match is found and updates when one is found — eliminating the need to pre-check for duplicates. # Delete a CRM Company Source: https://developer.zeeg.me/api/crm/delete-a-crm-company DELETE /crm/companies/{id} Delete a CRM company by ID and cascade its notes, activity logs, and relationships via the Zeeg CRM API. Permanent and irreversible. Permanently deletes a CRM company record and all associated data (notes, activity logs, relationships). This action is irreversible. All data stored on this company record will be permanently deleted. # Delete a CRM Object Source: https://developer.zeeg.me/api/crm/delete-a-crm-object DELETE /crm/objects/{slug} Delete a custom CRM object and all its records via the Zeeg CRM API. Removes attribute definitions and relation links; standard objects can't be deleted This action is permanent and cannot be undone. Deleting a CRM object permanently removes: * The object definition and all its attribute definitions * **All records** stored in the object * Any `relation` attributes in other objects that pointed to this object (the attribute is removed from those objects automatically) Standard objects (`people`, `companies`) cannot be deleted — the endpoint returns `403`. # Delete a CRM Person Source: https://developer.zeeg.me/api/crm/delete-a-crm-person DELETE /crm/people/{id} Delete a CRM person by ID via the Zeeg CRM API, also removing their notes, activity logs, and company links. This cannot be undone. Permanently deletes a CRM person record and all associated data (notes, activity logs, relationships). This action is irreversible. All data stored on this person record will be permanently deleted. # Delete a CRM Record Source: https://developer.zeeg.me/api/crm/delete-a-crm-record DELETE /crm/{objectSlug}/{recordId} Delete a custom object record in the Zeeg CRM API, permanently removing its attribute values, notes, activity logs, and links from related records Permanently deletes a CRM record and all data associated with it. This action is irreversible. Notes, activity logs, and all attribute data stored on the record are deleted immediately and cannot be recovered. ## Path parameters | Parameter | Type | Description | | - | - | - | | `objectSlug` | string | The slug of the custom CRM object the record belongs to. | | `recordId` | string (UUID) | The UUID of the record to delete. | ## What gets deleted Deleting a record removes: * The record itself and all its attribute values. * Any notes attached to the record. * Activity log entries for the record. * The record's presence in relation attributes on **other** records (it is removed from any related record's relation list). ## Response A successful delete returns `200 OK` with a confirmation message: ```json theme={null} { "success": true, "status": 200, "message": "Record deleted." } ``` Subsequent `GET` requests for the same `recordId` return `404 Not Found`. ## Idempotency Delete is **not** idempotent — calling it a second time on an already-deleted record returns `404`, not `200`. If your workflow requires idempotent deletes, catch `404` and treat it as a success. ## When to use this endpoint * **Hard delete** — a user explicitly removes a record from the CRM. * **Cleanup** — delete test records, duplicates, or outdated entries. * **GDPR / data erasure** — permanently erase a contact's record in response to a deletion request. There is no soft-delete or archive on custom object records. If you need to mark a record as inactive without losing data, add a `status` attribute to the object schema and set it to `archived` or `inactive` via PATCH instead of deleting. # Get a CRM Company Source: https://developer.zeeg.me/api/crm/get-a-crm-company GET /crm/companies/{id} Retrieve the full record for a single CRM company by ID with the Zeeg CRM API, including nested social links and custom attributes Returns the full record for a single CRM company by its ID. Social links are returned nested under the `socials` object. Custom attributes are returned under `customAttributes`. # Get a CRM Object Source: https://developer.zeeg.me/api/crm/get-a-crm-object GET /crm/objects/{slug} Retrieve the full schema of a single Zeeg CRM object by slug, including every attribute definition with its type, label, options, and relation metadata Returns the full schema for a single object identified by its slug, including every attribute definition with its type, label, options, and relation metadata. Standard object slugs are always `people` and `companies`. Custom object slugs are set at creation time and are immutable. # Get a CRM Person Source: https://developer.zeeg.me/api/crm/get-a-crm-person GET /crm/people/{id} Retrieve a single CRM person record by ID from the Zeeg CRM API, including social links, the linked company, and custom attributes. Returns the full record for a single CRM person by their ID. Social links are returned nested under the `socials` object. The associated company (if any) is returned as a compact object under `company`. Custom attributes are returned under `customAttributes`. # Get a CRM Record Source: https://developer.zeeg.me/api/crm/get-a-crm-record GET /crm/{objectSlug}/{recordId} Retrieve a single custom CRM object record by UUID with the Zeeg CRM API, returning all its attribute values keyed by attribute slug Returns a single record for a custom CRM object by its UUID. ## Path parameters | Parameter | Type | Description | | - | - | - | | `objectSlug` | string | The slug of the custom CRM object the record belongs to. | | `recordId` | string (UUID) | The unique ID of the record. Obtained from a previous [List](/api/crm/list-crm-records) or [Create](/api/crm/create-a-crm-record) response. | ## Response shape | Field | Type | Description | | - | - | - | | `id` | string (UUID) | Unique identifier for the record. | | `objectSlug` | string | The object this record belongs to (e.g. `products`). | | `attributes` | object | All attribute values for this record, keyed by attribute slug. Attributes that have not been set are returned as `null`. | | `createdAt` | string (ISO 8601) | Timestamp when the record was created. | | `updatedAt` | string (ISO 8601) | Timestamp when the record was last modified. | ## Understanding the `attributes` object Attribute keys inside `attributes` match the slugs defined on the object schema. The shape of the object depends entirely on which attributes exist on that CRM object. For example, a `products` object with attributes `sku`, `price`, and `in_stock` returns: ```json theme={null} { "record": { "id": "c1d2e3f4-a5b6-7890-cdef-123456789012", "objectSlug": "products", "attributes": { "sku": "DRESS-001", "price": 29, "in_stock": true }, "createdAt": "2025-06-01T10:00:00+00:00", "updatedAt": "2025-06-01T10:00:00+00:00" } } ``` To know which keys to expect and their types, call [`GET /v2/crm/objects/{slug}`](/api/crm/get-a-crm-object). ## 404 Not Found If either the object slug does not exist in your workspace, or the record ID does not belong to that object, the API returns `404`: ```json theme={null} { "success": false, "message": "Record not found.", "status": 404 } ``` ## When to use this endpoint * **Detail view** — display a single record's full data after the user clicks a row. * **Verification** — confirm a record still exists and fetch its latest values before displaying or editing. * **Webhook handlers** — look up a record by the ID received in a webhook payload. # List CRM Companies Source: https://developer.zeeg.me/api/crm/list-crm-companies GET /crm/companies List paginated CRM company records in your Zeeg workspace, sorted by creation date, with each company's custom attributes included via the CRM API Returns a paginated list of CRM company records in your workspace. Results are sorted by `created_at` ascending by default. Use `sortBy` and `sortOrder` to change the sort. Custom attributes defined on the companies object are included in each record under `customAttributes`. # List CRM Objects Source: https://developer.zeeg.me/api/crm/list-crm-objects GET /crm/objects Retrieve every CRM object in your Zeeg workspace with full attribute schemas, including standard people and companies plus any custom objects you created Returns all CRM objects in your workspace along with their complete attribute schemas. **Standard objects** (`people`, `companies`) are always present. Custom objects appear once created. Use this endpoint first to map your existing schema before importing records — every attribute `key` returned here is the identifier you use when writing record values. This endpoint returns all objects in a single response. There is no pagination. # List CRM People Source: https://developer.zeeg.me/api/crm/list-crm-people GET /crm/people List CRM person records in a workspace with the Zeeg CRM API, returning paginated, sortable results that include each contact's custom attributes Returns a paginated list of CRM person records in your workspace. Results are sorted by `created_at` ascending by default. Use `sortBy` and `sortOrder` to change the sort. Custom attributes defined on the people object are included in each record under `customAttributes`. # List CRM Records Source: https://developer.zeeg.me/api/crm/list-crm-records GET /crm/{objectSlug} List paginated records of any custom CRM object in Zeeg by object slug, with sorting and pagination controls for browsing, export, and sync jobs Returns a paginated list of records for a custom CRM object. Use this endpoint to browse all records for any object you've created — products, deals, subscriptions, or any other custom object in your workspace. ## Path parameter | Parameter | Type | Description | | - | - | - | | `objectSlug` | string | The slug of the custom CRM object. Must already exist. Discover available slugs via [List CRM Objects](/api/crm/list-crm-objects). | ## Query parameters | Parameter | Type | Default | Description | | - | - | - | - | | `page` | integer | `1` | Page number. Starts at 1. | | `perPage` | integer | `10` | Number of records returned per page. Maximum is `100`. | | `sortBy` | string | `created_at` | Attribute slug to sort by. Pass the exact attribute key (e.g. `price`, `sku`). Use `created_at` or `updated_at` for system timestamps. | | `sortOrder` | string | `asc` | Sort direction. Either `asc` (oldest/lowest first) or `desc` (newest/highest first). | ## Response shape Each record in the `records` array contains: | Field | Type | Description | | - | - | - | | `id` | string (UUID) | Unique identifier for the record. Store this on your side to avoid re-fetching. | | `objectSlug` | string | The object this record belongs to. | | `attributes` | object | All attribute values keyed by their attribute slug. Unset attributes are `null`. | | `createdAt` | string (ISO 8601) | When the record was created. | | `updatedAt` | string (ISO 8601) | When the record was last modified. | The `pagination` wrapper contains: | Field | Type | Description | | - | - | - | | `totalItems` | integer | Total number of records across all pages. | | `perPage` | integer | Records per page as requested. | | `currentPage` | integer | The page returned in this response. | | `lastPage` | integer | Total number of pages. | | `hasPreviousPage` | boolean | `true` when a previous page exists. | | `hasNextPage` | boolean | `true` when a next page exists. | ## Discovering attribute keys Attribute keys (the keys inside `attributes`) are defined on the object schema. To see all available attribute slugs and their types, call [`GET /v2/crm/objects/{slug}`](/api/crm/get-a-crm-object) before building a UI or processing the response. ## Pagination example To iterate through all records in pages of 50, ordered newest first: ``` GET /v2/crm/products?perPage=50&sortBy=created_at&sortOrder=desc&page=1 GET /v2/crm/products?perPage=50&sortBy=created_at&sortOrder=desc&page=2 ``` Keep requesting until `hasNextPage` is `false`. ## When to use this endpoint * **Browsing** — display a table of records in your UI. * **Export** — page through all records to write them to another system. * **Sync jobs** — poll for recently updated records with `sortBy=updated_at&sortOrder=desc`. # CRM Overview Source: https://developer.zeeg.me/api/crm/overview Manage contacts, companies, and custom objects through the Zeeg CRM API. The Zeeg CRM API lets you programmatically manage all data inside the Zeeg CRM — contacts (people), companies, and any custom objects your workspace has created. It is designed for data migrations, bi-directional syncs, and integrations with external systems. ## Scopes | Scope | What it allows | | - | - | | `crm:read` | Read all CRM objects and records | | `crm:write` | Create, update, and delete CRM records; also grants read access | All read endpoints accept either scope. All write endpoints require `crm:write`. ## Standard objects Zeeg CRM ships with two built-in objects. They are always present in every workspace and cannot be deleted. ### People (`people`) Represents a contact — an individual person your team interacts with. | Attribute | API key | Type | Notes | | - | - | - | - | | ID | `id` | text | UUID, read-only | | First Name | `first_name` | text | | | Last Name | `last_name` | text | | | Emails | `emails` | text | Unique. Accepts email validation. | | Phone Number | `phone_number` | phone\_number | | | Job Title | `job_title` | text | | | Description | `description` | text | | | Company | `crm_customer_company_id` | relation → companies | Links person to a company | | Contact Owner | `contact_owner_member_id` | user | Workspace member responsible for the contact | | LinkedIn | `linkedin` | text | URL | | X (Twitter) | `twitter` | text | URL | | Facebook | `facebook` | text | URL | | Instagram | `instagram` | text | URL | | Created At | `created_at` | datetime | Read-only | | Last Modified | `updated_at` | datetime | Read-only | | Last Interaction | `last_interaction` | datetime | Auto-updated from calendar/email activity | | Next Interaction | `next_interaction` | datetime | Auto-updated from scheduled meetings | ### Companies (`companies`) Represents an organisation your team works with. | Attribute | API field | Type | Notes | | - | - | - | - | | ID | `id` | text | UUID, read-only | | Name | `name` | text | Required | | Domain | `domain` | text | Unique within workspace. Used as matching attribute for assert (upsert). | | Description | `description` | text | | | Website URL | `websiteUrl` | text | URL | | Primary Location | `primaryLocation` | text | | | Industries | `industries` | multiselect | Array of industry IDs | | Account Owner | `account_owner_member_id` | user | Workspace member responsible for the account | | LinkedIn | `socials.linkedin` | text | URL | | X (Twitter) | `socials.twitter` | text | URL | | Facebook | `socials.facebook` | text | URL | | Instagram | `socials.instagram` | text | URL | | Created At | `createdAt` | datetime | Read-only | | Last Modified | `updatedAt` | datetime | Read-only | | Last Interaction | `last_interaction` | datetime | Auto-updated | | Next Interaction | `next_interaction` | datetime | Auto-updated | ## Custom objects In addition to the two standard objects, workspaces on eligible plans can create custom objects (e.g. `deals`, `products`, `tickets`). Each custom object has its own set of custom attributes. Use `GET /crm/objects` to list all objects — standard and custom — along with their full attribute schemas. ## Getting started The recommended workflow for importing or syncing data: ``` 1. GET /v2/crm/objects → Discover all objects and their attribute keys 2. PUT /v2/crm/companies?matchingAttribute=domain (for each company) → Create or update companies by domain 3. POST /v2/crm/people (for each person) → Create people records, linking them to companies via crm_customer_company_id ``` All timestamps in responses are in **ISO 8601** format with UTC offset (e.g. `2025-06-01T10:00:00+00:00`). # Patch a CRM Company Source: https://developer.zeeg.me/api/crm/patch-a-crm-company PATCH /crm/companies/{id} Update a CRM company via the Zeeg CRM API by sending only the fields to change, including individual social links; omitted fields stay as-is. Partially updates a CRM company record. Only fields you include in the request body are changed — omitted fields keep their current values. ## Social links To update individual social links without overwriting the others, include only the keys you want to change inside `socials`: ```json theme={null} { "socials": { "linkedin": "https://linkedin.com/company/updated" } } ``` This updates `linkedin` only. `twitter`, `facebook`, and `instagram` are unchanged. # Patch a CRM Person Source: https://developer.zeeg.me/api/crm/patch-a-crm-person PATCH /crm/people/{id} Update a CRM person via the Zeeg CRM API by sending only the contact fields to change, including single social links; the rest stays intact. Partially updates a CRM person record. Only fields you include in the request body are changed — omitted fields keep their current values. ## Social links To update individual social links without overwriting the others, include only the keys you want to change inside `socials`: ```json theme={null} { "socials": { "linkedin": "https://linkedin.com/in/updated" } } ``` This updates `linkedin` only. `twitter`, `facebook`, and `instagram` are unchanged. # Patch a CRM Record Source: https://developer.zeeg.me/api/crm/patch-a-crm-record PATCH /crm/{objectSlug}/{recordId} Update a CRM record by ID via the Zeeg CRM API by sending only the attributes to change; every other field on the record stays untouched. Partially updates a CRM record. Only the attributes you include in the request body are changed — all other attributes remain exactly as they are. ## Path parameters | Parameter | Type | Description | | - | - | - | | `objectSlug` | string | The slug of the custom CRM object the record belongs to. | | `recordId` | string (UUID) | The UUID of the record to update. | ## Request body A flat JSON object containing only the attribute slug → value pairs you want to change. Attributes not present in the body are untouched. ```json theme={null} { "price": 49, "in_stock": false } ``` This updates `price` and `in_stock` only. Every other attribute on the record keeps its current value. ## PATCH vs. Assert | | `PATCH /{objectSlug}/{recordId}` | `PUT /{objectSlug}?matchingAttribute=...` | | - | - | - | | Lookup | By record `id` | By attribute value | | Scope | Only provided attributes change | Full body applied (omitted attrs → null on create) | | Use case | Edit a known record's specific fields | Sync/upsert from an external system | Use PATCH when you have the record ID and only want to update certain fields. Use Assert when syncing from an external source or when you don't have the ID yet. ## Attribute types The same type rules apply as when creating a record: | Type | Expected value | Example | | - | - | - | | `text` | String, max 255 characters | `"Updated name"` | | `number` | Integer | `49` | | `checkbox` | Boolean | `false` | | `date` | ISO 8601 date string | `"2025-12-31"` | | `select` | Object with an `id` key | `{ "id": "opt_abc123" }` | | `multiselect` | Array of option objects | `[{ "id": "opt_abc" }]` | | `relation` | Array of related record UUIDs | `["uuid-1", "uuid-2"]` | | `user` | Array of organization member UUIDs | `["member-uuid-1"]` | For `relation` and `user` attributes, PATCH replaces the entire array. To add or remove individual IDs without overwriting the full list, use [Update Relation](/api/crm/update-relation-crm-record) instead. ## Clearing a value To explicitly clear an attribute, pass `null`: ```json theme={null} { "launched_at": null } ``` ## Example: update price and stock status ```json theme={null} PATCH /v2/crm/products/c1d2e3f4-a5b6-7890-cdef-123456789012 { "price": 49, "inventory_count": 0, "in_stock": false } ``` Response returns the full updated record with all attributes, including the unchanged ones. ## When to use this endpoint * **Edit forms** — a user edits one or a few fields and saves. * **Status updates** — flip a single boolean or enum field without touching anything else. * **Partial enrichment** — backfill a specific attribute after you've gathered more data. # Update a CRM Object Source: https://developer.zeeg.me/api/crm/update-a-crm-object PATCH /crm/objects/{slug} Update a custom CRM object's display metadata via the Zeeg CRM API. Change its singular and plural names; the slug stays immutable Partially updates a custom CRM object's display metadata. Only fields you include are changed — omitted fields retain their current values. ## What can and cannot be changed | Field | Updatable | Notes | | - | - | - | | `singularName` | Yes | | | `pluralName` | Yes | | | `slug` | **No** | Immutable after creation | | `isActive` | No | Not exposed on this endpoint | Standard objects (`people`, `companies`) cannot be updated — the endpoint returns `403`. To add or modify attributes, use `POST /crm/objects/{slug}/attributes` and `PATCH /crm/objects/{slug}/attributes/{attributeKey}`. # Assert a CRM Person Source: https://developer.zeeg.me/api/crm/update-a-crm-person PUT /crm/people Create or update a CRM person via the Zeeg CRM API, matching on any standard or custom attribute slug to upsert contacts in bulk imports and sync jobs Creates or updates a CRM person record using a unique attribute to find an existing match. * If a person with the same value for `matchingAttribute` is found → it is **updated** and `200` is returned. * If no match is found → a new person is **created** and `201` is returned. ## Matching attribute The `matchingAttribute` query parameter specifies which field to use for the lookup. It can be **any standard or custom attribute slug** on the people object. ``` PUT /v2/crm/people?matchingAttribute=email PUT /v2/crm/people?matchingAttribute=phone_number PUT /v2/crm/people?matchingAttribute=my_custom_attribute ``` ### Email matching When `matchingAttribute` is `email` or `emails`, the lookup checks both the primary email and all secondary email addresses, so a person is found regardless of which email slot holds the value. ### Other attributes All other standard attributes (e.g. `phone_number`) and custom attributes are matched using exact equality. ## Use case Assert is the recommended endpoint for bulk imports and sync jobs. Send your full person payload and let the API decide whether to create or update — no need to pre-check for duplicates. ```json theme={null} { "firstName": "Jane", "lastName": "Doe", "emails": ["jane.doe@acme.com"], "jobTitle": "Head of Product", "primaryLocation": "Berlin, Germany", "socials": { "linkedin": "https://linkedin.com/in/janedoe" } } ``` # Update CRM Object Attribute Source: https://developer.zeeg.me/api/crm/update-crm-object-attribute PATCH /crm/objects/{slug}/attributes/{attributeKey} Update a single attribute definition on a Zeeg CRM object with a partial PATCH that changes only the fields you send, like label, options, or currency Partially updates a single attribute definition. Only the fields you include are changed. ## Updatable fields | Field | Applies to | Notes | | - | - | - | | `label` | All types | | | `isRequired` | All types | | | `isArchived` | All types | Archived attributes are hidden in the UI but data is retained | | `options` | `select`, `multiselect`, `status` | Full replacement — see below | | `relatedObjectLabel` | `relation` | Display label shown in the UI for the linked object | | `currencyDisplay` | `currency` | `code`, `name`, or `symbol` | | `currencyDecimal` | `currency` | Show decimal places | | `currencyGrouping` | `currency` | Use thousands separator | ## Immutable fields | Field | Reason | | - | - | | `type` | Changing type would corrupt existing record data | | `key` | Used as the stable identifier in all record values | | `relatedObjectSlug` | Changing the target would orphan existing linked records | | `currency` | Changing the currency code would make stored amounts misleading | ## Updating options (`select` / `multiselect` / `status`) When you send `options`, the list **fully replaces** the current options: * **Include `id`** on existing options you want to keep — existing record values are preserved. * **Omit `id`** for new options — the server assigns a new ID. * **Exclude** an option entirely to delete it — existing records that held that value will have the value cleared. ```json theme={null} { "options": [ { "id": 12, "value": "Active", "color": "success" }, { "id": 13, "value": "On Hold", "color": "warning" }, { "value": "Churned", "color": "gray" } ] } ``` ## Standard attributes Standard attributes (e.g. `first_name`, `emails` on `people`) cannot be updated and return `400`. # Update a CRM Record Relation Source: https://developer.zeeg.me/api/crm/update-relation-crm-record PATCH /crm/{objectSlug}/{recordId}/relation Add or remove individual record and team-member links on a relation or user attribute of a Zeeg CRM record without overwriting existing linked IDs Adds or removes individual IDs from a `relation` or `user` attribute on a CRM record. Unlike [Patch](/api/crm/patch-a-crm-record), which replaces the entire array, this endpoint performs surgical add/remove operations — existing linked IDs not mentioned in the request are untouched. ## Path parameters | Parameter | Type | Description | | - | - | - | | `objectSlug` | string | The slug of the custom CRM object the record belongs to. | | `recordId` | string (UUID) | The UUID of the record whose relation attribute you want to modify. | ## Request body | Field | Type | Required | Description | | - | - | - | - | | `attributeSlug` | string | Yes | The key of the `relation` or `user` attribute to modify. Must exist on the object and be of type `relation` or `user`. | | `add` | string\[] | No | IDs to add to the relation. For `relation` attributes: UUIDs of records in the related object. For `user` attributes: UUIDs of organization members. | | `remove` | string\[] | No | IDs to remove from the relation. The same ID format as `add`. | **Rules:** * At least one of `add` or `remove` must be present and non-empty. * The same ID cannot appear in both `add` and `remove` in the same request. * Passing an ID in `add` that is already linked is a no-op (not an error). * Passing an ID in `remove` that is not linked is a no-op (not an error). ## Attribute types that support this endpoint | Attribute type | What IDs to pass | | - | - | | `relation` | UUIDs of records from the related object (obtained from `GET /v2/crm/{relatedObjectSlug}`) | | `user` | UUIDs of organization members (obtained from `GET /v2/organizations/users`) | Passing this endpoint for a `text`, `number`, `select`, or any non-relation attribute returns an error. Use [Patch](/api/crm/patch-a-crm-record) for those. ## Example: link products to an order Your `orders` object has a `relation` attribute called `products` that links to the `products` object. To add two products to an order: ```json theme={null} PATCH /v2/crm/orders/a1b2c3d4-e5f6-7890-abcd-ef1234567890/relation { "attributeSlug": "products", "add": [ "c1d2e3f4-a5b6-7890-cdef-123456789012", "d2e3f4a5-b6c7-8901-defa-234567890123" ] } ``` ## Example: swap a linked record Remove one product and add a replacement in a single request: ```json theme={null} { "attributeSlug": "products", "add": ["new-product-uuid-here"], "remove": ["old-product-uuid-here"] } ``` ## Example: unlink all products one at a time To remove products, pass their IDs in `remove`: ```json theme={null} { "attributeSlug": "products", "remove": ["c1d2e3f4-a5b6-7890-cdef-123456789012"] } ``` ## Example: assign a team member to a deal Your `deals` object has a `user` attribute called `owners`. To assign a team member: ```json theme={null} PATCH /v2/crm/deals/deal-uuid-here/relation { "attributeSlug": "owners", "add": ["org-member-uuid-here"] } ``` ## Finding the attribute slug Relation attribute slugs are defined when the attribute is created. You can look them up via [`GET /v2/crm/objects/{slug}`](/api/crm/get-a-crm-object) — look for attributes with `type: relation` or `type: user` and use their `key` field as `attributeSlug`. ## Update Relation vs. PATCH | | `PATCH /relation` | `PATCH /{recordId}` | | - | - | - | | Operation | Adds/removes individual IDs | Replaces the entire attribute value | | Existing IDs | Preserved unless explicitly removed | Overwritten | | Use case | Add or remove one or a few links | Set the complete list in one go | Use `PATCH /relation` when you need additive or subtractive changes. Use `PATCH /{recordId}` when you want to set the exact final state of a relation (replacing whatever was there before). ## When to use this endpoint * **Add a linked record** — a user picks a related product, company, or person to attach. * **Remove a linked record** — a user detaches a relation without affecting others. * **Assign / unassign owners** — add or remove team members from a user-type attribute. * **Incremental sync** — when your source system sends delta events ("product X was added to order Y") rather than full state. # Create Holiday Subscriptions Source: https://developer.zeeg.me/api/holidays/create-holiday-subscriptions POST /holidays/subscriptions Bulk-subscribe one or more users to a holiday category so the listed dates are automatically blocked from their availability. # Delete a Holiday Subscription Source: https://developer.zeeg.me/api/holidays/delete-holiday-subscription DELETE /holidays/subscriptions/{id} Remove a holiday subscription, unblocking the previously held holiday dates from the user's availability. # List Holiday Categories Source: https://developer.zeeg.me/api/holidays/list-holiday-categories GET /holidays/categories List the holiday categories (countries and regions) available for subscription, with localized names and identifiers. # List Holiday Subscriptions Source: https://developer.zeeg.me/api/holidays/list-holiday-subscriptions GET /holidays/subscriptions List the holiday subscriptions for users in your workspace, including which categories they follow and any per-holiday overrides. # Preview Holidays for a Category Source: https://developer.zeeg.me/api/holidays/preview-holidays GET /holidays/preview Preview the upcoming holidays in a given category before creating a subscription, so users can see exactly which dates will be blocked. # Toggle Holiday Overrides Source: https://developer.zeeg.me/api/holidays/toggle-holiday-overrides PUT /holidays/subscriptions/{id}/overrides Override individual holidays within a subscription — opt back in to specific dates without unsubscribing from the entire category. # Create a Note Source: https://developer.zeeg.me/api/notes/create-a-note POST /notes Attach a free-form note to a scheduled event for internal context, meeting prep, or post-call follow-up. # Delete a Note Source: https://developer.zeeg.me/api/notes/delete-a-note DELETE /notes/{id} Permanently delete a note attached to a scheduled event. # Get a Note Source: https://developer.zeeg.me/api/notes/get-a-note GET /notes/{id} Retrieve a single note by its identifier, including its content, author, and the event it is attached to. # List Notes by Event Source: https://developer.zeeg.me/api/notes/list-notes-by-event GET /notes/event/{uuid} List all notes attached to a scheduled event, ordered by creation time, including the author and timestamps. # Update a Note Source: https://developer.zeeg.me/api/notes/update-a-note PUT /notes/{id} Update the content of an existing note attached to a scheduled event. # Create a Routing Form Source: https://developer.zeeg.me/api/routing-forms/create-a-routing-form POST /routing-forms Create a routing form with the Zeeg API, setting its name, headline, body, submit button and colours, and returning the form with its default fallback route. Alpha This endpoint is in alpha: the request shape may still change without a deprecation cycle. Creates a routing form in your workspace and returns it in the shape that [Get a Routing Form](/api/routing-forms/get-a-routing-form) returns. `name` is the only required field. It is the internal name, which an invitee never reads. The new routing form holds no question and one fallback route that shows the default thank-you page. Add the questions with [Replace Routing Form Questions](/api/routing-forms/replace-routing-form-questions) and the routes with [Replace Routing Form Routes](/api/routing-forms/replace-routing-form-routes). The response carries `slug` and `url`. `url` is the public address at which an invitee opens the routing form. An inactive routing form accepts no submission at that address. Your subscription caps how many routing forms your workspace holds. If the workspace already holds that many, the call answers `422` and writes no routing form. # Delete a Routing Form Source: https://developer.zeeg.me/api/routing-forms/delete-a-routing-form DELETE /routing-forms/{id} Delete a routing form by ID via the Zeeg API, removing its questions, its routes and every submission it holds. This cannot be undone. Deletes one routing form with its questions, its routes and every submission that it holds. This endpoint requires the `routing_forms:write` scope. A token that carries `routing_forms:read` alone answers `403`. This action is irreversible. Read the submissions with [List Routing Form Submissions](/api/routing-forms/list-routing-form-submissions) before you delete the routing form. The delete takes the submission history with it. The public address of the routing form stops resolving as soon as the delete lands. An invitee who opens a saved link reaches nothing. The id is a UUID. A malformed id answers `404`, not `422`. A routing form outside your workspace answers `404` as well. # Delete a Routing Form Submission Source: https://developer.zeeg.me/api/routing-forms/delete-a-routing-form-submission DELETE /routing-form-submissions/{id} Delete a routing form submission by ID via the Zeeg API, removing the submission and every answer it holds. This cannot be undone. Deletes one routing form submission and every answer that it holds. This endpoint requires the `routing_forms:write` scope. A token that carries `routing_forms:read` alone answers `403`. This action is irreversible. The submission and its answers are permanently deleted. The id is a UUID. A malformed id answers `404`, not `422`. A submission outside your workspace answers `404` as well. # Get a Routing Form Source: https://developer.zeeg.me/api/routing-forms/get-a-routing-form GET /routing-forms/{id} Get one routing form by ID via the Zeeg API, including its questions in inputs and its routes with the rules that select each destination. Returns one routing form with its questions in `inputs` and its routes in `routes`. A question carries its `type`, its `options`, and the CRM attribute it maps onto. A question that takes no options carries an empty `options` array. A route carries its destination and the `rules` that select it. Exactly one route is the fallback, which takes every submission that matches no other route. The fallback carries an empty `rules` array. The id is a UUID. A malformed id answers `404`, not `422`. A routing form outside your workspace answers `404` as well. # Get a Routing Form Submission Source: https://developer.zeeg.me/api/routing-forms/get-a-routing-form-submission GET /routing-form-submissions/{id} Get one routing form submission by ID via the Zeeg API, with its answers, the route the answers selected, and the UTM and ad-click values captured at submit. Returns one routing form submission with its answers. `route` holds the destination that the answers selected, as it stood at the time of the submission. `utm` and `adAttribution` hold the campaign values that the page carried when the invitee sent the submission. ## The two forms of every answer Each answer ships twice: * `answer` is the display value. Show this value to a person. * `rawAnswer` is the stored value. For a question that maps onto a CRM select attribute it is the canonical `{"id","value","color"}` JSON that identifies the option. Map into a CRM by this value. `questionsAndAnswers` repeats the same answers as a flat map from question text to display value. Never show `rawAnswer` to a person. For a CRM select answer it is a JSON blob, not a label. The id is a UUID. A malformed id answers `404`, not `422`. A submission outside your workspace answers `404` as well. # List Routing Form Submissions Source: https://developer.zeeg.me/api/routing-forms/list-routing-form-submissions GET /routing-form-submissions List routing form submissions with the Zeeg API, filtering one flat feed by form, creation time, and destination type, with each answer in both display and raw form. Returns a paginated list of the routing form submissions in your workspace. The submissions of every routing form sit in one feed, so `routingFormId` filters that feed rather than addressing a sub-resource. Narrow the feed further by creation time with `minCreatedAt` and `maxCreatedAt`, and by destination with `routeType`. Results are newest first. Set `sort` to `asc` to read the oldest submission first. Each entry carries the same payload as [Get a Routing Form Submission](/api/routing-forms/get-a-routing-form-submission). Every answer ships twice. Read `answer` for anything a person sees, and `rawAnswer` to map the answer into a CRM. The [overview](/api/routing-forms/overview) explains why. # List Routing Forms Source: https://developer.zeeg.me/api/routing-forms/list-routing-forms GET /routing-forms List the routing forms in a workspace with the Zeeg API, returning paginated results that carry each form's name, slug, public URL, and status. Returns a paginated list of the routing forms in your workspace, newest first. Each entry carries the routing form itself. The questions and the routes ship from [Get a Routing Form](/api/routing-forms/get-a-routing-form). `url` is the public address at which an invitee opens the routing form. `slug` is the last segment of that address. # Routing Forms Overview Source: https://developer.zeeg.me/api/routing-forms/overview Build and read routing forms and their submissions through the Zeeg API, and map every answer into your own CRM. A routing form asks an invitee a few questions and then sends that invitee to the right destination — a scheduling page, a team page, an external address, or a message. The Routing Forms API builds a routing form, reads its definition, and reads and deletes the submissions that invitees send through it. ## Scopes | Scope | What it allows | | - | - | | `routing_forms:read` | Read routing forms and their submissions | | `routing_forms:write` | Create, change and delete routing forms; delete submissions; also grants read access | Every read endpoint accepts either scope. Every write endpoint requires `routing_forms:write`. ## Build a routing form A routing form is built in three calls, because the questions and the routes replace as whole lists: 1. [Create a Routing Form](/api/routing-forms/create-a-routing-form) writes the routing form with its name and its page settings. The new routing form holds no question and one fallback route that shows the default thank-you page. 2. [Replace Routing Form Questions](/api/routing-forms/replace-routing-form-questions) writes the questions. The response carries the id of each question. 3. [Replace Routing Form Routes](/api/routing-forms/replace-routing-form-routes) writes the routes. A rule names its question by that id, in the `inputId` field. [Update a Routing Form](/api/routing-forms/update-a-routing-form) changes the settings afterwards. It is a partial update: a field that the body leaves out keeps its stored value. The two `PUT` endpoints replace the whole list. A question or a route that the body leaves out is deleted. Read the current list, change it, and send it back complete. ## The two resources ### Routing forms `GET /routing-forms` returns one entry per routing form. `GET /routing-forms/{id}` adds two lists to that entry: * `inputs` holds the questions. A question carries its `type`, its `options`, and the CRM attribute it maps onto. * `routes` holds the routes. A route carries its destination and the `rules` that select it. Exactly one route of a routing form is the fallback, which takes every submission that matches no other route. A condition of a rule names its question with `inputId` on both surfaces. The read endpoints return that key and the write endpoints accept it. ### Submissions The submissions of every routing form sit in one feed. `GET /routing-form-submissions` returns that feed, and `routingFormId` narrows it to one routing form. Filter further by creation time with `minCreatedAt` and `maxCreatedAt`, and by destination with `routeType`. ## Read the answers Each answer ships twice, and the two values serve different consumers: | Field | What it holds | Use it for | | - | - | - | | `answer` | The display value | Anything a person reads | | `rawAnswer` | The stored value | Mapping into a CRM | For a question that maps onto a CRM select attribute, `rawAnswer` is the canonical `{"id","value","color"}` JSON that identifies the option. The `id` in it is the value that your CRM needs. `answer` holds the label alone. Never show `rawAnswer` to a person. For a CRM select answer it is a JSON blob, not a label. `questionsAndAnswers` repeats the same answers as a flat map from question text to display value. It matches the map that the `routing_form.submitted` webhook ships, so one parser reads both surfaces. ## Identifiers Every id in this API is a UUID. A malformed id answers `404`, not `422`. An id that names a resource outside your workspace answers `404` as well, so the response never tells you that the resource exists. ## Timestamps Every timestamp in a response is in **ISO 8601** format with a UTC offset, for example `2026-04-10T08:30:00+00:00`. The `minCreatedAt` and `maxCreatedAt` filters accept the same format. # Replace Routing Form Questions Source: https://developer.zeeg.me/api/routing-forms/replace-routing-form-questions PUT /routing-forms/{id}/inputs Replace every question of a routing form with the Zeeg API. The write covers the whole list, so a question the body leaves out is deleted. Alpha This endpoint is in alpha: the request shape may still change without a deprecation cycle. Replaces every question of one routing form and returns the routing form in the shape that [Get a Routing Form](/api/routing-forms/get-a-routing-form) returns. ## The write covers the whole list `inputs` holds the complete question list. This endpoint is not an append and not a patch. * An entry that carries an `id` updates that stored question in place. * An entry without an `id` creates a question. * A stored question that the body leaves out is deleted. Read the questions first and send them back with your changes. Send the whole list every time. When a question is deleted, every route that read it drops the matching condition. A rule that ends up with no condition is cleared, which sends every submission to the fallback. Check the routes after you remove a question. `inputs` must be present. A body without it answers `422`, so a mistyped key never deletes every question. Array position sets the question order. The first entry carries order `0`. The `order` that the read endpoint returns is ignored on write. ## Question types `type` takes `TEXT_INPUT`, `TEXTAREA`, `SELECT`, `RADIO_INPUT`, `EMAIL`, `PHONE_NUMBER`, `NUMBER_INPUT` or `CRM_ATTRIBUTE`. A `SELECT` or a `RADIO_INPUT` question needs 1 to 20 `options`. Every other type ignores the field. A `CRM_ATTRIBUTE` question needs `crmAttribute`, which names the CRM object and the attribute that the answer writes to. Two questions of one routing form must not map onto the same attribute of the same object. [Get a CRM Object](/api/crm/get-a-crm-object) shows which attribute slugs a question maps onto. The id is a UUID. A malformed id answers `404`, not `422`. A routing form outside your workspace answers `404` as well. # Replace Routing Form Routes Source: https://developer.zeeg.me/api/routing-forms/replace-routing-form-routes PUT /routing-forms/{id}/routes Replace the routes of a routing form with the Zeeg API, sending each answer to a scheduling page, a team, a URL or a message, with an optional fallback. Alpha This endpoint is in alpha: the request shape may still change without a deprecation cycle. Replaces the routes of one routing form and returns the routing form in the shape that [Get a Routing Form](/api/routing-forms/get-a-routing-form) returns. ## The write covers the whole list `routes` holds the complete route list. This endpoint is not an append and not a patch. * An entry that carries an `id` updates that stored route in place. * An entry without an `id` creates a route. * A stored route that the body leaves out is deleted. Read the routes first and send them back with your changes. Send the whole list every time. `routes` must be present. A body without it answers `422`, so a mistyped key never deletes every route. Array position sets the route order. The routing form reads the routes in that order and takes the first route whose conditions hold. ## The fallback `fallback` is optional. It is the destination that takes every submission that matches no route. If the body names no `fallback`, the routing form keeps its stored fallback destination. Replacing the route list never moves where an unmatched submission goes. The fallback decides where every unmatched submission goes. A body that restates it slightly differently redirects that traffic, and nothing in the response says so. Leave `fallback` out unless you mean to change it. ## The rule tree A route carries `rules`, the condition tree that selects it. A group holds child nodes under `all` for AND, or under `any` for OR. A child is a group or a condition. ```json theme={null} { "all": [ { "inputId": "f7c82298-6dd9-43bd-98e4-c437b5c0ae47", "operator": "is_any_of", "values": ["50-200 employees"] } ] } ``` A condition names its question with `inputId`, the same key that the read endpoints return. The operator is one of `equals`, `does_not_equal`, `is_any_of`, `is_not_any_of`, `contains`, `does_not_contain`, `starts_with`, `ends_with`, `greater_than`, `greater_than_or_equal`, `less_than`, `less_than_or_equal`, `is_empty` or `is_not_empty`. `equals` and `does_not_equal` take exactly one value. The list operators take 1 to 500 values. The ordering operators take exactly one numeric value and match only a numeric answer. `is_empty` and `is_not_empty` take none. ## Destinations `type` takes `EVENT_TYPE`, `TEAM_PAGE`, `URL` or `CUSTOM_PAGE`. Each type reads its own fields: `eventTypeId`, `teamId`, `customUrl` with `submissionParamsForwarding`, or `headline` with `body`. A route points at an event type or a team of your own organization. A route that points anywhere else answers `400`, and the whole write is refused. A rule that names a question of another routing form answers `400` as well. The id is a UUID. A malformed id answers `404`, not `422`. A routing form outside your workspace answers `404` as well. # Update a Routing Form Source: https://developer.zeeg.me/api/routing-forms/update-a-routing-form PATCH /routing-forms/{id} Update the settings of a routing form with the Zeeg API. The update is partial, so every field the body leaves out keeps its stored value. Updates the settings of one routing form and returns it in the shape that [Get a Routing Form](/api/routing-forms/get-a-routing-form) returns. Every field is optional and takes the same values as [Create a Routing Form](/api/routing-forms/create-a-routing-form). ## The update is partial A field that the body leaves out keeps its stored value. A body that names `headline` alone changes the headline and nothing else. To clear `headline` or `body`, send the field with the value `null`. An omitted field is not a cleared field. `styleSettings` merges colour by colour. A body that names `bgColor` alone leaves the two button colours as they stand. ## The colours round-trip `styleSettings` is nested on both sides. The read endpoints return the object with its three keys, and this endpoint accepts that same object, so a routing form that you read goes straight back here without reshaping. A routing form that sets no colour returns the three keys as `null` rather than omitting the object. The dashboard's own internal API flattens the same three colours to the top level. That surface is not this one. Send the nested `styleSettings` object here. The questions and the routes have their own endpoints: [Replace Routing Form Questions](/api/routing-forms/replace-routing-form-questions) and [Replace Routing Form Routes](/api/routing-forms/replace-routing-form-routes). This endpoint changes neither. The id is a UUID. A malformed id answers `404`, not `422`. A routing form outside your workspace answers `404` as well. # Cancel a Scheduled Event Source: https://developer.zeeg.me/api/scheduled-events/cancel-a-scheduled-event PUT /scheduled-events/{uuid}/cancel Cancel a scheduled Zeeg event by UUID. Notifies all invitees and hosts and marks the event as cancelled in the system. # Cancel a Scheduled Invitee Source: https://developer.zeeg.me/api/scheduled-events/cancel-a-scheduled-invitee PUT /scheduled-invitees/{uuid}/cancel Cancel a single invitee on a scheduled event without cancelling the event itself. Useful for group bookings where one attendee drops out. # Get a Scheduled Event Source: https://developer.zeeg.me/api/scheduled-events/get-a-scheduled-event GET /scheduled-events/{uuid} Retrieve full details of a single scheduled event by UUID, including invitees, hosts, location, status, and custom answers. # Get Reschedule Availability Source: https://developer.zeeg.me/api/scheduled-events/get-reschedule-availability GET /scheduled-events/{uuid}/reschedule-availability List the slots a specific booking can be moved to. Call this before rescheduling, and reschedule to one of the slots it returns. # Handover a Scheduled Event Source: https://developer.zeeg.me/api/scheduled-events/handover-a-scheduled-event PUT /scheduled-events/{uuid}/handover Reassign a scheduled Round Robin event from its current host to another eligible host in your workspace. # List Scheduled Events Source: https://developer.zeeg.me/api/scheduled-events/list-scheduled-events GET /scheduled-events Retrieve a paginated list of scheduled events in your Zeeg workspace, with filters for status, date range, host, team, and invitee. # Reschedule a Scheduled Event Source: https://developer.zeeg.me/api/scheduled-events/reschedule-a-scheduled-event PUT /scheduled-events/{uuid}/reschedule Move a booking to a new date and time. The invitee and the host receive a reschedule notice, and the response names the new identifiers. # Create an Event Source: https://developer.zeeg.me/api/scheduling-pages/create-an-event POST /event Programmatically book a scheduled event on a Zeeg scheduling page by providing an invitee, a chosen time slot, and any required custom answers. # Get a Scheduling Page Source: https://developer.zeeg.me/api/scheduling-pages/get-a-scheduling-page GET /event-types/{uuid} Retrieve a single scheduling page by UUID, including its profile, custom invitee questions, duration, and configuration. # Get a Single-Use Scheduling Link Source: https://developer.zeeg.me/api/scheduling-pages/get-a-single-use-scheduling-link GET /event-types/{uuid}/single-use-link Generate a one-time-use scheduling link for a scheduling page so that only one invitee can book before the link expires. # Get Available Time Slots Source: https://developer.zeeg.me/api/scheduling-pages/get-available-time-slots GET /availability/{ownerSlug}/event-types/{eventTypeSlug} Query bookable time slots for any scheduling page, personal, team or shared. Used as the first step of a programmatic booking flow. # Get Available Time Slots by ID Source: https://developer.zeeg.me/api/scheduling-pages/get-available-time-slots-by-id GET /event-types/{uuid}/availability Query bookable time slots for a scheduling page by the id the list endpoint returns, with no owner slug to work out. # List Scheduling Pages Source: https://developer.zeeg.me/api/scheduling-pages/list-scheduling-pages GET /event-types Retrieve a paginated list of scheduling pages (event types) in your workspace, including title, slug, duration, and active status. # Toggle Scheduling Page Status Source: https://developer.zeeg.me/api/scheduling-pages/toggle-scheduling-page-status PUT /event-types/{uuid}/status Activate or deactivate a scheduling page so it stops or resumes accepting new bookings without deleting its configuration. # AI Agent Call Webhook Payload Source: https://developer.zeeg.me/api/webhook-payloads/ai-agent-call-webhook-payload Schema reference for the webhook payload Zeeg sends when an AI agent call completes, including the outcome, transcript summary, collected data, actions, and any booking made. Alpha This payload is delivered to your webhook callback URL when an `ai_agent.call_completed` event occurs. It fires once when an AI agent call reaches a terminal state. This webhook is in alpha: the payload shape may still change without a deprecation cycle. ## Event Info Always `"ai_agent.call_completed"`. Unique identifier for this call. Use this as the dedup key for idempotent processing — in rare cases the same call may be delivered more than once. A delivery that fails is not retried. ## Agent UUID of the AI agent that handled the call. Display name of the AI agent. ## Call `"inbound"` (caller reached the agent) or `"outbound"` (agent dialled out). Terminal outcome of the call attempt: `"answered"`, `"voicemail"`, `"not_answered"`, `"busy"`, or `"unknown"`. `true` when the interaction was a real phone call; `false` for non-call interactions such as chat. ISO 8601 UTC timestamp when the call started. ISO 8601 UTC timestamp when the call ended. Total call duration in seconds. The external party's (contact/lead) phone number in E.164 format, regardless of call direction. `null` for test/widget calls or blocked caller ID. The contact's name. It is the name the caller stated on this call, else the full name sent to the outbound call API, else the name of the matched CRM contact. `null` when none is known. The contact's email address. It is the address the caller stated on this call, else the email sent to the outbound call API, else the email of the matched CRM contact. `null` when none is known. The agent's phone line that the call used, in E.164 format. If the call never connected, this is the agent's current provisioned number. `null` when neither is known. The agent's primary language code, for example `de` or `en`. The language code the conversation was held in, as detected on the call, for example `de`. It can differ from `agentLanguage` on a multilingual agent. `null` when no language was detected. ## Transcript Ordered conversation turns. Tool-only turns with no spoken message are omitted — the actions the agent took are in `actions`. Who spoke the turn. Example: `agent`, `user`. The spoken text of the turn. Offset in seconds from the start of the call when the turn occurred. ## Conversation Analysis AI-generated analysis of the conversation. AI-generated narrative summary of the transcript. Map of collected field id to its `{ value, type }` entry. Keys are the data the agent collects — e.g. `name`, `email`, `phone`, `message`, `summary`, plus any custom fields. Keys depend on your agent's configuration. The value the agent collected for this field. The field's data type (e.g. `string`). `null` when unknown. ## System notes Notes Zeeg adds about the call, beside what the agent collected. An empty array when there is nothing to note. New notes can be added at any time, so ignore a `code` you do not recognize rather than failing on it. Stable identifier to branch on. `null` for a note that is informational text only. * `caller_phone_not_dialable` — the callback number the caller stated is not a dialable phone number. `conversationAnalysis.collectedData.phone` holds it exactly as the caller gave it. English, human-readable description of the note. The wording can change; branch on `code`. ## Actions Ordered list of actions the agent executed during the call. Action identifier. Example: `book_event`, `send_email`, `send_sms`. UUID of the route configuration that drove the action, if applicable. `null` otherwise. ISO 8601 timestamp when this action was executed. ## Booking The scheduled event created during the call, in the same shape as the `GET /v2/scheduled-events/{uuid}` resource; `null` if no booking was made. Correlate `booking.uuid` with `invitee.scheduled` webhook events for the same booking. Public API URI of the scheduled event resource. Unique identifier of the scheduled event. Use this to correlate with `invitee.scheduled` webhook events. Title of the scheduled event. Event type kind. Example: `ONE_ON_ONE`, `GROUP`, `ROUND_ROBIN`. ISO 8601 UTC start time of the booked event. ISO 8601 UTC end time of the booked event. Event duration in minutes. Booking status. Example: `confirmed`, `cancelled`. Public API URI of the event type this booking was made against. Meeting location for the booking. Location type. Example: `google_meet`, `zoom`, `phone`, `in_person`. Join URL for the meeting, when applicable. Maximum number of active invitees the event allows. Current number of active (non-cancelled) invitees. Invitees booked on the scheduled event. Unique identifier of the invitee. Salutation for the invitee, if collected. Full name of the invitee. Email address of the invitee. Additional guest email addresses for this invitee. IANA time zone of the invitee. Cancellation details. All fields are `null` when the invitee is not cancelled. ISO 8601 UTC timestamp when the invitee was cancelled. Identifier of who cancelled the booking. Type of canceller (e.g. host, invitee). Reason given for the cancellation. Payment details for the booking, or `null` when no payment applies. Booking questions answered by the invitee. ISO 8601 UTC timestamp when the invitee booked. UTM tracking parameters captured at booking time. Holds all five keys (`utm_campaign`, `utm_source`, `utm_medium`, `utm_term`, `utm_content`); a key holds `null` when the booking did not carry a value for it. Custom query parameters captured at booking time. An empty object when the booking captured none. Reference code for bookings made via the AI agent; `null` otherwise. Event-level guest email addresses. Hosts assigned to the scheduled event. Host's first name. Host's last name. Host's email address. Host's public profile slug. Host's public profile URL. URL of the host's avatar image, if set. Name of the team that owns the scheduling page the booking was made on, `null` for a personal scheduling page. ISO 8601 UTC timestamp when the booking was created. ISO 8601 UTC timestamp when the booking was last updated. ISO 8601 UTC server time when the resource was rendered. ## CRM UUID of the CRM person record linked to this call, if matched. ## Outbound Call What you sent to the outbound-call API when you started this call. `null` for an inbound call and for an outbound call that the outbound-call API did not start. The `id` that the outbound-call API returned. The contact name you sent. The contact email you sent. The additional context you sent for the agent. ISO 8601 UTC time the call was scheduled for. ## Full Payload Example ```json theme={null} { "event": "ai_agent.call_completed", "conversationId": "abc1234-0000-0000-0000-xyz987654321", "agentId": "9a39bf60-a6c3-45e7-80cd-2cd36e520861", "agentName": "Sales Agent", "direction": "inbound", "callOutcome": "answered", "isCall": true, "startedAt": "2026-06-01T10:00:00+00:00", "endedAt": "2026-06-01T10:01:30+00:00", "durationSeconds": 90, "contactPhoneNumber": "+4917612345678", "contactName": "Alice Example", "contactEmail": "alice@example.com", "agentPhoneNumber": "+4989123456789", "agentLanguage": "de", "conversationLanguage": "de", "transcript": [ { "role": "agent", "message": "Hello, thank you for calling. How can I help you today?", "timeInCallSeconds": 0 }, { "role": "user", "message": "Hi, I'd like to know more about your pricing.", "timeInCallSeconds": 4 }, { "role": "agent", "message": "Of course. Our plans start at 12 euros per month. Shall I book a follow-up call?", "timeInCallSeconds": 9 } ], "conversationAnalysis": { "transcriptSummary": "The caller asked about product pricing and requested a follow-up call next week.", "collectedData": { "name": { "value": "Alice Müller", "type": "string" }, "email": { "value": "alice@example.com", "type": "string" }, "message": { "value": "Please send me the pricing sheet.", "type": "string" }, "summary": { "value": "Caller asked about pricing and wants a follow-up.", "type": "string" } } }, "systemNotes": [], "actions": [ { "type": "book_event", "routeId": null, "calledAt": "2026-06-01T10:00:45+00:00" }, { "type": "send_email", "routeId": "9b1c2d3e-4f56-7890-abcd-ef0123456789", "calledAt": "2026-06-01T10:01:20+00:00" } ], "booking": { "uri": "https://api.zeeg.me/v2/scheduled-events/zg-O69bd55e004c8b", "uuid": "zg-O69bd55e004c8b", "title": "30-Minute Meeting with Sales Agent", "type": "ONE_ON_ONE", "startTime": "2026-06-03T14:00:00.000000Z", "endTime": "2026-06-03T14:30:00.000000Z", "duration": 30, "status": "confirmed", "eventTypeUri": "https://api.zeeg.me/v2/event-types/80f46bf5-eb01-4c07-960e-a9a3e18aae5e", "location": { "type": "google_meet", "joinUrl": "https://meet.google.com/abc-defg-hij" }, "maxActiveInvitees": 1, "activeInviteesCount": 1, "invitees": [ { "uuid": "zg-A12cd34ef005a9c", "salutation": null, "fullName": "Alice Müller", "email": "alice@example.com", "guests": [], "timeZone": "Europe/Berlin", "cancellation": { "cancelledAt": null, "cancelledBy": null, "cancellerType": null, "cancellationReason": null }, "payment": null, "questions": [], "scheduledAt": "2026-06-01T10:00:50.000000Z", "utm": { "utm_campaign": null, "utm_source": null, "utm_medium": null, "utm_content": null, "utm_term": null }, "customQueryParams": {}, "agentBookingReference": "123456" } ], "guests": [], "hosts": [ { "firstName": "Lena", "lastName": "Meier", "email": "lena.meier@example.com", "slug": "lena-meier", "url": "https://zeeg.me/lena-meier", "avatarUrl": null } ], "teamName": null, "createdAt": "2026-06-01T10:00:50.000000Z", "updatedAt": "2026-06-01T10:00:50.000000Z", "currentTime": "2026-06-01T10:01:30.000000Z" }, "crmPersonId": "7f3e2a91-1234-4abc-9012-b3c456d78901", "outboundCall": null } ``` # Event Webhook Payload Source: https://developer.zeeg.me/api/webhook-payloads/event-webhook-payload Schema reference for the webhook payload Zeeg sends to your callback URL when an invitee schedules, reschedules, or cancels an event. This payload is delivered to your webhook callback URL when an `invitee.scheduled`, `invitee.cancelled`, `invitee.withdrawn` or `invitee.no_show` event occurs. All four event types share the same structure -- cancellation, withdrawal, no-show and rescheduling fields are only populated when relevant. Store `inviteeUuid` (and `eventUuid`) on your side. Invitee-related UUIDs are the best identifiers for correlating bookings, cancellations, and reschedules across webhook deliveries, and they ensure future compatibility -- especially for Group events. ## Event Info Event type that triggered this webhook. One of `invitee.scheduled`, `invitee.cancelled`, `invitee.withdrawn` or `invitee.no_show`. Full API URI for the scheduled event. Example: `https://api.zeeg.me/v2/scheduled-events/zg-O69bac566950c6`. Zeeg event ID in `zg-XXX` format (e.g., `zg-O69bac566950c6`). Use this to uniquely identify the event. Title of the scheduling page / event type (e.g., "30-Minute Discovery Call"). Event type category. One of `ONE_ON_ONE`, `GROUP`, `COLLECTIVE`, `ROUND_ROBIN`, or `FLEXI`. Event duration in minutes. Human-readable duration (e.g., `"30 mins"`, `"1 hr 30 mins"`). ## Invitee Info Invitee's salutation (e.g., `"Ms."`, `"Mr."`). `null` if not provided. Invitee's first name. `null` if not provided. Invitee's last name. `null` if not provided. Invitee's full display name. Invitee's email address. Invitee's phone number. `null` if not collected. Invitee's IANA timezone (e.g., `"Europe/Paris"`). Number of seats booked. `1` for standard bookings, higher for Group events. Zeeg invitee ID in `zg-XXX` format. Use this to track a specific booking across schedule, cancel, and reschedule events. **Recommended for storage and correlation.** ## Timing Event start time in UTC (ISO 8601). Event end time in UTC (ISO 8601). Event start time in the invitee's local timezone (ISO 8601 with offset). Event end time in the invitee's local timezone (ISO 8601 with offset). Human-readable start time in the invitee's timezone. Format: `HH:mm - Day, Month DD, YYYY` (e.g., `"11:00 - Wednesday, April 15, 2026"`). Human-readable end time in the invitee's timezone. Same format as `inviteeStartAtPretty`. ## Host Info Map of 1-based host positions to host full names (e.g., `{"1": "Lena Meier"}`). Array of host detail objects. The host's URL slug. The host's first name. The host's last name. The host's email address. The host's full name. Team name if the event belongs to a team, otherwise `null`. ## Location Location type or name (e.g., `"Google Meet"`, `"Zoom"`, a physical address). `null` if no location is set. Join URL for virtual meetings. `null` for non-virtual locations or if not applicable. ## Questions Array of question/answer objects from the invitee's booking form. The question text. The invitee's answer. Flat key-value map of questions to answers (question text as key). Convenient for quick lookups. ## Scheduling Page API URI for the event type. Example: `https://api.zeeg.me/v2/event-types/80f46bf5-eb01-4c07-960e-a9a3e18aae5e`. Single-use scheduling link used for this booking. `null` if a standard scheduling page was used. ## Guests Array of additional guest email addresses added by the invitee. Empty array if no guests were added. ## Payment Total price charged for the booking. `null` if the event is free. Price per seat. Equal to `price` for single-seat bookings. For multi-seat bookings, this is `price / inviteeNumberOfSeats`. `null` if the event is free. Currency code (e.g., `"EUR"`, `"USD"`). `null` if the event is free. Payment provider used (e.g., `"stripe"`, `"paypal"`). `null` if the event is free. Payment provider's transaction ID. `null` if the event is free. ## Links URL the invitee can use to cancel the event. `null` if the event is already cancelled. URL the invitee can use to reschedule the event. `null` if the event is already cancelled. ## Cancellation These fields are populated when the event is `invitee.cancelled`. `true` if the event has been cancelled, `false` otherwise. Cancellation timestamp in UTC (ISO 8601). `null` if not cancelled. Name of the person who cancelled the event. `null` if not cancelled. Reason provided for the cancellation. `null` if not cancelled or no reason was given. ## No-show `true` once a host has marked the invitee as a no-show, `false` otherwise. Present on every delivery. Timestamp the invitee was marked as a no-show, in UTC (ISO 8601). `null` when they were not marked. ## Withdrawal Present on every delivery for a booking whose event type grants a statutory right of withdrawal (§ 356a BGB), so an ordinary cancellation can be told apart from a withdrawal without subscribing to `invitee.withdrawn`. `null` for every other booking. `true` once the invitee has exercised the right, `false` while it is still open or has lapsed. Timestamp the withdrawal was received, in UTC (ISO 8601). This is the statutory receipt time. `null` until withdrawn. End of the withdrawal period in UTC (ISO 8601), fixed when the booking was concluded. Total paid across the booking's reschedule chain, in **minor units** (e.g. `2000` for EUR 20.00). This is the amount the provider must repay under § 357 BGB. ISO 4217 currency of `amountPaid`. ## Rescheduling These fields are populated when a booking has been rescheduled. On a reschedule, the old event receives `invitee.cancelled` and the new event receives `invitee.scheduled`, both with rescheduling context. The `old*` fields appear on the **new** event's payload (referencing the event it replaced). The `new*` fields appear on the **old** event's payload (referencing the event that replaced it). `true` if this event was rescheduled (i.e., a newer event replaced it), `false` otherwise. Rescheduling timestamp in UTC (ISO 8601). Name of the person who initiated the reschedule. Reason provided for rescheduling. API URI of the previous (old) event before rescheduling. UUID of the previous (old) event. Invitee UUID from the previous (old) event. Start time (UTC, ISO 8601) of the previous event. Start time of the previous event in the invitee's timezone (human-readable format). API URI of the replacement (new) event after rescheduling. UUID of the replacement (new) event. Invitee UUID for the replacement (new) event. Start time (UTC, ISO 8601) of the replacement event. Start time of the replacement event in the invitee's timezone (human-readable format). ## UTM Parameters These fields capture UTM tracking parameters if they were present on the scheduling page URL when the invitee booked. The `utm_campaign` value. The `utm_source` value. The `utm_medium` value. The `utm_term` value. The `utm_content` value. Ad click identifiers captured at booking, or `null` when none were present. Keys: `gclid`, `gbraid`, `wbraid`, `fbclid`, `fbp`, `fbc`, `landingUrl` (all `string`, only the captured keys are present). ## Metadata Timestamp (UTC, ISO 8601) of when the booking was created. ## Full Payload Example ```json theme={null} { "event": "invitee.scheduled", "eventUri": "https://api.zeeg.me/v2/scheduled-events/zg-O69bac566950c6", "inviteeSalutation": "Ms.", "inviteeFirstName": "Sophie", "inviteeLastName": "Laurent", "inviteeName": "Sophie Laurent", "inviteeEmail": "sophie.laurent@northwind.io", "inviteePhoneNumber": "+49 170 9876543", "inviteeTimezone": "Europe/Paris", "inviteeNumberOfSeats": 1, "teamName": null, "hosts": { "1": "Lena Meier" }, "hostsDetails": [ { "slug": "lena-meier", "firstName": "Lena", "lastName": "Meier", "email": "lena.meier@horizondigital.de", "fullName": "Lena Meier" } ], "title": "30-Minute Discovery Call", "duration": 30, "durationPretty": "30 mins", "type": "ONE_ON_ONE", "eventTypeUri": "https://api.zeeg.me/v2/event-types/80f46bf5-eb01-4c07-960e-a9a3e18aae5e", "singleUseLink": null, "startAt": "2026-04-15T09:00:00+00:00", "inviteeStartAt": "2026-04-15T11:00:00+02:00", "inviteeStartAtPretty": "11:00 - Wednesday, April 15, 2026", "endAt": "2026-04-15T09:30:00+00:00", "inviteeEndAt": "2026-04-15T11:30:00+02:00", "inviteeEndAtPretty": "11:30 - Wednesday, April 15, 2026", "location": "Google Meet", "locationLink": "https://meet.google.com/abc-defg-hij", "questions": [ { "question": "What would you like to discuss?", "answer": "Product demo and pricing options" } ], "questionsAndAnswers": { "What would you like to discuss?": "Product demo and pricing options" }, "price": null, "pricePerSeat": null, "currency": null, "paymentGateway": null, "transactionId": null, "guests": ["alex.chen@northwind.io"], "eventUuid": "zg-O69bac566950c6", "inviteeUuid": "zg-O69bad4047abf0", "cancelUrl": "https://zeeg.me/cancel/zg-O69bad4047abf0", "rescheduleUrl": "https://zeeg.me/rescheduling/zg-O69bad4047abf0", "cancelled": false, "cancelledAt": null, "cancelledBy": null, "cancellationReason": null, "rescheduled": false, "rescheduledAt": null, "rescheduledBy": null, "rescheduleReason": null, "oldEventUri": null, "oldEventUuid": null, "oldInviteeUuid": null, "oldStartAt": null, "oldInviteeStartAt": null, "newEventUri": null, "newEventUuid": null, "newInviteeUuid": null, "newStartAt": null, "newInviteeStartAt": null, "utmCampaign": "spring_launch", "utmSource": "linkedin", "utmMedium": "social", "utmTerm": null, "utmContent": null, "adAttribution": { "gclid": "Cj0KCQjw_ndBhCrARIsAAy", "fbclid": "IwAR2xq9Zt", "landingUrl": "https://zeeg.me/lena-meier/30min?gclid=Cj0KCQjw_ndBhCrARIsAAy" }, "withdrawal": { "withdrawn": false, "withdrawnAt": null, "deadline": "2026-04-24T21:59:59+00:00", "amountPaid": 2000, "currency": "EUR" }, "noShow": false, "noShowAt": null, "createdAt": "2026-04-10T08:30:00+00:00" } ``` # Routing Form Webhook Payload Source: https://developer.zeeg.me/api/webhook-payloads/routing-form-webhook-payload Schema reference for the webhook payload Zeeg sends when a routing form is submitted, including answers and the routed scheduling page. This payload is delivered to your webhook callback URL when a `routing_form.submitted` event occurs. ## Event Info Always `"routing_form.submitted"`. Unique identifier for this specific form submission. ## Routing Form UUID of the routing form. Display name of the routing form. URL slug of the routing form. Full public URL of the routing form. ## Routing How the visitor was routed after submission. Example: `"EVENT_TYPE"`. Custom redirect URL when the route type is a custom URL. `null` otherwise. Custom headline shown to the visitor after submission, if configured. `null` otherwise. `true` if the visitor was routed via the fallback (default) route, `false` if a specific routing rule matched. ## Event Type The event type the visitor was routed to. `null` if the route does not lead to an event type. The event type ID. The event type title. The event type URL slug. Full API URI for the event type. Example: `https://api.zeeg.me/v2/event-types/80f46bf5-eb01-4c07-960e-a9a3e18aae5e`. ## Answers Ordered array of the visitor's responses to the routing form questions. The question text. The visitor's answer. ID of the routing form input field. Display order of the question in the form. Flat key-value map of questions to answers (question text as key). Convenient for quick lookups. ## UTM Parameters These fields capture UTM tracking parameters if they were present on the routing form URL when the visitor submitted. The `utm_campaign` value. The `utm_source` value. The `utm_medium` value. The `utm_term` value. The `utm_content` value. Ad click identifiers captured at submission, or `null` when none were present. Keys: `gclid`, `gbraid`, `wbraid`, `fbclid`, `fbp`, `fbc`, `landingUrl` (all `string`, only the captured keys are present). ## Metadata Submission timestamp in UTC (ISO 8601). ## Full Payload Example ```json theme={null} { "event": "routing_form.submitted", "reportId": "71621fb7-30b0-4d91-9f2e-a3e009cc6853", "routingFormId": "f1cbafc4-b646-4cf9-af29-193491b555d9", "routingFormName": "Inbound Lead Qualification", "routingFormSlug": "inbound-lead-qual", "routingFormUrl": "https://zeeg.me/RF/inbound-lead-qual", "routeType": "EVENT_TYPE", "routeCustomUrl": null, "headline": null, "isFallbackRoute": false, "eventType": { "id": "80f46bf5-eb01-4c07-960e-a9a3e18aae5e", "title": "30-Minute Discovery Call", "slug": "30min-discovery-call", "uri": "https://api.zeeg.me/v2/event-types/80f46bf5-eb01-4c07-960e-a9a3e18aae5e" }, "answers": [ { "question": "What is your company size?", "answer": "50-200 employees", "inputId": "f7c82298-6dd9-43bd-98e4-c437b5c0ae47", "order": 1 } ], "questionsAndAnswers": { "What is your company size?": "50-200 employees" }, "utmCampaign": "spring_launch", "utmSource": "linkedin", "utmMedium": null, "utmTerm": null, "utmContent": null, "adAttribution": { "gclid": "Cj0KCQjw_ndBhCrARIsAAy", "fbclid": "IwAR2xq9Zt", "landingUrl": "https://zeeg.me/RF/company-size?gclid=Cj0KCQjw_ndBhCrARIsAAy" }, "createdAt": "2026-04-10T08:30:00+00:00" } ``` # Create Webhook Subscription Source: https://developer.zeeg.me/api/webhooks/create-webhook-subscription POST /webhooks Register a webhook callback URL to receive real-time notifications for selected Zeeg events such as bookings, cancellations, and reschedules. # Delete Webhook Subscription Source: https://developer.zeeg.me/api/webhooks/delete-webhook-subscription DELETE /webhooks/{uuid} Permanently delete a webhook subscription by UUID. Zeeg will stop sending event notifications to its callback URL. # Get Webhook Subscription Source: https://developer.zeeg.me/api/webhooks/get-webhook-subscription GET /webhooks/{uuid} Retrieve a single webhook subscription by UUID, including the callback URL, scope, and the event types it is subscribed to. # List Webhook Subscriptions Source: https://developer.zeeg.me/api/webhooks/list-webhook-subscriptions GET /webhooks/scope/{scope} List all webhook subscriptions for a given scope (user, team, or organization), including their callback URLs and event types. # Add a Team Member Source: https://developer.zeeg.me/api/workspaces-teams/add-a-team-member POST /teams/{teamId}/members Add an existing workspace user to a team. Address the team by its UUID or its slug. Useful for automating onboarding flows and team membership sync. # Check Logged-in User Source: https://developer.zeeg.me/api/workspaces-teams/check-logged-in-user GET /whoami Verify your API token and identify the authenticated user. Use this as the first call to confirm credentials are valid. # List Users in a Workspace Source: https://developer.zeeg.me/api/workspaces-teams/list-users-in-a-workspace GET /organizations/users List all users in your Zeeg workspace, including their roles, slugs, and email addresses. Supports per_page pagination. # Authentication Source: https://developer.zeeg.me/authentication Authenticate Zeeg API requests with Bearer tokens. Learn how to generate API keys, scope them to specific permissions, and keep your credentials secure. All Zeeg API endpoints require authentication via a Bearer token sent in the `Authorization` header. ## Generating a Token Create an API token from your Zeeg dashboard: **[Account Settings > API](https://app.zeeg.me/account/settings/api-access)** When creating a token, you select the specific scopes it should have access to. Follow the principle of least privilege — only grant the scopes your integration actually needs. ## Making Authenticated Requests Include your token in the `Authorization` header of every request. Every response is JSON, whatever `Accept` header you send. ```bash cURL theme={null} curl -X GET https://api.zeeg.me/v2/scheduled-events \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Accept: application/json" ``` ```python Python theme={null} import requests response = requests.get( "https://api.zeeg.me/v2/scheduled-events", headers={ "Authorization": "Bearer YOUR_TOKEN", "Accept": "application/json", }, ) ``` ```javascript JavaScript theme={null} const response = await fetch("https://api.zeeg.me/v2/scheduled-events", { headers: { Authorization: "Bearer YOUR_TOKEN", Accept: "application/json", }, }); ``` If the token is missing or invalid, the API returns a `401 Unauthorized` response. ## Token Scopes Each token is scoped to specific permissions. The table below lists all available scopes and the endpoints they unlock. | Scope | Description | Endpoints | | - | - | - | | `events:read` | Read scheduled events | List events, get event details, get reschedule availability | | `events:write` | Change scheduled events | Cancel event, cancel invitee, hand over event, reschedule event | | `event_types:read` | Read scheduling pages | List scheduling pages, get scheduling page, get single-use link | | `event_types:write` | Change scheduling pages | Toggle scheduling page status, create single-use link | | `schedules:read` | Read availability | List schedules, get schedule by UUID, get default schedule, list time-off periods, list holiday categories and subscriptions | | `schedules:write` | Change availability | Update schedules, create, update, and delete time-off periods, manage holiday subscriptions | | `timetable` | Access availability and time slot data | Get available time slots | | `booking` | Create events programmatically | Create an event via the API | | `webhooks:read` | Read webhook subscriptions | List webhooks, get webhook details | | `webhooks:write` | Create, test, and delete webhooks | Create webhook, test webhook, delete webhook | | `users:read` | List workspace users | List users in a workspace | | `teams:write` | Add team members | Add a team member to a workspace | | `outbound-calls:write` | Trigger AI agent calls | Create outbound call | | `agents:read` | View AI agents, their calls, transcripts and collected data | List AI agents, list an agent's calls, get a call with its transcript and collected data | | `notes:read` | Read notes on scheduled events | List notes by event, get note details | | `notes:write` | Create, update, and delete notes | Create note, update note, delete note | | `routing_forms:read` | Read routing forms | List and get routing forms, list and get submissions | | `routing_forms:write` | Change routing forms | Create, update, and delete routing forms, delete submissions | | `crm:read` | Read CRM data | List and get objects, people, companies, and records | | `crm:write` | Change CRM data | Create, update, upsert, and delete objects, attributes, people, companies, and records | | `admin:full` | Full organization access | Required for org-wide operations (e.g., listing all workspace events) | The `timetable` and `booking` scopes require a paid Zeeg subscription. If your plan does not include API access to availability slots or programmatic booking, requests using these scopes will be rejected. ## Scope Requirements by Endpoint Group | Endpoint Group | Required Scope | | - | - | | Scheduled Events (read, reschedule availability) | `events:read` | | Scheduled Events (cancel/handover/reschedule) | `events:write` | | Scheduling Pages (list/get) | `event_types:read` | | Scheduling Pages (toggle status, single-use link) | `event_types:write` | | Available Time Slots | `timetable` | | Create an Event | `booking` | | Availability, Time-Off, Holidays (read) | `schedules:read` | | Availability, Time-Off, Holidays (change) | `schedules:write` | | Webhooks (read) | `webhooks:read` | | Webhooks (create/delete) | `webhooks:write` | | Workspace Users | `users:read` | | Add Team Member | `teams:write` | | AI Agent Outbound Call | `outbound-calls:write` | | AI Agents (read) | `agents:read` | | Notes (read) | `notes:read` | | Notes (create/update/delete) | `notes:write` | | Routing Forms and Submissions (read) | `routing_forms:read` | | Routing Forms and Submissions (change) | `routing_forms:write` | | CRM (read) | `crm:read` | | CRM (change) | `crm:write` | | Organization-wide Operations | `admin:full` | **Keep your API tokens secure.** Treat them like passwords. Do not commit tokens to version control, expose them in client-side code, or share them in plain text. If a token is compromised, revoke it immediately from the [API settings page](https://app.zeeg.me/account/settings/api-access) and generate a new one. # Zeeg CLI Source: https://developer.zeeg.me/cli Install the zeeg command-line tool, sign in, and script the Zeeg API from your terminal or CI pipelines. `zeeg` works with your Zeeg account from the terminal: event types, available times, bookings, notes, CRM records, routing forms and AI phone calls. It calls the public [Zeeg API v2](/authentication), so it can do exactly what an API token can do, with your own dashboard permissions. The CLI needs a plan that includes API access. A free plan cannot sign in. Finding available times (`zeeg availability`) and booking meetings (`zeeg bookings create`) need a **paid** plan; a **trial** can use everything else. If your API access comes from AI minutes alone, the CLI can only run `zeeg agents` and `zeeg calls start`. ## Install Node.js 20 or later: ```bash theme={null} npm install -g @zeegme/cli zeeg --help ``` ## Log in ```bash theme={null} zeeg login ``` The CLI prints a one-time code and opens `https://api.zeeg.me/oauth/device` in your browser. Sign in to Zeeg if you're not signed in already, check that the code on the page matches the one in your terminal, and allow access. On a machine without a browser, run `zeeg login --no-browser` and open the printed link anywhere. Only approve a code you started yourself. Anyone holding a code you approve gets access to your account. * `zeeg whoami` shows the account, the API origin, and the scopes the login carries. * `zeeg logout` revokes the login on Zeeg's side and removes it from this machine. The access token lasts an hour and renews itself; a login you don't use for 30 days ends, and `zeeg login` starts a new one. ### Where the login is stored In the system keychain: Keychain on macOS, Credential Manager on Windows, and the Secret Service (GNOME Keyring, KWallet) on Linux. A machine without one, such as a server you reach over SSH, keeps it in `~/.config/zeeg/credentials.json` (or under `$XDG_CONFIG_HOME`), readable only by you; the CLI says so the first time. Set `ZEEG_TOKEN_STORE=file` to use the file everywhere. ### Other environments `--base-url` or `ZEEG_API_URL` points the CLI at another API origin, for example a local backend: ```bash theme={null} ZEEG_API_URL=http://localhost:8000 zeeg login ``` Plain `http` is accepted for `localhost`, `127.0.0.1` and `[::1]` only — every other origin needs `https`. Each origin keeps its own login. ## Commands | Command | What it does | | - | - | | `zeeg event-types list` / `get ` | Your event types (scheduling pages) | | `zeeg availability [--from --to --tz --duration]` | Open start times, by day | | `zeeg schedules list [--email]` | Availability schedules | | `zeeg time-off add --start --end --title [--type --note --email]` | Block days as time off | | `zeeg bookings list [--status --from --to --invitee-email --host-email --keyword --all --desc]` | Bookings you can see | | `zeeg bookings get ` | One booking | | `zeeg bookings create --date --time --name --email` | Book a meeting for an invitee | | `zeeg bookings cancel [--reason]` | Cancel a booking | | `zeeg bookings reschedule --date --time` | Move a booking | | `zeeg bookings hand-over [--to]` | Hand a round-robin booking to another host | | `zeeg bookings no-show [--undo]` | Mark an invitee as a no-show, or undo it | | `zeeg notes list ` / `add ` | Notes on a booking | | `zeeg crm list ` | CRM records, paginated (`--search ` filters by name/email/domain) | | `zeeg crm get ` | One CRM record | | `zeeg crm upsert --match --data ` | Create a record, or update the one that matches | | `zeeg crm update --data ` | Update many records by `id`, up to 50 per request | | `zeeg forms list` / `submissions [--form]` | Routing forms and their submissions | | `zeeg agents list` | Your AI phone agents | | `zeeg agents calls [--from --to --direction]` | One agent's calls, newest first | | `zeeg agents call ` | One call, with its transcript and collected data | | `zeeg calls start --phone [--name --email --data --at]` | Have an AI phone agent call someone | `zeeg crm update` takes a JSON array of `{ "id": …, …fields }` objects, inline or as `@file.json`, and sends more than 50 records as several requests. It prints one line per record and exits with code 3 when any record was not updated; the others are still saved. If a request fails, the records it never sent show as `not sent`. `zeeg --help` lists every option. Times are in your machine's time zone unless you pass `--tz`. Commands that reach other people or spend AI minutes (`bookings create`, `cancel`, `reschedule`, `hand-over`, `no-show`, `calls start`) ask before they act. Pass `-y` / `--yes` to skip the question, which scripts must do: without a terminal to ask in, they stop instead. ```bash theme={null} zeeg availability 5c7e… --from 2026-10-05 --to 2026-10-09 --tz Europe/Berlin zeeg bookings create 5c7e… --date 2026-10-06 --time 14:30 --tz Europe/Berlin \ --name "Sophie Laurent" --email sophie@northwind.io zeeg crm upsert people --match emails --data '{"firstName":"Sophie","emails":["sophie@northwind.io"]}' zeeg crm update people --data '[{"id":"9b1c…","jobTitle":"CTO"},{"id":"a77e…","contactOwnerMemberId":42}]' ``` ## JSON output In a terminal the CLI prints tables. When its output goes to a pipe or a file, or with `--json`, it prints JSON you can hand to `jq` or an agent. Every command answers in one of these shapes: * **Lists** print `{ "collection": [...], "pagination": {...} }`. `pagination` is `null` when the command has no pages, as for `notes list`. * **One record** prints `{ "resource": {...} }`. That covers `get` commands and the record a write command created or changed. `resource` is `null` when the API returns no record, as for `bookings cancel`. * **Login and logout** print their own small status objects. For example: ```bash theme={null} zeeg bookings list --status confirmed | jq -r '.collection[].uuid' ``` Messages meant for you (the login code, questions, notices) go to stderr and never mix with the JSON. ## Exit codes | Code | Meaning | | - | - | | 0 | Done | | 1 | Wrong usage, or you answered no | | 2 | Not logged in, or the login ended: run `zeeg login` | | 3 | The API refused the request or could not be reached; the message says why | # Errors Source: https://developer.zeeg.me/errors Reference for HTTP status codes and error response shapes returned by the Zeeg API, with examples for 400, 401, 403, 404, 409, 422, and 500 errors. The Zeeg API uses standard HTTP status codes to indicate the success or failure of a request. Codes in the `2xx` range indicate success, `4xx` codes indicate a client error, and `5xx` codes indicate a server-side issue. ## Status codes | Code | Meaning | Description | | - | - | - | | `201` | Created | The resource was created successfully. | | `400` | Bad Request | The request is malformed or contains invalid parameters. | | `401` | Unauthorized | Authentication is missing or invalid. | | `403` | Forbidden | You do not have permission to access the resource. | | `404` | Not Found | The requested resource does not exist. | | `409` | Conflict | The resource already exists or conflicts with the current state. | | `422` | Unprocessable Entity | The request body failed validation. | | `429` | Too Many Requests | You have exceeded the rate limit. See [Rate Limits](/rate-limits). | | `500` | Internal Server Error | Something went wrong on our end. | ## Error response shapes The exact response shape varies slightly between endpoints. Some endpoints use an `error` field, while others use a `success` + `message` pattern. Always check the HTTP status code first, then parse the response body. Returned when the request is malformed or a required parameter is missing. ```json theme={null} { "success": false, "message": "Missing required parameter: event_uuid.", "status": 400 } ``` Always `false` for error responses. A human-readable description of the error. The HTTP status code. Returned when the `Authorization` header is missing, malformed, or the API key is invalid. ```json theme={null} { "message": "Unauthenticated." } ``` A human-readable description of the error. Returned when the authenticated user does not have permission to access the requested resource. ```json theme={null} { "success": false, "message": "You cannot access this resource.", "status": 403 } ``` Always `false` for error responses. A human-readable description of the error. The HTTP status code. Returned when the requested resource does not exist. ```json theme={null} { "error": "Resource not found" } ``` A human-readable description of the error. Returned when the resource already exists or the request conflicts with the current state. ```json theme={null} { "success": false, "message": "Resource already exists.", "status": 409 } ``` Always `false` for error responses. A human-readable description of the error. The HTTP status code. Returned when the request body fails validation. The `errors` object contains field-level details. ```json theme={null} { "message": "The given data was invalid.", "errors": { "field_name": [ "The field_name field is required." ] } } ``` A summary of the validation failure. A map of field names to arrays of validation error messages. Returned when an unexpected error occurs on our side. If this persists, contact support. ```json theme={null} { "success": false, "message": "An unexpected error occurred.", "status": 500 } ``` Always `false` for error responses. A human-readable description of the error. The HTTP status code. ## Best practices * **Check the HTTP status code first.** The status code is the most reliable indicator of what happened. Parse the response body only after determining the status. * **Handle both error shapes.** Some endpoints return `{"error": "..."}` while others return `{"success": false, "message": "...", "status": ...}`. Your client code should handle both. * **Use 422 errors for form validation.** The `errors` object gives you per-field messages you can surface directly to users. * **Retry on 500 errors.** Internal server errors are transient. Use exponential backoff when retrying. # AI Agent Calls Source: https://developer.zeeg.me/guides/ai-agent-calls Use the Zeeg API to trigger immediate or scheduled AI agent outbound phone calls, including how to pass call context and handle responses. Zeeg's AI Agent can make outbound phone calls on your behalf. Use the API to trigger calls immediately or schedule them for a specific time. This guide walks you through both scenarios. ## Prerequisites Before you begin, make sure you have the following in place: * An AI agent created, published, and enabled in your [Zeeg dashboard](https://app.zeeg.me) * The agent's UUID (found in the agent settings) * An API token with the `outbound-calls:write` scope ## Triggering an Immediate Call Send a `POST` request to start a call right away. The AI agent will dial the specified phone number and use the provided context to drive the conversation. **Endpoint** ``` POST https://api.zeeg.me/v2/agents/{agentUuid}/outbound-calls ``` **Required fields** | Field | Type | Description | | - | - | - | | `phoneNumber` | string | Phone number in E.164 format (e.g., `+4915112345678`) | | `email` | string | Contact's email address | | `fullName` | string | Contact's full name | **Optional fields** | Field | Type | Description | | - | - | - | | `additionalData` | string | Extra context for the AI agent to use during the call | | `scheduledTime` | string | Schedule the call for later (see [Scheduling a Future Call](#scheduling-a-future-call)) | Phone numbers must be in **E.164 format** — a `+` followed by the country code and subscriber number with no spaces or dashes. For example: `+4915112345678` (Germany) or `+14155551234` (US). ```bash cURL theme={null} curl -X POST https://api.zeeg.me/v2/agents/YOUR_AGENT_UUID/outbound-calls \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{ "phoneNumber": "+4915112345678", "email": "alex.mueller@example.com", "fullName": "Alex Mueller", "additionalData": "Follow up on demo request from March 28. Interested in the Enterprise plan." }' ``` ```python Python theme={null} import requests response = requests.post( "https://api.zeeg.me/v2/agents/YOUR_AGENT_UUID/outbound-calls", headers={ "Authorization": "Bearer YOUR_TOKEN", "Content-Type": "application/json", "Accept": "application/json", }, json={ "phoneNumber": "+4915112345678", "email": "alex.mueller@example.com", "fullName": "Alex Mueller", "additionalData": "Follow up on demo request from March 28. Interested in the Enterprise plan.", }, ) print(response.json()) ``` ```javascript JavaScript theme={null} const response = await fetch( "https://api.zeeg.me/v2/agents/YOUR_AGENT_UUID/outbound-calls", { method: "POST", headers: { Authorization: "Bearer YOUR_TOKEN", "Content-Type": "application/json", Accept: "application/json", }, body: JSON.stringify({ phoneNumber: "+4915112345678", email: "alex.mueller@example.com", fullName: "Alex Mueller", additionalData: "Follow up on demo request from March 28. Interested in the Enterprise plan.", }), } ); const data = await response.json(); console.log(data); ``` Use `additionalData` to give your AI agent relevant context about the person being called — prior interactions, the reason for the call, or specific talking points. This makes conversations more personalized and effective. ## Scheduling a Future Call To schedule a call for a later time, include the `scheduledTime` field in the same request. The value must be a future datetime in `YYYY-MM-DD HH:mm:ss` format. ```bash cURL theme={null} curl -X POST https://api.zeeg.me/v2/agents/YOUR_AGENT_UUID/outbound-calls \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{ "phoneNumber": "+4915112345678", "email": "alex.mueller@example.com", "fullName": "Alex Mueller", "scheduledTime": "2026-04-10 14:30:00", "additionalData": "Quarterly check-in call. Discuss renewal options." }' ``` ```python Python theme={null} import requests response = requests.post( "https://api.zeeg.me/v2/agents/YOUR_AGENT_UUID/outbound-calls", headers={ "Authorization": "Bearer YOUR_TOKEN", "Content-Type": "application/json", "Accept": "application/json", }, json={ "phoneNumber": "+4915112345678", "email": "alex.mueller@example.com", "fullName": "Alex Mueller", "scheduledTime": "2026-04-10 14:30:00", "additionalData": "Quarterly check-in call. Discuss renewal options.", }, ) print(response.json()) ``` ```javascript JavaScript theme={null} const response = await fetch( "https://api.zeeg.me/v2/agents/YOUR_AGENT_UUID/outbound-calls", { method: "POST", headers: { Authorization: "Bearer YOUR_TOKEN", "Content-Type": "application/json", Accept: "application/json", }, body: JSON.stringify({ phoneNumber: "+4915112345678", email: "alex.mueller@example.com", fullName: "Alex Mueller", scheduledTime: "2026-04-10 14:30:00", additionalData: "Quarterly check-in call. Discuss renewal options.", }), } ); const data = await response.json(); console.log(data); ``` ## Response A successful request returns the created call object with a `201` status code. ```json theme={null} { "id": 42, "status": "pending", "agentId": "YOUR_AGENT_UUID", "phoneNumber": "+4915112345678", "email": "alex.mueller@example.com", "fullName": "Alex Mueller", "additionalData": "Follow up on demo request from March 28. Interested in the Enterprise plan.", "scheduledTime": null } ``` **Status values** | Status | Description | | - | - | | `pending` | Call is queued or scheduled | | `completed` | Call finished successfully | | `failed` | Call could not be completed | ## Error Handling | Status | Error | Description | | - | - | - | | `400` | Agent is not active | The agent must be published and enabled in the dashboard | | `403` | Missing scope | Your API token does not have the `outbound-calls:write` scope | | `404` | Agent not found | No agent exists with the provided UUID | | `422` | Invalid phone number format | The phone number is not in valid E.164 format | | `422` | Scheduled time in the past | `scheduledTime` must be a future datetime | ## Integration Example You can combine this API with workflow automation tools to trigger calls based on external events. For a step-by-step walkthrough using n8n, see the guide: Automate outbound calls by connecting Zeeg's AI Agent API to n8n workflows. # Booking Flow Source: https://developer.zeeg.me/guides/booking-flow End-to-end guide for programmatically creating bookings via the Zeeg API — fetch scheduling pages, query available time slots, and create the event. This guide walks you through the end-to-end flow for programmatically booking an event via the Zeeg API. The availability and booking endpoints require a **paid Zeeg subscription**. Requests on free plans will be rejected. **Always verify availability before creating an event.** Attempting to book a slot that is no longer available will result in an error. Check available time slots immediately before creating the event to avoid race conditions. ## End-to-end booking flow Retrieve the scheduling page (event type) you want to book against. You need its slug, the owner's slug, any custom invitee questions, and whether a phone number is required. List all your scheduling pages: ```bash theme={null} GET /event-types ``` Or fetch a specific one by UUID: ```bash theme={null} GET /event-types/{uuid} ``` **Example response** (key fields highlighted): ```json theme={null} { "resource": { "uri": "https://api.zeeg.me/v2/event-types/80f46bf5-eb01-4c07-960e-a9a3e18aae5e", "uuid": "80f46bf5-eb01-4c07-960e-a9a3e18aae5e", "title": "30-Minute Discovery Call", "type": "ONE_ON_ONE", "slug": "30min-discovery-call", "schedulingUrl": "https://zeeg.me/lena-meier/30min-discovery-call", "isActive": true, "duration": 30, "profile": { "type": "User", "firstName": "Lena", "lastName": "Meier", "slug": "lena-meier", "email": "lena.meier@horizondigital.de" }, "inviteePhoneNumber": false, "inviteeQuestions": [ { "id": 1, "order": 1, "question": "What would you like to discuss?", "isActive": true, "isRequired": true, "type": "TEXT_INPUT", "options": null } ] } } ``` Take note of: * **`slug`** — identifies the event type in subsequent requests (e.g., `30min-discovery-call`). * **`profile.slug`** — the owner's slug, used as `ownerSlug` (e.g., `lena-meier`). * **`inviteeQuestions`** — custom questions with their numeric `id`s. You will pass answers in step 3 using `question_id`. * **`inviteePhoneNumber`** — `true` if a phone number is required, `false` otherwise. Query the availability endpoint to find open slots for the scheduling page. ```bash cURL theme={null} curl -X GET "https://api.zeeg.me/v2/availability/lena-meier/event-types/30min-discovery-call?startDate=2026-04-01&endDate=2026-04-14&timeZone=Europe/Berlin" \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Accept: application/json" ``` ```javascript JavaScript theme={null} const response = await fetch( "https://api.zeeg.me/v2/availability/lena-meier/event-types/30min-discovery-call?" + new URLSearchParams({ startDate: "2026-04-01", endDate: "2026-04-14", timeZone: "Europe/Berlin", }), { headers: { Authorization: "Bearer YOUR_TOKEN", Accept: "application/json", }, } ); const data = await response.json(); ``` ```python Python theme={null} import requests response = requests.get( "https://api.zeeg.me/v2/availability/lena-meier/event-types/30min-discovery-call", params={ "startDate": "2026-04-01", "endDate": "2026-04-14", "timeZone": "Europe/Berlin", }, headers={ "Authorization": "Bearer YOUR_TOKEN", "Accept": "application/json", }, ) data = response.json() ``` **Example response:** ```json theme={null} { "ownerSlug": "lena-meier", "eventTypeSlug": "30min-discovery-call", "schedulingUrl": "https://zeeg.me/lena-meier/30min-discovery-call", "duration": 30, "firstAvailableDate": "2026-04-01", "lastAvailableDate": "2026-04-14", "timeZone": "Europe/Berlin", "timeZoneOffset": "+02:00", "isNotAvailable": false, "availability": [ { "date": "2026-04-01", "slots": ["09:00", "09:30", "10:00", "10:30", "14:00", "14:30", "15:00"] }, { "date": "2026-04-02", "slots": ["09:00", "09:30", "10:00", "11:00", "14:00", "15:00"] }, { "date": "2026-04-03", "slots": [] } ], "currentTime": "2026-03-20T12:00:00+00:00" } ``` This endpoint requires the **`admin:full`** or **`timetable`** scope. Holding the scheduling page's `uuid` from `GET /event-types`? Call `GET /event-types/{uuid}/availability` with the same query parameters instead. It returns the same response, and it works the same for personal, team and shared scheduling pages. **Shared event types (Round Robin, Flexi, Collective):** * Use `shared` as the `ownerSlug` instead of a specific user's slug. * A shared scheduling link is addressed the same way: its token is the `eventTypeSlug`. * For **Round Robin** events, add `withHostsCount=1` to the query to see how many hosts are available per slot. **Additional query parameters:** * Add `duration=45` to override the default meeting duration (useful when the scheduling page supports multiple durations). * Add `seats=3` to check availability for group event types that support multiple attendees. Book a slot by sending a `POST` request to `/event` with the slot details and invitee information. ```bash cURL theme={null} curl -X POST "https://api.zeeg.me/v2/event" \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{ "ownerSlug": "lena-meier", "eventTypeSlug": "30min-discovery-call", "date": "2026-04-15", "start": "09:00", "duration": 30, "name": "Sophie Laurent", "email": "sophie.laurent@northwind.io", "timeZone": "Europe/Paris", "guests": ["alex.chen@northwind.io"], "location": "GOOGLE_MEET", "question_answers": [ { "question_id": 1, "answer": "Product demo and pricing options" } ] }' ``` ```javascript JavaScript theme={null} const response = await fetch("https://api.zeeg.me/v2/event", { method: "POST", headers: { Authorization: "Bearer YOUR_TOKEN", "Content-Type": "application/json", Accept: "application/json", }, body: JSON.stringify({ ownerSlug: "lena-meier", eventTypeSlug: "30min-discovery-call", date: "2026-04-15", start: "09:00", duration: 30, name: "Sophie Laurent", email: "sophie.laurent@northwind.io", timeZone: "Europe/Paris", guests: ["alex.chen@northwind.io"], location: "GOOGLE_MEET", question_answers: [ { question_id: 1, answer: "Product demo and pricing options" }, ], }), }); const event = await response.json(); ``` ```python Python theme={null} import requests response = requests.post( "https://api.zeeg.me/v2/event", headers={ "Authorization": "Bearer YOUR_TOKEN", "Content-Type": "application/json", "Accept": "application/json", }, json={ "ownerSlug": "lena-meier", "eventTypeSlug": "30min-discovery-call", "date": "2026-04-15", "start": "09:00", "duration": 30, "name": "Sophie Laurent", "email": "sophie.laurent@northwind.io", "timeZone": "Europe/Paris", "guests": ["alex.chen@northwind.io"], "location": "GOOGLE_MEET", "question_answers": [ {"question_id": 1, "answer": "Product demo and pricing options"}, ], }, ) event = response.json() ``` This endpoint requires the **`admin:full`** or **`booking`** scope. **Required fields:** Send `eventTypeId` (the scheduling page's `uuid` from step 1) in place of `ownerSlug` and `eventTypeSlug` if you prefer. It cannot be combined with them. | Field | Description | | - | - | | `ownerSlug` | The scheduling page owner's slug (from `profile.slug` in step 1) | | `eventTypeSlug` | The scheduling page slug (from step 1) | | `date` | The date of the slot (from step 2), e.g. `2026-04-01` | | `start` | The start time of the slot (from step 2), e.g. `09:00` | | `name` | Invitee's full name | | `email` | Invitee's email address | | `timeZone` | Invitee's time zone, e.g. `Europe/Berlin` | **Optional fields:** | Field | Description | | - | - | | `duration` | Duration in minutes, one of the durations the scheduling page offers. Omitted, the booking takes the page's default duration | | `phone` | Invitee's phone number (required if `inviteePhoneNumber` is `true` on the scheduling page) | | `question_answers` | Array of `{ question_id, answer }` objects matching the `inviteeQuestions` from step 1 | | `guests` | Array of additional guest email addresses | | `location` | A location type the scheduling page offers, e.g. `ZOOM`, `IN_PERSON`, `PHONE_CALL` or `CUSTOM`. Required when the page offers any location; the API reference lists every type | | `locationOptions` | Additional location details (see below) | | `utm` | UTM tracking object with keys: `utm_campaign`, `utm_source`, `utm_medium`, `utm_content`, `utm_term` | | `customQueryParams` | Custom URL query parameters to attach to the event. Keys must start with `c__` (max 10 fields) | | `sharedLink` | Required for shared event types (Round Robin, Flexi, Collective) | **Location options:** For **`IN_PERSON`** locations, include `locationOptions` with the meeting address: ```json theme={null} { "location": "IN_PERSON", "locationOptions": { "address": "123 Main St, Berlin, Germany", "details": "3rd floor, room 301" } } ``` For **`PHONE_CALL`** locations, include `locationOptions` with the call type and a phone number: ```json theme={null} { "location": "PHONE_CALL", "locationOptions": { "callType": "I_WILL_CALL" }, "phone": "+49 170 1234567" } ``` The response from step 3 contains the created event with all its details. **Example response:** ```json theme={null} { "uri": "https://api.zeeg.me/v2/scheduled-events/zg-O69bac566950c6", "uuid": "zg-O69bac566950c6", "title": "30-Minute Discovery Call", "type": "ONE_ON_ONE", "startTime": "2026-04-15T07:00:00.000000Z", "endTime": "2026-04-15T07:30:00.000000Z", "duration": 30, "status": "confirmed", "eventTypeUri": "https://api.zeeg.me/v2/event-types/80f46bf5-eb01-4c07-960e-a9a3e18aae5e", "location": { "type": "Google Meet", "joinUrl": "https://meet.google.com/abc-defg-hij" }, "invitees": [ { "uuid": "zg-O69bad4047abf0", "fullName": "Sophie Laurent", "email": "sophie.laurent@northwind.io", "timeZone": "Europe/Paris", "guests": ["alex.chen@northwind.io"] } ], "hosts": [ { "firstName": "Lena", "lastName": "Meier", "email": "lena.meier@horizondigital.de", "slug": "lena-meier" } ] } ``` To verify the booking at any later point, fetch it by UUID: ```bash cURL theme={null} curl -X GET "https://api.zeeg.me/v2/scheduled-events/zg-O69bac566950c6" \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Accept: application/json" ``` ```javascript JavaScript theme={null} const response = await fetch( "https://api.zeeg.me/v2/scheduled-events/zg-O69bac566950c6", { headers: { Authorization: "Bearer YOUR_TOKEN", Accept: "application/json", }, } ); const event = await response.json(); ``` ```python Python theme={null} import requests response = requests.get( "https://api.zeeg.me/v2/scheduled-events/zg-O69bac566950c6", headers={ "Authorization": "Bearer YOUR_TOKEN", "Accept": "application/json", }, ) event = response.json() ``` # CRM Bulk Import Source: https://developer.zeeg.me/guides/crm-bulk-import Migrate contacts, companies, and custom records from another CRM (HubSpot, Salesforce, Airtable, CSV) into Zeeg using the upsert endpoints — idempotent, batch-safe, and re-runnable. This guide walks you through migrating contacts, companies, and custom records from another system — HubSpot, Salesforce, Airtable, a spreadsheet — into the Zeeg CRM via the API. The flow is built around the `assert` (upsert) endpoints, so an import is **idempotent**: re-running the same job will update the existing rows instead of creating duplicates. The CRM API requires a workspace on a plan that includes CRM. Custom objects require a plan that supports them — see the [CRM overview](/api/crm/overview) for the full schema. ## Prerequisites Before you start, make sure you have: * An API token with the **`crm:write`** scope. `crm:read` is sufficient for discovery, but every write call requires `crm:write`. * A source export — typically a CSV, JSON dump, or a query against the source system's API. * A clear **dedup key** for each entity in your source data: * Companies → a domain (e.g. `acme.com`) * People → an email address * Custom records → any attribute you mark `isUnique` The companies `assert` endpoint only supports `domain` as the matching attribute. If your source has companies without a domain, you must either enrich them first or fall back to plain `POST /crm/companies` and dedupe on your side. ## Migration flow at a glance Fetch all objects and their attributes so you can map source fields to Zeeg attributes. For source fields that don't fit the standard schema, create custom attributes on `people`, `companies`, or a custom object. For source entities that aren't people or companies (deals, tickets, properties, …), create a custom object first. Use `PUT /crm/companies?matchingAttribute=domain` for each company. Use `PUT /crm/people?matchingAttribute=email` for each person, linking them to their company via `companyId`. Use `PUT /crm/{objectSlug}?matchingAttribute=` for any non-standard objects. For many-to-many or non-standard links, call `PATCH /crm/{objectSlug}/{recordId}/relation`. ## 1. Discover the target schema Before importing, list all CRM objects and their attribute definitions. This tells you exactly which keys your payloads need to use. ```bash cURL theme={null} curl -X GET "https://api.zeeg.me/v2/crm/objects" \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Accept: application/json" ``` ```python Python theme={null} import requests response = requests.get( "https://api.zeeg.me/v2/crm/objects", headers={ "Authorization": "Bearer YOUR_TOKEN", "Accept": "application/json", }, ) schema = response.json() ``` ```javascript JavaScript theme={null} const response = await fetch("https://api.zeeg.me/v2/crm/objects", { headers: { Authorization: "Bearer YOUR_TOKEN", Accept: "application/json", }, }); const schema = await response.json(); ``` The response contains every standard and custom object with its full attribute list. Use it to build a mapping table from source fields → Zeeg attribute keys. ```json theme={null} { "success": true, "status": 200, "collection": [ { "slug": "people", "singularName": "Person", "pluralName": "People", "isStandard": true, "attributes": [ { "key": "first_name", "label": "First Name", "type": "text", "isStandard": true }, { "key": "last_name", "label": "Last Name", "type": "text", "isStandard": true }, { "key": "emails", "label": "Emails", "type": "text", "isStandard": true }, { "key": "phone_number", "label": "Phone Number", "type": "phone_number", "isStandard": true } ] }, { "slug": "companies", "singularName": "Company", "pluralName": "Companies", "isStandard": true, "attributes": [ { "key": "name", "label": "Name", "type": "text", "isStandard": true, "isRequired": true }, { "key": "domain", "label": "Domain", "type": "text", "isStandard": true } ] } ] } ``` Snapshot the schema once at the start of your import job and reuse it for the rest of the run — the schema is stable and there is no benefit to re-fetching it for every row. ## 2. Add custom attributes If your source has fields that don't fit the standard schema (a HubSpot `Lifecycle Stage`, a Salesforce `Lead Score`, an Airtable status), create a custom attribute before you import. Add a custom attribute with `POST /crm/objects/{slug}/attributes`. The attribute `key` is auto-generated from the `label`. ```bash cURL theme={null} curl -X POST "https://api.zeeg.me/v2/crm/objects/people/attributes" \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "type": "select", "label": "Lifecycle Stage", "isRequired": false, "options": [ { "value": "Lead", "color": "warning" }, { "value": "MQL", "color": "sky" }, { "value": "SQL", "color": "primary" }, { "value": "Customer", "color": "success" } ] }' ``` ```python Python theme={null} import requests response = requests.post( "https://api.zeeg.me/v2/crm/objects/people/attributes", headers={ "Authorization": "Bearer YOUR_TOKEN", "Content-Type": "application/json", }, json={ "type": "select", "label": "Lifecycle Stage", "isRequired": False, "options": [ {"value": "Lead", "color": "warning"}, {"value": "MQL", "color": "sky"}, {"value": "SQL", "color": "primary"}, {"value": "Customer", "color": "success"}, ], }, ) attribute = response.json()["attribute"] print(attribute["key"]) # e.g. "lifecycle_stage" ``` ```javascript JavaScript theme={null} const response = await fetch( "https://api.zeeg.me/v2/crm/objects/people/attributes", { method: "POST", headers: { Authorization: "Bearer YOUR_TOKEN", "Content-Type": "application/json", }, body: JSON.stringify({ type: "select", label: "Lifecycle Stage", isRequired: false, options: [ { value: "Lead", color: "warning" }, { value: "MQL", color: "sky" }, { value: "SQL", color: "primary" }, { value: "Customer", color: "success" }, ], }), } ); const { attribute } = await response.json(); ``` **Type-specific required fields** — see [Add an attribute to a CRM object](/api/crm/add-attribute-to-crm-object) for the full reference: | Attribute type | Required fields | | - | - | | `select`, `multiselect`, `status` | `options` (max 20) | | `relation` | `relationType`, `relatedObjectSlug`, `relatedObjectLabel` | | `text`, `phone_number`, `number` | `isUnique` | | `currency` | `currency`, `currencyDisplay`, `currencyDecimal`, `currencyGrouping` | The attribute `type` cannot be changed after creation. Pick it carefully — switching from `text` to `select` later requires deleting and recreating the attribute, which loses any data already imported into it. If you plan to use a custom attribute as the matching key for `assert` (e.g. an `external_id` carried over from the source system), set `isUnique: true` when you create it. ## 3. Create custom objects If the source system has entities that aren't people or companies — deals, properties, tickets, vehicles — create a custom object before importing its records. ```bash cURL theme={null} curl -X POST "https://api.zeeg.me/v2/crm/objects" \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "slug": "deals", "singularName": "Deal", "pluralName": "Deals" }' ``` ```python Python theme={null} import requests response = requests.post( "https://api.zeeg.me/v2/crm/objects", headers={ "Authorization": "Bearer YOUR_TOKEN", "Content-Type": "application/json", }, json={ "slug": "deals", "singularName": "Deal", "pluralName": "Deals", }, ) ``` ```javascript JavaScript theme={null} const response = await fetch("https://api.zeeg.me/v2/crm/objects", { method: "POST", headers: { Authorization: "Bearer YOUR_TOKEN", "Content-Type": "application/json", }, body: JSON.stringify({ slug: "deals", singularName: "Deal", pluralName: "Deals", }), }); ``` A new custom object only ships with the system attributes (`id`, `created_at`, `updated_at`). Add the rest with the attributes endpoint described above — at minimum, add a unique `external_id` (or equivalent) text attribute so you can upsert records by it. ## 4. Upsert companies The `PUT /crm/companies` endpoint is the recommended path for imports. Send the full payload and let Zeeg decide whether to create or update: * **`200 OK`** — a company with the same `domain` already existed and was updated. * **`201 Created`** — no match was found and a new record was created. The matching attribute for companies is **always `domain`** — no other attribute is supported on the companies upsert. ```bash cURL theme={null} curl -X PUT "https://api.zeeg.me/v2/crm/companies?matchingAttribute=domain" \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Acme Corp", "domain": "acme.com", "description": "A global provider of anvils and gadgets.", "websiteUrl": "https://acme.com", "primaryLocation": "Phoenix, AZ", "socials": { "linkedin": "https://linkedin.com/company/acme", "twitter": "https://twitter.com/acme" } }' ``` ```python Python theme={null} import requests response = requests.put( "https://api.zeeg.me/v2/crm/companies", params={"matchingAttribute": "domain"}, headers={ "Authorization": "Bearer YOUR_TOKEN", "Content-Type": "application/json", }, json={ "name": "Acme Corp", "domain": "acme.com", "description": "A global provider of anvils and gadgets.", "websiteUrl": "https://acme.com", "primaryLocation": "Phoenix, AZ", "socials": { "linkedin": "https://linkedin.com/company/acme", "twitter": "https://twitter.com/acme", }, }, ) company = response.json()["company"] company_id = company["id"] # save this for linking people later ``` ```javascript JavaScript theme={null} const response = await fetch( "https://api.zeeg.me/v2/crm/companies?matchingAttribute=domain", { method: "PUT", headers: { Authorization: "Bearer YOUR_TOKEN", "Content-Type": "application/json", }, body: JSON.stringify({ name: "Acme Corp", domain: "acme.com", description: "A global provider of anvils and gadgets.", websiteUrl: "https://acme.com", primaryLocation: "Phoenix, AZ", socials: { linkedin: "https://linkedin.com/company/acme", twitter: "https://twitter.com/acme", }, }), } ); const { company } = await response.json(); ``` Keep a `domain → company.id` map in memory as you iterate — you will need the company UUID when upserting people in the next step. ## 5. Upsert people People work the same way, but the matching attribute is configurable. Use `email` for typical contact imports — it matches against both the primary and any secondary emails on a person. ```bash cURL theme={null} curl -X PUT "https://api.zeeg.me/v2/crm/people?matchingAttribute=email" \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "firstName": "Jane", "lastName": "Doe", "emails": ["jane.doe@acme.com"], "jobTitle": "Head of Product", "phoneNumber": "+4930123456789", "primaryLocation": "Berlin, Germany", "companyId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "socials": { "linkedin": "https://linkedin.com/in/janedoe" } }' ``` ```python Python theme={null} import requests response = requests.put( "https://api.zeeg.me/v2/crm/people", params={"matchingAttribute": "email"}, headers={ "Authorization": "Bearer YOUR_TOKEN", "Content-Type": "application/json", }, json={ "firstName": "Jane", "lastName": "Doe", "emails": ["jane.doe@acme.com"], "jobTitle": "Head of Product", "phoneNumber": "+4930123456789", "primaryLocation": "Berlin, Germany", "companyId": company_id, # from step 4 "socials": { "linkedin": "https://linkedin.com/in/janedoe", }, }, ) ``` ```javascript JavaScript theme={null} const response = await fetch( "https://api.zeeg.me/v2/crm/people?matchingAttribute=email", { method: "PUT", headers: { Authorization: "Bearer YOUR_TOKEN", "Content-Type": "application/json", }, body: JSON.stringify({ firstName: "Jane", lastName: "Doe", emails: ["jane.doe@acme.com"], jobTitle: "Head of Product", phoneNumber: "+4930123456789", primaryLocation: "Berlin, Germany", companyId: companyId, // from step 4 socials: { linkedin: "https://linkedin.com/in/janedoe", }, }), } ); ``` The `matchingAttribute` query parameter accepts **any standard or custom attribute slug** on `people`. If you tracked a stable `external_id` from the source system (and created it as a unique custom attribute in step 2), pass `matchingAttribute=external_id` instead — that's the most robust dedup key for ongoing syncs because it survives email changes. The first entry in `emails` becomes the primary email and is the one used for matching when `matchingAttribute=email`. Make sure the source system's primary email maps to position `[0]`. ## 6. Upsert custom records Custom records use the same upsert pattern via `PUT /crm/{objectSlug}`, with `matchingAttribute` set to any unique attribute on the object (typically `external_id` or a domain-specific key like `sku`). The request body is a flat object of `attributeSlug → value` pairs. ```bash cURL theme={null} curl -X PUT "https://api.zeeg.me/v2/crm/deals?matchingAttribute=external_id" \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "external_id": "HUBSPOT-DEAL-12345", "name": "Acme — Q3 Renewal", "amount": 24000, "stage": "Negotiation" }' ``` ```python Python theme={null} import requests response = requests.put( "https://api.zeeg.me/v2/crm/deals", params={"matchingAttribute": "external_id"}, headers={ "Authorization": "Bearer YOUR_TOKEN", "Content-Type": "application/json", }, json={ "external_id": "HUBSPOT-DEAL-12345", "name": "Acme — Q3 Renewal", "amount": 24000, "stage": "Negotiation", }, ) ``` ```javascript JavaScript theme={null} const response = await fetch( "https://api.zeeg.me/v2/crm/deals?matchingAttribute=external_id", { method: "PUT", headers: { Authorization: "Bearer YOUR_TOKEN", "Content-Type": "application/json", }, body: JSON.stringify({ external_id: "HUBSPOT-DEAL-12345", name: "Acme — Q3 Renewal", amount: 24000, stage: "Negotiation", }), } ); ``` ## 7. Link records via relations The `companyId` field on a person is enough for the standard person-to-company link. For any other relation — many-to-many, custom-object-to-custom-object, person-to-deal — use the relation endpoint. `PATCH /crm/{objectSlug}/{recordId}/relation` adds or removes IDs from a relation or `user` attribute. Supply `add`, `remove`, or both. The same ID cannot appear in both arrays. ```bash cURL theme={null} curl -X PATCH "https://api.zeeg.me/v2/crm/deals/c1d2e3f4-a5b6-7890-cdef-123456789012/relation" \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "attributeSlug": "contacts", "add": [ "b2c3d4e5-f6a7-8901-bcde-f23456789012", "d4e5f6a7-b8c9-0123-defa-456789012345" ], "remove": [] }' ``` ```python Python theme={null} import requests response = requests.patch( "https://api.zeeg.me/v2/crm/deals/c1d2e3f4-a5b6-7890-cdef-123456789012/relation", headers={ "Authorization": "Bearer YOUR_TOKEN", "Content-Type": "application/json", }, json={ "attributeSlug": "contacts", "add": [ "b2c3d4e5-f6a7-8901-bcde-f23456789012", "d4e5f6a7-b8c9-0123-defa-456789012345", ], "remove": [], }, ) ``` ```javascript JavaScript theme={null} const response = await fetch( "https://api.zeeg.me/v2/crm/deals/c1d2e3f4-a5b6-7890-cdef-123456789012/relation", { method: "PATCH", headers: { Authorization: "Bearer YOUR_TOKEN", "Content-Type": "application/json", }, body: JSON.stringify({ attributeSlug: "contacts", add: [ "b2c3d4e5-f6a7-8901-bcde-f23456789012", "d4e5f6a7-b8c9-0123-defa-456789012345", ], remove: [], }), } ); ``` The endpoint is also the way to populate `user` attributes (e.g. setting an Account Owner on a company) — pass the workspace member's ID in `add`. ## Update many records in one request To change fields on records that already exist, `PATCH /crm/people`, `PATCH /crm/companies` or `PATCH /crm/{objectSlug}` takes up to 50 records per request. Each item is the body of the single-record `PATCH`, plus the record's `id`. Items may carry different fields, and only the fields an item carries change. ```bash cURL theme={null} curl -X PATCH "https://api.zeeg.me/v2/crm/people" \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "records": [ { "id": "9b1c2d3e-4f50-4a6b-8c7d-0e1f2a3b4c5d", "contactOwnerMemberId": 42 }, { "id": "a77e8f90-1a2b-4c3d-9e4f-5a6b7c8d9e0f", "contactOwnerMemberId": 42, "jobTitle": "CTO" }, { "id": "b88f9aa1-2b3c-4d4e-8f5a-6b7c8d9e0f1a", "emails": ["not-an-email"] } ] }' ``` Each record is validated and updated on its own. If the second record above does not exist and the third has an invalid email, the first is still updated, and the response reports all three in request order: ```json theme={null} { "success": true, "status": 200, "updated": 1, "failed": 2, "results": [ { "id": "9b1c2d3e-…", "updated": true, "record": { "…": "…" }, "error": null }, { "id": "a77e8f90-…", "updated": false, "record": null, "error": { "status": 404, "message": "Not Found" } }, { "id": "b88f9aa1-…", "updated": false, "record": null, "error": { "status": 422, "message": "The emails.0 field must be a valid email address.", "errors": { "emails.0": ["The emails.0 field must be a valid email address."] } } } ] } ``` A request that is malformed as a whole (`records` missing, empty or over 50, an `id` missing, not a UUID or repeated) returns `422` and changes nothing; a token without `crm:write` returns `403` and changes nothing. There is no rollback across records, so re-send only the entries whose `error` is set. When a field failed validation (`error.status` is `422`), `error.errors` maps each field to its messages, as the single-record `PATCH` does; any other error has no `errors` key. ## Batching, rate limits, and error handling The CRM API rate-limits per endpoint and per workspace — see [Rate Limits](/rate-limits) for the general policy. For a bulk import: * **Send sequentially or in small parallel batches.** Five to ten parallel workers is usually a safe starting point; back off if you start seeing 429s. * **Implement exponential backoff on 429.** Sleep `2^attempt + jitter` seconds, up to a 60-second cap. * **Log failed rows, don't abort.** A failed row should not stop the whole import. Capture the row, the request, and the error response so you can re-run only the failures — the upsert semantics make this safe. * **Validate locally first.** Required fields, type constraints, and length limits are checked server-side; pre-validating saves round trips on a large run. A minimal upsert helper with retries: ```python Python theme={null} import time import random import requests BASE_URL = "https://api.zeeg.me/v2" TOKEN = "YOUR_TOKEN" def upsert(path, payload, matching_attribute, max_attempts=6): url = f"{BASE_URL}{path}" headers = { "Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json", } params = {"matchingAttribute": matching_attribute} for attempt in range(max_attempts): response = requests.put(url, params=params, headers=headers, json=payload) if response.status_code in (200, 201): return response.json() if response.status_code == 429: sleep = min(2 ** attempt + random.random(), 60) time.sleep(sleep) continue # Non-retriable: 4xx (other than 429) — surface to the caller for logging. response.raise_for_status() raise RuntimeError(f"Gave up after {max_attempts} attempts: {url}") ``` ```javascript JavaScript theme={null} const BASE_URL = "https://api.zeeg.me/v2"; const TOKEN = "YOUR_TOKEN"; async function upsert(path, payload, matchingAttribute, maxAttempts = 6) { const url = `${BASE_URL}${path}?matchingAttribute=${encodeURIComponent(matchingAttribute)}`; const headers = { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", }; for (let attempt = 0; attempt < maxAttempts; attempt++) { const response = await fetch(url, { method: "PUT", headers, body: JSON.stringify(payload), }); if (response.status === 200 || response.status === 201) { return await response.json(); } if (response.status === 429) { const sleep = Math.min(2 ** attempt + Math.random(), 60) * 1000; await new Promise((r) => setTimeout(r, sleep)); continue; } // Non-retriable: surface for logging. throw new Error(`${response.status} ${await response.text()}`); } throw new Error(`Gave up after ${maxAttempts} attempts: ${url}`); } ``` For the full set of error codes and response shapes, see [Errors](/errors). ## Worked example: importing a CSV A complete script that imports companies and people from two CSV files. The dedup keys are `domain` for companies and `email` for people; re-running the script updates existing rows without creating duplicates. It uses the `upsert` helper from the previous section so 429s are retried automatically. ```python Python theme={null} import csv # Reuses the `upsert(path, payload, matching_attribute)` helper defined above. # 1. Import companies — keep a domain → id map for the people pass. domain_to_company_id = {} with open("companies.csv") as f: for row in csv.DictReader(f): if not row.get("domain"): print(f"SKIP company without domain: {row['name']}") continue payload = { "name": row["name"], "domain": row["domain"], "description": row.get("description") or None, "websiteUrl": row.get("website") or None, "primaryLocation": row.get("location") or None, } try: result = upsert("/crm/companies", payload, "domain") except Exception as e: print(f"FAIL company {row['domain']}: {e}") continue domain_to_company_id[row["domain"]] = result["company"]["id"] # 2. Import people — link to company by domain. with open("people.csv") as f: for row in csv.DictReader(f): if not row.get("email"): print(f"SKIP person without email: {row.get('first_name')} {row.get('last_name')}") continue payload = { "firstName": row.get("first_name") or None, "lastName": row.get("last_name") or None, "emails": [row["email"]], "jobTitle": row.get("job_title") or None, "phoneNumber": row.get("phone") or None, "companyId": domain_to_company_id.get(row.get("company_domain")), } try: upsert("/crm/people", payload, "email") except Exception as e: print(f"FAIL person {row['email']}: {e}") ``` For larger imports (tens of thousands of rows), run this in batches with a small worker pool and write failures to a separate CSV so you can re-run only the failed rows. The same script doubles as a recurring sync job. Schedule it on a cron, point it at a fresh export, and the assert semantics will keep Zeeg in step with the source system without manual reconciliation. ## Next steps * Subscribe to [webhooks](/guides/webhooks-guide) to receive updates when CRM-linked events are scheduled or cancelled. * Browse the full [CRM API reference](/api/crm/overview) for every endpoint, including delete, individual `GET`, and the non-upsert `POST`/`PATCH` variants. # Managing Events Source: https://developer.zeeg.me/guides/managing-events List, filter, inspect, cancel, and hand over scheduled events with the Zeeg API. Includes filtering by host, team, status, and date range. The Zeeg API gives you full control over scheduled events in your workspace. You can list events with powerful filters, retrieve detailed event information, cancel events or individual invitees, and hand over Round Robin events to a different host. ## Listing events Retrieve a paginated list of scheduled events with `GET /scheduled-events`. Use query parameters to filter and sort the results. ### Available filters | Parameter | Type | Description | | - | - | - | | `status` | string | Filter by event status: `confirmed` or `cancelled` | | `minStartTime` | ISO 8601 | Only return events starting at or after this time | | `maxStartTime` | ISO 8601 | Only return events starting at or before this time | | `userSlug` | string | Filter by the host's user slug | | `hostEmail` | string | Filter by the host's email address | | `inviteeEmail` | string | Filter by an invitee's email address | | `teamSlug` | string | Filter by team slug | | `keyword` | string | Search invitee names, invitee emails, booking question answers, and custom query parameter values | | `sort` | string | Sort by start time: `asc` (default) or `desc` | | `scope` | string | Set to `all` to list events across the entire organization (requires an admin or owner token with `admin:full` scope) | ### Example: filter by date range and status ```bash cURL theme={null} curl -X GET "https://api.zeeg.me/v2/scheduled-events?status=confirmed&minStartTime=2026-04-01T00:00:00Z&maxStartTime=2026-04-30T23:59:59Z&sort=asc" \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Accept: application/json" ``` ```python Python theme={null} import requests response = requests.get( "https://api.zeeg.me/v2/scheduled-events", params={ "status": "confirmed", "minStartTime": "2026-04-01T00:00:00Z", "maxStartTime": "2026-04-30T23:59:59Z", "sort": "asc", }, headers={ "Authorization": "Bearer YOUR_TOKEN", "Accept": "application/json", }, ) data = response.json() ``` ```javascript JavaScript theme={null} const params = new URLSearchParams({ status: "confirmed", minStartTime: "2026-04-01T00:00:00Z", maxStartTime: "2026-04-30T23:59:59Z", sort: "asc", }); const response = await fetch( `https://api.zeeg.me/v2/scheduled-events?${params}`, { headers: { Authorization: "Bearer YOUR_TOKEN", Accept: "application/json", }, } ); const data = await response.json(); ``` To list events across your entire organization, add `scope=all`. This requires a token with the `admin:full` scope. ```bash theme={null} curl -X GET "https://api.zeeg.me/v2/scheduled-events?scope=all" \ -H "Authorization: Bearer YOUR_ADMIN_TOKEN" \ -H "Accept: application/json" ``` ## Getting event details Retrieve full details for a single event with `GET /scheduled-events/{uuid}`. Event UUIDs follow the format `zg-XXX` (for example, `zg-O69bac566950c6`). ```bash cURL theme={null} curl -X GET "https://api.zeeg.me/v2/scheduled-events/zg-O69bac566950c6" \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Accept: application/json" ``` ```python Python theme={null} import requests event_uuid = "zg-O69bac566950c6" response = requests.get( f"https://api.zeeg.me/v2/scheduled-events/{event_uuid}", headers={ "Authorization": "Bearer YOUR_TOKEN", "Accept": "application/json", }, ) event = response.json() ``` ```javascript JavaScript theme={null} const eventUuid = "zg-O69bac566950c6"; const response = await fetch( `https://api.zeeg.me/v2/scheduled-events/${eventUuid}`, { headers: { Authorization: "Bearer YOUR_TOKEN", Accept: "application/json", }, } ); const event = await response.json(); ``` ### Example response ```json theme={null} { "resource": { "uri": "https://api.zeeg.me/v2/scheduled-events/zg-O69bac566950c6", "uuid": "zg-O69bac566950c6", "title": "30-Minute Discovery Call", "type": "ONE_ON_ONE", "startTime": "2026-04-15T09:00:00.000000Z", "endTime": "2026-04-15T09:30:00.000000Z", "duration": 30, "status": "confirmed", "eventTypeUri": "https://api.zeeg.me/v2/event-types/80f46bf5-eb01-4c07-960e-a9a3e18aae5e", "maxActiveInvitees": 1, "activeInviteesCount": 1, "location": { "type": "Google Meet", "joinUrl": "https://meet.google.com/abc-defg-hij" }, "invitees": [ { "uuid": "zg-O69bad4047abf0", "salutation": "Ms.", "fullName": "Sophie Laurent", "email": "sophie.laurent@northwind.io", "guests": ["alex.chen@northwind.io"], "timeZone": "Europe/Paris", "questions": [ { "answer": "Product demo and pricing options", "answer_type": "STRING", "question": "What would you like to discuss?" } ], "scheduledAt": "2026-04-10T08:30:00.000000Z", "utm": { "utm_campaign": "spring_launch", "utm_source": "linkedin", "utm_medium": "social", "utm_content": null, "utm_term": null }, "customQueryParams": { "c__ref": "nl-2026-04" } } ], "hosts": [ { "firstName": "Lena", "lastName": "Meier", "email": "lena.meier@horizondigital.de", "slug": "lena-meier", "url": "https://zeeg.me/lena-meier", "avatarUrl": null } ], "teamName": "Sales", "createdAt": "2026-04-10T08:30:00.000000Z", "updatedAt": "2026-04-10T08:30:02.000000Z" } } ``` `customQueryParams`, `utm`, `guests`, and `teamName` each keep one JSON shape on every response from every scheduled-event endpoint. `customQueryParams` is always an object. If the booking captured no custom parameters, the object is empty (`{}`). `utm` is always an object with all five keys (`utm_campaign`, `utm_source`, `utm_medium`, `utm_term`, `utm_content`). If the booking did not carry a value for a key, that key holds `null`. `guests` is always an array. If the invitee added no guests, the array is empty (`[]`). `teamName` is always present. If the booking was made on a personal scheduling page, it holds `null`. It carries the same value the `invitee.scheduled` webhook sends for that booking, so you can recover the team after a missed delivery. Check for the empty value directly (`{}`, `[]`, or a `null` key) instead of testing the type. ## Cancelling events Cancel a scheduled event with `PUT /scheduled-events/{uuid}/cancel`. You can optionally include a `cancellationReason` (maximum 512 characters) that will be shared with participants. ```bash cURL theme={null} curl -X PUT "https://api.zeeg.me/v2/scheduled-events/zg-O69bac566950c6/cancel" \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ -d '{"cancellationReason": "Scheduling conflict — will rebook next week."}' ``` ```python Python theme={null} import requests event_uuid = "zg-O69bac566950c6" response = requests.put( f"https://api.zeeg.me/v2/scheduled-events/{event_uuid}/cancel", headers={ "Authorization": "Bearer YOUR_TOKEN", "Accept": "application/json", "Content-Type": "application/json", }, json={ "cancellationReason": "Scheduling conflict — will rebook next week." }, ) ``` ```javascript JavaScript theme={null} const eventUuid = "zg-O69bac566950c6"; const response = await fetch( `https://api.zeeg.me/v2/scheduled-events/${eventUuid}/cancel`, { method: "PUT", headers: { Authorization: "Bearer YOUR_TOKEN", Accept: "application/json", "Content-Type": "application/json", }, body: JSON.stringify({ cancellationReason: "Scheduling conflict — will rebook next week.", }), } ); ``` A successful cancellation returns a `200` response confirming the operation. ```json theme={null} { "success": true, "message": "Event cancelled successfully.", "status": 200 } ``` Attempting to cancel an event that is already cancelled will return a `400 Bad Request` error. Check the event's `status` field before making the request if you need to handle this case gracefully. ## Cancelling individual invitees For group events where you need to remove a single attendee without cancelling the entire event, use `PUT /scheduled-invitees/{uuid}/cancel`. The invitee UUID can be found in the event details response. ```bash cURL theme={null} curl -X PUT "https://api.zeeg.me/v2/scheduled-invitees/zg-O69bad4047abf0/cancel" \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ -d '{"cancellationReason": "Attendee can no longer make it."}' ``` ```python Python theme={null} import requests invitee_uuid = "zg-O69bad4047abf0" response = requests.put( f"https://api.zeeg.me/v2/scheduled-invitees/{invitee_uuid}/cancel", headers={ "Authorization": "Bearer YOUR_TOKEN", "Accept": "application/json", "Content-Type": "application/json", }, json={ "cancellationReason": "Attendee can no longer make it." }, ) ``` ```javascript JavaScript theme={null} const inviteeUuid = "zg-O69bad4047abf0"; const response = await fetch( `https://api.zeeg.me/v2/scheduled-invitees/${inviteeUuid}/cancel`, { method: "PUT", headers: { Authorization: "Bearer YOUR_TOKEN", Accept: "application/json", "Content-Type": "application/json", }, body: JSON.stringify({ cancellationReason: "Attendee can no longer make it.", }), } ); ``` ## Marking no-shows When an invitee doesn't turn up, mark them as a no-show with `POST /scheduled-invitees/{uuid}/no-show`. It has the same effect as marking them in the dashboard: workflows triggered by a no-show run, the CRM records the missed meeting, and webhooks subscribed to `invitee.no_show` receive the invitee. The response is the invitee, with `noShow: true` and the `noShowAt` timestamp. ```bash Mark theme={null} curl -X POST "https://api.zeeg.me/v2/scheduled-invitees/zg-O69bad4047abf0/no-show" \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Accept: application/json" ``` ```bash Undo theme={null} curl -X DELETE "https://api.zeeg.me/v2/scheduled-invitees/zg-O69bad4047abf0/no-show" \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Accept: application/json" ``` * The booking must have started. Marking earlier, or marking an invitee whose seat is cancelled, rescheduled or unconfirmed, returns `422`. * Both calls are safe to repeat: marking a marked invitee, or undoing an unmarked one, returns the invitee unchanged. * Undoing a marking cancels no-show workflow steps that haven't run yet. It sends no webhook, and it doesn't restore workflow steps the marking cancelled. ## Handing over events Transfer a Round Robin event to a different host with `PUT /scheduled-events/{uuid}/handover`. You can specify the new host explicitly or let the system auto-assign one based on the Round Robin rules. ### Options | Parameter | Type | Default | Description | | - | - | - | - | | `newHostEmail` | string | — | Email of the new host. If omitted, the system auto-assigns using Round Robin logic. | | `requireHostAvailability` | boolean | `true` | When `true`, the handover only succeeds if the new host is free at the event time. | | `fallbackToReschedule` | boolean | `false` | When `true` and the booking cannot move in place (it sits in a calendar other than Microsoft, or its location is Zoom, Webex or Demodesk), reschedules it to the new host at the same time instead of failing. It does not help when the new host is busy. | A booking that moves in place emails the new host, with the booking details and who it came from, and tells the previous host it is no longer theirs. The invitee receives no email, and nothing is sent when the event type turns host emails off. A booking rescheduled through `fallbackToReschedule` sends the usual reschedule emails to the invitee and the hosts instead. `fallbackToReschedule: true` goes through a reschedule, so the booking gets a new event `uuid` and a new invitee `uuid`. Keep the default `false` if your integration stores the identifiers it already holds. ```bash cURL theme={null} curl -X PUT "https://api.zeeg.me/v2/scheduled-events/zg-O69bac566950c6/handover" \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ -d '{ "newHostEmail": "marco.rossi@horizondigital.de", "requireHostAvailability": true, "fallbackToReschedule": true }' ``` ```python Python theme={null} import requests event_uuid = "zg-O69bac566950c6" response = requests.put( f"https://api.zeeg.me/v2/scheduled-events/{event_uuid}/handover", headers={ "Authorization": "Bearer YOUR_TOKEN", "Accept": "application/json", "Content-Type": "application/json", }, json={ "newHostEmail": "marco.rossi@horizondigital.de", "requireHostAvailability": True, "fallbackToReschedule": True, }, ) ``` ```javascript JavaScript theme={null} const eventUuid = "zg-O69bac566950c6"; const response = await fetch( `https://api.zeeg.me/v2/scheduled-events/${eventUuid}/handover`, { method: "PUT", headers: { Authorization: "Bearer YOUR_TOKEN", Accept: "application/json", "Content-Type": "application/json", }, body: JSON.stringify({ newHostEmail: "marco.rossi@horizondigital.de", requireHostAvailability: true, fallbackToReschedule: true, }), } ); ``` **Handover limitations.** The handover endpoint currently has the following constraints: * **Round Robin only** — handover is only supported for events booked through a Round Robin scheduling page. Other event types will return an error. * **Calendar provider** — the host must use a Microsoft calendar or have no calendar connected. Google Calendar is not yet supported for handovers. * **No Zoom, Webex or Demodesk locations** — events with a Zoom, Webex or Demodesk meeting location cannot be handed over in place. The meeting link is tied to the original host and cannot be transferred automatically. ## Adding notes to events You can attach internal notes to scheduled events that are visible only to hosts and workspace members. Notes are useful for adding context before or after a meeting. See the [Notes API reference](/api/notes/create-a-note) for the full list of endpoints. ## Real-time updates Instead of polling `GET /scheduled-events` to detect changes, set up [webhook subscriptions](/guides/webhooks-guide) to receive real-time notifications when events are created, cancelled, rescheduled, or handed over. This is more efficient and gives you near-instant updates. # Managing Scheduling Pages Source: https://developer.zeeg.me/guides/managing-scheduling-pages List, inspect, toggle, and issue single-use links for Zeeg scheduling pages programmatically. Provision pages at sign-up, sync from a master config, or generate per-customer booking links. This guide covers the lifecycle of a scheduling page from the API side — listing what you have, inspecting a single page's configuration, activating or deactivating it, and issuing single-use links to share with specific customers. If you want to **use** a scheduling page (fetch availability and create a booking), see the [Booking Flow](/guides/booking-flow) guide. This guide is its complement: it covers how you **manage** the pages themselves. ## When to use these endpoints Typical reasons to manage scheduling pages programmatically: * **Provisioning at sign-up** — list a newly invited user's pages to confirm they exist before pointing the user at them. * **Syncing from a master config** — keep an internal source of truth in step with what's published on Zeeg by toggling pages active/inactive as your data changes. * **Per-customer booking links** — issue a unique, one-time-use link to a specific customer (for example, after a paid signup) so they can only book once. * **Operational hygiene** — deactivate an employee's scheduling page when they leave; reactivate when they return. ## Prerequisites * An API token from your [Zeeg dashboard](https://app.zeeg.me/account/settings/api-access). All endpoints in this guide require a Bearer token. * The `userSlug` filter on listing requires an **organization admin or owner** token. **Naming.** Zeeg's dashboard calls these "scheduling pages." The API path is `/event-types` for historical reasons. The two terms refer to the same object — this guide uses "scheduling page" in prose and shows the actual `/event-types` path in code samples. ## Listing scheduling pages Retrieve a paginated list of scheduling pages with `GET /event-types`. By default, the response contains pages on which the authenticated user is a host. ### Query parameters | Parameter | Type | Description | | - | - | - | | `userSlug` | string | Filter to a specific user's scheduling pages. Requires an organization admin/owner token. | | `count` | integer | Items per page. Default `20`, max `100`. See [Pagination](/pagination). | | `page` | integer | Page number (1-indexed). | ```bash cURL theme={null} curl -X GET "https://api.zeeg.me/v2/event-types?count=20&page=1" \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Accept: application/json" ``` ```python Python theme={null} import requests response = requests.get( "https://api.zeeg.me/v2/event-types", params={"count": 20, "page": 1}, headers={ "Authorization": "Bearer YOUR_TOKEN", "Accept": "application/json", }, ) data = response.json() ``` ```javascript JavaScript theme={null} const response = await fetch( "https://api.zeeg.me/v2/event-types?" + new URLSearchParams({ count: "20", page: "1" }), { headers: { Authorization: "Bearer YOUR_TOKEN", Accept: "application/json", }, } ); const data = await response.json(); ``` ### Example response ```json theme={null} { "collection": [ { "uri": "https://api.zeeg.me/v2/event-types/80f46bf5-eb01-4c07-960e-a9a3e18aae5e", "uuid": "80f46bf5-eb01-4c07-960e-a9a3e18aae5e", "title": "30-Minute Discovery Call", "type": "ONE_ON_ONE", "slug": "30min-discovery-call", "schedulingUrl": "https://zeeg.me/lena-meier/30min-discovery-call", "isActive": true, "duration": 30, "profile": { "type": "User", "firstName": "Lena", "lastName": "Meier", "slug": "lena-meier", "email": "lena.meier@horizondigital.de" }, "color": "2196f3", "maxActiveInvitees": 1, "inviteeNameFormat": "FULL_NAME", "inviteePhoneNumber": false } ], "pagination": { "total": 1, "count": 1, "totalPages": 1, "previousPage": null, "currentPage": 1, "nextPage": null } } ``` The response uses Zeeg's standard pagination envelope. Walk through `pagination.nextPage` until it returns `null` — see [Pagination](/pagination) for the full pattern. To audit pages owned by another user in your organization (for example, when offboarding), call `GET /event-types?userSlug=` with an admin token. ## Getting a single page's details Fetch a specific scheduling page by UUID with `GET /event-types/{uuid}`. The detail response is richer than the list response — it includes notification settings, the assigned hosts (or Flexi host collections), and the full `inviteeQuestions` array. ```bash cURL theme={null} curl -X GET "https://api.zeeg.me/v2/event-types/80f46bf5-eb01-4c07-960e-a9a3e18aae5e" \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Accept: application/json" ``` ```python Python theme={null} import requests uuid = "80f46bf5-eb01-4c07-960e-a9a3e18aae5e" response = requests.get( f"https://api.zeeg.me/v2/event-types/{uuid}", headers={ "Authorization": "Bearer YOUR_TOKEN", "Accept": "application/json", }, ) page = response.json()["resource"] ``` ```javascript JavaScript theme={null} const uuid = "80f46bf5-eb01-4c07-960e-a9a3e18aae5e"; const response = await fetch( `https://api.zeeg.me/v2/event-types/${uuid}`, { headers: { Authorization: "Bearer YOUR_TOKEN", Accept: "application/json", }, } ); const { resource: page } = await response.json(); ``` ### Fields most integrations care about | Field | Why it matters | | - | - | | `slug` | Used as `eventTypeSlug` when [creating a booking](/guides/booking-flow). | | `profile.slug` | Used as `ownerSlug` when creating a booking. | | `isActive` | `false` means the page is hidden from public scheduling; bookings will be rejected. | | `duration` / `eventDurations` | Default duration plus any alternative durations the invitee can pick. | | `inviteePhoneNumber` | When `true`, bookings against this page must include a `phone` field. | | `inviteeQuestions` | Custom questions with their numeric `id`s. Pass answers via `question_answers` when booking. | | `type` | `ONE_ON_ONE`, `GROUP`, `ROUND_ROBIN`, `COLLECTIVE`, or `FLEXI`. Shared types use `shared` as the owner slug. | | `hosts` / `flexiCollections` | Assigned hosts. `hosts` is populated for everything except FLEXI pages. | | `notifications` | Per-page email and calendar invite behaviour. | For the complete schema, see the [Get a scheduling page](/api/scheduling-pages/get-a-scheduling-page) API reference. ## Toggling active/inactive Send `PUT /event-types/{uuid}/status` to flip a page's active state. The endpoint takes no body — it toggles whatever the current state is. Active pages accept bookings; inactive pages do not appear at the public URL and reject API bookings. ```bash cURL theme={null} curl -X PUT "https://api.zeeg.me/v2/event-types/80f46bf5-eb01-4c07-960e-a9a3e18aae5e/status" \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Accept: application/json" ``` ```python Python theme={null} import requests uuid = "80f46bf5-eb01-4c07-960e-a9a3e18aae5e" response = requests.put( f"https://api.zeeg.me/v2/event-types/{uuid}/status", headers={ "Authorization": "Bearer YOUR_TOKEN", "Accept": "application/json", }, ) ``` ```javascript JavaScript theme={null} const uuid = "80f46bf5-eb01-4c07-960e-a9a3e18aae5e"; await fetch(`https://api.zeeg.me/v2/event-types/${uuid}/status`, { method: "PUT", headers: { Authorization: "Bearer YOUR_TOKEN", Accept: "application/json", }, }); ``` ### Example response ```json theme={null} { "success": true, "message": "Success" } ``` This endpoint **toggles** — it does not let you set an explicit target state. If you need to ensure a page ends up active (or inactive) regardless of its current state, fetch the page first, check `isActive`, and only call the toggle endpoint when the current state differs from your target. ```python Python theme={null} def ensure_active(uuid: str, target: bool) -> None: page = requests.get( f"https://api.zeeg.me/v2/event-types/{uuid}", headers={"Authorization": "Bearer YOUR_TOKEN"}, ).json()["resource"] if page["isActive"] != target: requests.put( f"https://api.zeeg.me/v2/event-types/{uuid}/status", headers={"Authorization": "Bearer YOUR_TOKEN"}, ) ``` ## Issuing single-use links A single-use link lets you give a specific customer a unique URL that can be redeemed exactly once. After the booking is confirmed, the link can no longer be used. This is the most powerful programmatic feature exposed on scheduling pages — use it any time you want to bind a single booking to a specific recipient (post-purchase scheduling, support escalations, gated demo invites, etc.). Generate one with `GET /event-types/{uuid}/single-use-link`. ### Query parameters | Parameter | Type | Description | | - | - | - | | `price` | number | Optional. For paid scheduling pages, override the default price for this single-use link. Useful for issuing discounted or free bookings to specific recipients. The override applies to all configured durations on the page. | ```bash cURL theme={null} curl -X GET "https://api.zeeg.me/v2/event-types/80f46bf5-eb01-4c07-960e-a9a3e18aae5e/single-use-link" \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Accept: application/json" ``` ```python Python theme={null} import requests uuid = "80f46bf5-eb01-4c07-960e-a9a3e18aae5e" response = requests.get( f"https://api.zeeg.me/v2/event-types/{uuid}/single-use-link", headers={ "Authorization": "Bearer YOUR_TOKEN", "Accept": "application/json", }, ) link = response.json()["link"] ``` ```javascript JavaScript theme={null} const uuid = "80f46bf5-eb01-4c07-960e-a9a3e18aae5e"; const response = await fetch( `https://api.zeeg.me/v2/event-types/${uuid}/single-use-link`, { headers: { Authorization: "Bearer YOUR_TOKEN", Accept: "application/json", }, } ); const { link } = await response.json(); ``` ### Example response ```json theme={null} { "success": true, "link": "https://zeeg.me/S/axo3kbv54/30min-discovery-call", "eventTypeUri": "https://api.zeeg.me/v2/event-types/80f46bf5-eb01-4c07-960e-a9a3e18aae5e" } ``` The `link` field contains the URL to share with the recipient. Once they complete a booking through it, the link is consumed and any subsequent attempts to use it will fail. Each call to `GET /event-types/{uuid}/single-use-link` returns a **new** link. Generate one link per recipient — never reuse the same single-use link for two different people. ### Worked example: issue a link on customer signup A common pattern is to mint a single-use link the moment a customer completes a paid signup, then email it to them so they can book a kickoff call exactly once. ```python Python theme={null} import requests ZEEG_TOKEN = "YOUR_TOKEN" KICKOFF_PAGE_UUID = "80f46bf5-eb01-4c07-960e-a9a3e18aae5e" def issue_kickoff_link(customer_email: str) -> str: """Issue a single-use kickoff link for a newly signed-up customer.""" response = requests.get( f"https://api.zeeg.me/v2/event-types/{KICKOFF_PAGE_UUID}/single-use-link", headers={ "Authorization": f"Bearer {ZEEG_TOKEN}", "Accept": "application/json", }, timeout=10, ) response.raise_for_status() link = response.json()["link"] send_email( to=customer_email, subject="Book your kickoff call", body=f"Welcome aboard! Pick a time that works for you: {link}", ) return link # In your signup webhook handler: # issue_kickoff_link(customer.email) ``` ```javascript JavaScript theme={null} const ZEEG_TOKEN = "YOUR_TOKEN"; const KICKOFF_PAGE_UUID = "80f46bf5-eb01-4c07-960e-a9a3e18aae5e"; async function issueKickoffLink(customerEmail) { const response = await fetch( `https://api.zeeg.me/v2/event-types/${KICKOFF_PAGE_UUID}/single-use-link`, { headers: { Authorization: `Bearer ${ZEEG_TOKEN}`, Accept: "application/json", }, } ); if (!response.ok) { throw new Error(`Failed to mint single-use link: ${response.status}`); } const { link } = await response.json(); await sendEmail({ to: customerEmail, subject: "Book your kickoff call", body: `Welcome aboard! Pick a time that works for you: ${link}`, }); return link; } ``` Don't mint single-use links until you actually need them. Generating one for every record in a CRM list "just in case" creates URLs that may sit unused — and you'll have no easy way to map them back to recipients later. Mint on demand, log the recipient alongside the link, and let the [`invitee.scheduled`](/guides/webhooks-guide) webhook tell you when each link has been consumed. ## Composing with the booking flow Once you have a scheduling page (or a single-use link), the next step is fetching availability and creating the booking. That's covered end-to-end in: Fetch a scheduling page's available slots and create a booking via the API. ## Rate limits and error handling * All endpoints in this guide return standard Zeeg error codes — see [Errors](/errors) for the full list and recommended client behaviour. * API requests are subject to per-token limits — see [Rate Limits](/rate-limits) before bulk-issuing single-use links or syncing many pages. * A `404` from the toggle or single-use-link endpoints means the UUID does not match a scheduling page accessible by your token. Double-check the UUID and the token's owner. # Syncing Employee Time-Off Source: https://developer.zeeg.me/guides/syncing-employee-time-off Push vacations and out-of-office periods from your HRIS (BambooHR, Personio, Workday, a spreadsheet) into Zeeg so bookings never land on someone who's away. This guide walks you through pushing employee absences — vacations, out-of-office days, sabbaticals — from an HRIS (or any system of record for time off) into Zeeg. Once a period exists in Zeeg, that user becomes unavailable for bookings on the affected days, on every scheduling page they own or share. The endpoints in this guide live under the **Availability Schedule** API and require a token with `schedules:write` for mutations and `schedules:read` for reads. Mapping HRIS users to Zeeg additionally needs `users:read` (or `admin:full`). ## Concepts Three different objects affect a user's availability. Pick the right one for the job: | Object | What it is | When to use it | | - | - | - | | **Time-off period** (`/time-off`) | A dated range when a specific user is unavailable — `vacation` or `out_of_office`. Optional half-day cutoffs on the first and/or last day. | This guide. Sync vacations, OOO days, sabbaticals from your HRIS. | | **Availability schedule** (`/schedules`) | A user's recurring weekly working hours, plus per-date overrides (`specialHours`). | Permanent changes to working hours, e.g. someone moves to a 4-day week. | | **Holiday subscription** (`/holidays/subscriptions`) | A subscription to a country's or region's public holidays. | Country-wide closures (e.g. all German users blocked on Dec 25). | This guide focuses on **time-off periods**. Holiday subscriptions and weekly schedules are covered briefly at the end. ### Time-off shape A time-off period is **all-day by default** and identified by date, not datetime — there is no `startTime`/`endTime`/`timeZone` on the resource. Half-day flags (`startHalfDay`, `endHalfDay`) with `HH:MM` cutoffs let the first or last day be partial; the cutoff is interpreted in the user's schedule timezone. | Field | Type | Notes | | - | - | - | | `type` | `vacation` \| `out_of_office` | Required. | | `title` | string (≤255) | Required on create. | | `startDate` / `endDate` | date (`YYYY-MM-DD`) | Required. Inclusive on both ends. | | `startHalfDay` / `endHalfDay` | boolean | Default `false`. | | `startHalfDayCutoff` / `endHalfDayCutoff` | `HH:MM` | Required when the matching half-day flag is `true`. | | `note` | string (≤1000), nullable | Free-form. Useful as a forensic tag for the source HRIS record. | ## Prerequisites * A Zeeg admin or owner account with API access enabled. * An API token with the following scopes: * `users:read` (or `admin:full`) — list workspace users. * `schedules:read` — list existing time-off. * `schedules:write` — create, update, delete time-off. * The token owner must have edit rights on each target user's schedules (i.e. be the user themselves, an admin/owner, or the team manager). Targets the token can't edit are reported as `failed` — they don't fail the whole request. ## End-to-end sync flow Time-off endpoints accept any of three identifiers per user: `email`, `slug`, or workspace `uuid`. Email is the most stable join key against most HRIS systems, so cache an email → Zeeg `uuid` map at the start of every sync. ```bash cURL theme={null} curl -X GET "https://api.zeeg.me/v2/organizations/users?per_page=100&page=1" \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Accept: application/json" ``` ```python Python theme={null} import requests def fetch_zeeg_users(token): users = {} page = 1 while True: resp = requests.get( "https://api.zeeg.me/v2/organizations/users", params={"per_page": 100, "page": page}, headers={ "Authorization": f"Bearer {token}", "Accept": "application/json", }, ) resp.raise_for_status() body = resp.json() for u in body["collection"]: if u["isActive"]: users[u["email"].lower()] = u if page >= body["pagination"]["totalPages"]: break page += 1 return users ``` ```javascript JavaScript theme={null} async function fetchZeegUsers(token) { const users = {}; let page = 1; while (true) { const resp = await fetch( `https://api.zeeg.me/v2/organizations/users?per_page=100&page=${page}`, { headers: { Authorization: `Bearer ${token}`, Accept: "application/json", }, } ); const body = await resp.json(); for (const u of body.collection) { if (u.isActive) users[u.email.toLowerCase()] = u; } if (page >= body.pagination.totalPages) break; page += 1; } return users; } ``` Lowercase emails on both sides of the join — Zeeg stores them as the user typed them. Skip inactive users (`isActive: false`); attempting to write time-off for them returns `failed`. **HRIS users with no Zeeg account.** This will happen — contractors, new hires not yet provisioned, deactivated employees still in the HRIS. Log them and skip; do not try to invite them implicitly. `POST /time-off` is **bulk-only**. Each request creates the same period for one set of users (up to 10) **or** every active member of one team. There is no single-user create endpoint — to create one period for one person, send a `users.emails` array with one entry. Provide **exactly one** of `users` or `team`. Inside `users`, provide **exactly one** of `emails`, `slugs`, or `uuids`. **Example: Alice is on vacation Mon–Fri next week.** ```bash cURL theme={null} curl -X POST "https://api.zeeg.me/v2/time-off" \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{ "users": { "emails": ["alice.weber@horizondigital.de"] }, "type": "vacation", "title": "Spring break", "startDate": "2026-05-11", "endDate": "2026-05-15", "note": "[hris-id:bamboo-12345]" }' ``` ```python Python theme={null} import requests resp = requests.post( "https://api.zeeg.me/v2/time-off", headers={ "Authorization": "Bearer YOUR_TOKEN", "Content-Type": "application/json", "Accept": "application/json", }, json={ "users": {"emails": ["alice.weber@horizondigital.de"]}, "type": "vacation", "title": "Spring break", "startDate": "2026-05-11", "endDate": "2026-05-15", "note": "[hris-id:bamboo-12345]", }, ) result = resp.json() ``` ```javascript JavaScript theme={null} const resp = await fetch("https://api.zeeg.me/v2/time-off", { method: "POST", headers: { Authorization: "Bearer YOUR_TOKEN", "Content-Type": "application/json", Accept: "application/json", }, body: JSON.stringify({ users: { emails: ["alice.weber@horizondigital.de"] }, type: "vacation", title: "Spring break", startDate: "2026-05-11", endDate: "2026-05-15", note: "[hris-id:bamboo-12345]", }), }); const result = await resp.json(); ``` The response is a per-target outcome list. Each entry's `status` is `created`, `replaced`, or `failed` — handle all three: ```json theme={null} { "users": [ { "email": "alice.weber@horizondigital.de", "slug": "alice-weber", "uuid": "AliceK2QwR9XPv", "status": "created", "period": { "uuid": "01907f10-5b00-7b31-9cf0-1234567890ab", "uri": "https://api.zeeg.me/v2/time-off/01907f10-5b00-7b31-9cf0-1234567890ab", "type": "vacation", "title": "Spring break", "startDate": "2026-05-11", "endDate": "2026-05-15", "startHalfDay": false, "endHalfDay": false, "startHalfDayCutoff": null, "endHalfDayCutoff": null, "note": "[hris-id:bamboo-12345]" }, "replaced": [] } ] } ``` The HTTP status is `200` if at least one entry succeeded; `422` only when **every** target failed. Always inspect each entry's `status` rather than trusting the HTTP code alone. **Half-day example.** Alice leaves at 13:00 on Friday afternoon: ```json theme={null} { "users": { "emails": ["alice.weber@horizondigital.de"] }, "type": "vacation", "title": "Long weekend", "startDate": "2026-05-15", "endDate": "2026-05-15", "startHalfDay": true, "startHalfDayCutoff": "13:00" } ``` The cutoff is interpreted against the user's schedule, not the caller's timezone — verify against your users' configured timezones if half-day precision matters. `PATCH /time-off/{uuid}` partially updates a period. Only supplied fields change; omitted fields are left alone. ```bash cURL theme={null} curl -X PATCH "https://api.zeeg.me/v2/time-off/01907f10-5b00-7b31-9cf0-1234567890ab" \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{ "startDate": "2026-05-12", "endDate": "2026-05-16" }' ``` ```python Python theme={null} import requests resp = requests.patch( f"https://api.zeeg.me/v2/time-off/{uuid}", headers={ "Authorization": "Bearer YOUR_TOKEN", "Content-Type": "application/json", "Accept": "application/json", }, json={"startDate": "2026-05-12", "endDate": "2026-05-16"}, ) ``` ```javascript JavaScript theme={null} const resp = await fetch(`https://api.zeeg.me/v2/time-off/${uuid}`, { method: "PATCH", headers: { Authorization: "Bearer YOUR_TOKEN", "Content-Type": "application/json", Accept: "application/json", }, body: JSON.stringify({ startDate: "2026-05-12", endDate: "2026-05-16", }), }); ``` `DELETE /time-off/{uuid}` removes a period: ```bash theme={null} curl -X DELETE "https://api.zeeg.me/v2/time-off/01907f10-5b00-7b31-9cf0-1234567890ab" \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Accept: application/json" ``` **PATCH rejects overlap.** If the new `startDate`/`endDate` would overlap another time-off period the same user already owns, PATCH returns `422` with `This time-off period overlaps with an existing one.` This is the opposite of POST — see the next step. The `/time-off` resource has no native external-id lookup, so re-running a sync needs care to avoid duplicates. Use the two mechanisms Zeeg gives you: **1. Built-in overlap-replace on POST.** When you `POST /time-off` for a user and the new range overlaps an existing period, Zeeg deletes the old period(s), creates the new one, and returns `status: "replaced"` with the deleted UUIDs in `replaced[]`. This makes re-posting the same vacation safe-by-default — the second call replaces the first instead of duplicating. ```json theme={null} { "users": [ { "email": "alice.weber@horizondigital.de", "slug": "alice-weber", "uuid": "AliceK2QwR9XPv", "status": "replaced", "period": { "uuid": "01907f10-...new-uuid", "...": "..." }, "replaced": ["01907f10-...old-uuid"] } ] } ``` **2. An external-id → Zeeg-uuid map on your side.** Overlap-replace handles re-syncs of the same vacation, but it doesn't help when: * The HRIS shifts a vacation to a non-overlapping range (old: May 11–15, new: May 18–22). POST creates a second period instead of moving the first. You need the original UUID to PATCH or DELETE. * The HRIS deletes a vacation. Zeeg doesn't know about the source-of-truth deletion; you must DELETE explicitly. * You want to detect drift between HRIS and Zeeg (e.g. someone manually created a Zeeg-side period that has no HRIS counterpart). So the canonical pattern is: **keep a persistent map** (`hris_record_id` → `zeeg_period_uuid`, plus the user identifier and the last-seen range) on the integration side. The map is your primary lookup. The `note` field is a fine **forensic** breadcrumb (e.g. `[hris-id:bamboo-12345]`) — it shows up in the dashboard and helps humans audit — but `note` is not query-filterable, so it's a complement to the map, not a substitute. When you want to *move* a period to a non-overlapping range, choose between POST and PATCH: * **PATCH** if you have the UUID and the new range doesn't overlap any other of that user's periods. PATCH is cheaper and preserves the same UUID downstream. * **POST** if you don't have the UUID, or the new range overlaps another existing period you'd actually like to absorb. Putting it together. The sync runs against three sources of state — your HRIS, your local map, and Zeeg — and produces three sets of operations: create, update, delete. ```python Python theme={null} # Pseudocode for an HRIS → Zeeg time-off reconciliation pass. # Assumes you have: # hris_records: list of dicts with {id, employee_email, type, start, end, title} # local_map: dict mapping hris_id -> {zeeg_uuid, email, start, end, type, title} # zeeg_users: dict mapping email.lower() -> Zeeg user (from step 1) import requests API = "https://api.zeeg.me/v2" HEADERS = { "Authorization": "Bearer YOUR_TOKEN", "Content-Type": "application/json", "Accept": "application/json", } def upsert(record, zeeg_user): """Create a new period (overlap-replace handles re-runs).""" body = { "users": {"emails": [zeeg_user["email"]]}, "type": record["type"], # "vacation" or "out_of_office" "title": record["title"], "startDate": record["start"], "endDate": record["end"], "note": f"[hris-id:{record['id']}]", } resp = requests.post(f"{API}/time-off", headers=HEADERS, json=body) payload = resp.json() entry = payload["users"][0] if entry["status"] in ("created", "replaced"): return entry["period"]["uuid"] raise RuntimeError(f"Failed for {record['id']}: {entry.get('reason')}") def patch(zeeg_uuid, fields): resp = requests.patch(f"{API}/time-off/{zeeg_uuid}", headers=HEADERS, json=fields) resp.raise_for_status() def delete(zeeg_uuid): resp = requests.delete(f"{API}/time-off/{zeeg_uuid}", headers=HEADERS) if resp.status_code == 404: return # already gone, that's fine resp.raise_for_status() seen_hris_ids = set() for record in hris_records: seen_hris_ids.add(record["id"]) zeeg_user = zeeg_users.get(record["employee_email"].lower()) if not zeeg_user: log_skip(record, reason="no Zeeg account") continue cached = local_map.get(record["id"]) if cached is None: # New HRIS record -> POST (overlap-replace handles incidental dupes). new_uuid = upsert(record, zeeg_user) local_map[record["id"]] = { "zeeg_uuid": new_uuid, "email": zeeg_user["email"], "start": record["start"], "end": record["end"], "type": record["type"], "title": record["title"], } elif (cached["start"], cached["end"], cached["type"], cached["title"]) != ( record["start"], record["end"], record["type"], record["title"] ): # Existing record changed -> PATCH if range is safe, else POST + replace. try: patch(cached["zeeg_uuid"], { "type": record["type"], "title": record["title"], "startDate": record["start"], "endDate": record["end"], }) except requests.HTTPError as e: # 422 on overlap -> fall back to POST, which replaces conflicts. if e.response.status_code == 422: new_uuid = upsert(record, zeeg_user) local_map[record["id"]]["zeeg_uuid"] = new_uuid else: raise local_map[record["id"]].update({ "start": record["start"], "end": record["end"], "type": record["type"], "title": record["title"], }) # Anything in the map that the HRIS didn't return -> deleted upstream. for hris_id in list(local_map): if hris_id not in seen_hris_ids: delete(local_map[hris_id]["zeeg_uuid"]) del local_map[hris_id] ``` A few things this loop intentionally handles: * **Idempotency on first run.** If your map is empty but Zeeg already has matching periods, the POST overlap-replace logic absorbs them — you don't end up with double-booked time-off. * **Range moves.** PATCH first (preserves the UUID), POST as fallback when PATCH hits a `422` overlap. * **Upstream deletions.** Anything in the map but not in this HRIS pull is treated as deleted. * **Failed entries.** `upsert` raises on `failed` status (often: token can't edit that user's schedules) so you can log and continue. ## Adjacent topics ### Public holidays For country- or region-wide closures (Christmas, Bavarian regional holidays, US Thanksgiving), use **holiday subscriptions** instead of creating time-off for every user every year: ```bash theme={null} GET /holidays/categories # list supported countries + regions GET /holidays/preview?categoryKey=DE # preview which dates would be blocked GET /holidays/subscriptions?email=... # list a user's current subscriptions POST /holidays/subscriptions # subscribe a user (or team) to a category ``` Subscriptions auto-roll forward each year — you subscribe once and the holidays keep coming. See the [Holidays API reference](/api/holidays/list-holiday-categories) for the full surface. ### Recurring weekly availability For permanent working-hour changes (someone moves to a 4-day week, or starts an hour later on Wednesdays), update the user's **availability schedule** with `PATCH /schedules/{uuid}`. Use `weeklyHours` for the recurring pattern and `specialHours` for date-specific overrides. See [`PATCH /schedules/{uuid}`](/api/availability/update-availability-schedule-by-uuid). Don't use `specialHours` to model vacations — that's what `/time-off` is for. `specialHours` is for *altering* the working day on a date (e.g. "I'm only available 09:00–11:00 on this date"); time-off is for *removing* it. ## Rate limits and error handling * The bulk POST endpoint accepts up to **10 users per request** or **one team of up to 200 active members**. Chunk larger HRIS pulls into multiple requests. * For `429 Too Many Requests` responses, follow the [exponential backoff guidance](/rate-limits) — 1s, 2s, 4s, … capped at 60s, with a small jitter. * See [Errors](/errors) for the standard error envelope. Common time-off-specific cases: * `200` with `status: "failed"` — token can't edit that user's schedules. Don't retry; surface to a human. * `422 No updatable fields were provided` on PATCH — the request body was empty. * `422 This time-off period overlaps with an existing one` on PATCH — fall back to POST or pick a non-overlapping range. * `404 Time-off period not found` on PATCH/DELETE — the UUID was already deleted, or the token can't see it. Treat DELETE 404 as success; treat PATCH 404 as a stale map entry to be cleaned up. Run the sync on a schedule (hourly or every 15 minutes is typical), not in response to every HRIS webhook. Reconciliation is naturally idempotent and absorbs missed events; per-event delivery isn't worth the complexity for time-off. # Webhooks Source: https://developer.zeeg.me/guides/webhooks-guide Subscribe to Zeeg webhooks to receive real-time notifications when events are scheduled, rescheduled, cancelled, or when routing forms are submitted. Webhooks let you receive real-time HTTP POST notifications when events happen in Zeeg. Instead of polling the API, you register a callback URL and Zeeg pushes event data to you the moment something occurs. Zeeg supports six event types: | Event | Trigger | | - | - | | `invitee.scheduled` | An invitee books, or a booking is rescheduled | | `invitee.cancelled` | An invitee or host cancels a booking | | `invitee.withdrawn` | An invitee exercises a statutory right of withdrawal on a paid booking. `invitee.cancelled` is also delivered for the same booking | | `invitee.no_show` | A host marks an invitee as a no-show, in the dashboard, from Slack or with `POST /scheduled-invitees/{uuid}/no-show`. Undoing the marking sends no webhook | | `routing_form.submitted` | A visitor submits a routing form | | `ai_agent.call_completed` Alpha | An AI agent call reaches a terminal state | ## Setting up webhooks Create a webhook subscription by sending a `POST` request to `/webhooks`. ### Required fields | Field | Type | Description | | - | - | - | | `callbackUrl` | string | The URL Zeeg will send events to. Must be publicly reachable. | | `events` | array | One or more event types to subscribe to. | | `scope` | string | `user` or `organization`. | ### Optional fields | Field | Type | Description | | - | - | - | | `token` | string | A secret token included in delivery headers for verification. | ### Scope explained * **`user`** — You only receive events related to your own scheduling pages. * **`organization`** — You receive events for all members of your organization. Requires an API token belonging to an admin or owner. `routing_form.submitted` and `ai_agent.call_completed` deliver to `organization`-scoped webhooks only. Subscribing a `user`-scoped webhook to either is rejected with a 422, at creation and on every later update — scope cannot change once a webhook exists. ### Example ```bash cURL theme={null} curl -X POST https://api.zeeg.me/v2/webhooks \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "callbackUrl": "https://example.com/webhooks/zeeg", "events": ["invitee.scheduled", "invitee.cancelled"], "scope": "organization", "token": "my-secret-verification-token" }' ``` ```python Python theme={null} import requests response = requests.post( "https://api.zeeg.me/v2/webhooks", headers={ "Authorization": "Bearer YOUR_TOKEN", "Content-Type": "application/json", }, json={ "callbackUrl": "https://example.com/webhooks/zeeg", "events": ["invitee.scheduled", "invitee.cancelled"], "scope": "organization", "token": "my-secret-verification-token", }, ) print(response.json()) ``` ```javascript JavaScript theme={null} const response = await fetch("https://api.zeeg.me/v2/webhooks", { method: "POST", headers: { Authorization: "Bearer YOUR_TOKEN", "Content-Type": "application/json", }, body: JSON.stringify({ callbackUrl: "https://example.com/webhooks/zeeg", events: ["invitee.scheduled", "invitee.cancelled"], scope: "organization", token: "my-secret-verification-token", }), }); const data = await response.json(); console.log(data); ``` ```php PHP theme={null} $ch = curl_init("https://api.zeeg.me/v2/webhooks"); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer YOUR_TOKEN", "Content-Type: application/json", ], CURLOPT_POSTFIELDS => json_encode([ "callbackUrl" => "https://example.com/webhooks/zeeg", "events" => ["invitee.scheduled", "invitee.cancelled"], "scope" => "organization", "token" => "my-secret-verification-token", ]), ]); $response = curl_exec($ch); curl_close($ch); echo $response; ``` ### Response ```json theme={null} { "success": true, "resource": { "uuid": "9a39bf60-a6c3-45e7-80cd-2cd36e520861", "name": null, "description": null, "callbackUrl": "https://example.com/webhooks/zeeg", "scope": "organization", "creator": { "firstName": "Lena", "lastName": "Meier", "slug": "lena-meier" }, "events": ["invitee.scheduled", "invitee.cancelled"], "organization": null, "apiVersion": null, "createdAt": "2026-04-10T08:30:00.000000Z", "updatedAt": "2026-04-10T08:30:00.000000Z" } } ``` ## Verifying webhook origin If you provide a `token` when creating the webhook subscription, Zeeg sends it in the `Token` header of every webhook request. Use this value to verify that incoming requests actually come from Zeeg and not a third party. ```python Python theme={null} from flask import Flask, request, abort WEBHOOK_TOKEN = "my-secret-verification-token" app = Flask(__name__) @app.route("/webhooks/zeeg", methods=["POST"]) def handle_webhook(): token = request.headers.get("Token") if token != WEBHOOK_TOKEN: abort(403) payload = request.json # Process the event return "", 200 ``` ## Event payloads ### invitee.scheduled / invitee.cancelled / invitee.withdrawn / invitee.no\_show All four event types share the same payload structure. Cancellation-specific, no-show-specific and reschedule-specific fields are populated only when relevant. ```json theme={null} { "event": "invitee.scheduled", "eventUri": "https://api.zeeg.me/v2/scheduled-events/zg-O69bac566950c6", "inviteeSalutation": "Ms.", "inviteeFirstName": "Sophie", "inviteeLastName": "Laurent", "inviteeName": "Sophie Laurent", "inviteeEmail": "sophie.laurent@northwind.io", "inviteePhoneNumber": "+49 170 9876543", "inviteeTimezone": "Europe/Paris", "inviteeNumberOfSeats": 1, "teamName": null, "hosts": { "1": "Lena Meier" }, "hostsDetails": [ { "slug": "lena-meier", "firstName": "Lena", "lastName": "Meier", "email": "lena.meier@horizondigital.de", "fullName": "Lena Meier" } ], "title": "30-Minute Discovery Call", "duration": 30, "durationPretty": "30 mins", "type": "ONE_ON_ONE", "eventTypeUri": "https://api.zeeg.me/v2/event-types/80f46bf5-eb01-4c07-960e-a9a3e18aae5e", "singleUseLink": null, "startAt": "2026-04-15T09:00:00+00:00", "inviteeStartAt": "2026-04-15T11:00:00+02:00", "inviteeStartAtPretty": "11:00 - Wednesday, April 15, 2026", "endAt": "2026-04-15T09:30:00+00:00", "inviteeEndAt": "2026-04-15T11:30:00+02:00", "inviteeEndAtPretty": "11:30 - Wednesday, April 15, 2026", "location": "Google Meet", "locationLink": "https://meet.google.com/abc-defg-hij", "questions": [ { "question": "What would you like to discuss?", "answer": "Product demo and pricing options" } ], "questionsAndAnswers": { "What would you like to discuss?": "Product demo and pricing options" }, "price": null, "pricePerSeat": null, "currency": null, "paymentGateway": null, "transactionId": null, "guests": ["alex.chen@northwind.io"], "eventUuid": "zg-O69bac566950c6", "inviteeUuid": "zg-O69bad4047abf0", "cancelUrl": "https://zeeg.me/cancel/zg-O69bad4047abf0", "rescheduleUrl": "https://zeeg.me/rescheduling/zg-O69bad4047abf0", "cancelled": false, "cancelledAt": null, "cancelledBy": null, "cancellationReason": null, "rescheduled": false, "rescheduledAt": null, "rescheduledBy": null, "rescheduleReason": null, "oldEventUri": null, "oldEventUuid": null, "oldInviteeUuid": null, "oldStartAt": null, "oldInviteeStartAt": null, "newEventUri": null, "newEventUuid": null, "newInviteeUuid": null, "newStartAt": null, "newInviteeStartAt": null, "utmCampaign": "spring_launch", "utmSource": "linkedin", "utmMedium": "social", "utmTerm": null, "utmContent": null, "adAttribution": { "gclid": "Cj0KCQjw_ndBhCrARIsAAy", "fbclid": "IwAR2xq9Zt", "landingUrl": "https://zeeg.me/lena-meier/30min?gclid=Cj0KCQjw_ndBhCrARIsAAy" }, "withdrawal": { "withdrawn": false, "withdrawnAt": null, "deadline": "2026-04-24T21:59:59+00:00", "amountPaid": 2000, "currency": "EUR" }, "noShow": false, "noShowAt": null, "createdAt": "2026-04-10T08:30:00+00:00" } ``` #### Key fields | Field | Description | | - | - | | `eventUuid` | Unique event identifier in `zg-XXX` format. Use this for storage and correlation. | | `inviteeUuid` | Unique invitee identifier in `zg-XXX` format. Use this to track a specific booking across schedule/cancel/reschedule events. | | `hosts` | Object mapping host positions to names, e.g. `{"1": "Lena Meier"}`. | | `hostsDetails` | Array of host objects with `slug`, `firstName`, `lastName`, `email`, `fullName`. | | `inviteeStartAt` | The event start time in the **invitee's timezone**. Useful for display in confirmation emails or UIs. | | `cancelled` / `cancelledAt` / `cancelledBy` | Populated when the event is `invitee.cancelled`. `cancelledBy` indicates who initiated the cancellation. | | `withdrawal` | Present when the booking's event type grants a statutory right of withdrawal, otherwise `null`. `withdrawal.withdrawn` distinguishes a withdrawal from an ordinary cancellation; `withdrawal.amountPaid` is in minor units. | | `noShow` / `noShowAt` | `noShow` is `true` once a host marks the invitee as a no-show; `noShowAt` carries the UTC timestamp of the marking. | | `rescheduled` / `rescheduledAt` / `rescheduleReason` | Populated when a booking is rescheduled. | | `oldEventUuid` / `newEventUuid` | When rescheduled, links the old and new events together. | | `price` / `currency` / `paymentGateway` / `transactionId` | Populated when the booking involves a paid event. | | `guests` | Array of additional guest email addresses added by the invitee. | Store `inviteeUuid` (and `eventUuid`) on your side. Invitee-related UUIDs are the best identifiers for correlating bookings, cancellations, and reschedules across webhook deliveries, and they ensure future compatibility -- especially for Group events. ### routing\_form.submitted ```json theme={null} { "event": "routing_form.submitted", "reportId": "71621fb7-30b0-4d91-9f2e-a3e009cc6853", "routingFormId": "f1cbafc4-b646-4cf9-af29-193491b555d9", "routingFormName": "Inbound Lead Qualification", "routingFormSlug": "inbound-lead-qual", "routingFormUrl": "https://zeeg.me/RF/inbound-lead-qual", "routeType": "EVENT_TYPE", "routeCustomUrl": null, "headline": null, "isFallbackRoute": false, "eventType": { "id": "80f46bf5-eb01-4c07-960e-a9a3e18aae5e", "title": "30-Minute Discovery Call", "slug": "30min-discovery-call", "uri": "https://api.zeeg.me/v2/event-types/80f46bf5-eb01-4c07-960e-a9a3e18aae5e" }, "answers": [ { "question": "What is your company size?", "answer": "50-200 employees", "inputId": "f7c82298-6dd9-43bd-98e4-c437b5c0ae47", "order": 1 } ], "questionsAndAnswers": { "What is your company size?": "50-200 employees" }, "utmCampaign": "spring_launch", "utmSource": "linkedin", "utmMedium": null, "utmTerm": null, "utmContent": null, "adAttribution": { "gclid": "Cj0KCQjw_ndBhCrARIsAAy", "fbclid": "IwAR2xq9Zt", "landingUrl": "https://zeeg.me/RF/company-size?gclid=Cj0KCQjw_ndBhCrARIsAAy" }, "createdAt": "2026-04-10T08:30:00+00:00" } ``` #### Key fields | Field | Description | | - | - | | `routingFormId` | UUID of the routing form. | | `reportId` | Unique identifier for this specific submission. | | `routeType` | How the visitor was routed (e.g., `EVENT_TYPE`). | | `isFallbackRoute` | `true` if the default/fallback route was used. | | `eventType` | The event type the visitor was routed to (with `id`, `title`, `slug`, `uri`). | | `answers` / `questionsAndAnswers` | The visitor's responses. `answers` is an ordered array with `inputId` and `order`; `questionsAndAnswers` is a flat key-value map. | ### ai\_agent.call\_completed Alpha Fired once when an AI agent call reaches a terminal state (answered, voicemail, not answered, busy, or unknown). Contains the call outcome, transcript summary, any data the agent collected from the caller, actions the agent executed, and the booking created during the call (if any). ```json theme={null} { "event": "ai_agent.call_completed", "conversationId": "abc1234-0000-0000-0000-xyz987654321", "agentId": "9a39bf60-a6c3-45e7-80cd-2cd36e520861", "agentName": "Sales Agent", "direction": "inbound", "callOutcome": "answered", "isCall": true, "startedAt": "2026-06-01T10:00:00+00:00", "endedAt": "2026-06-01T10:01:30+00:00", "durationSeconds": 90, "contactPhoneNumber": "+4917612345678", "contactName": "Alice Example", "contactEmail": "alice@example.com", "agentPhoneNumber": "+4989123456789", "agentLanguage": "de", "conversationLanguage": "de", "transcript": [ { "role": "agent", "message": "Hello, thank you for calling. How can I help you today?", "timeInCallSeconds": 0 }, { "role": "user", "message": "Hi, I'd like to know more about your pricing.", "timeInCallSeconds": 4 }, { "role": "agent", "message": "Of course. Our plans start at 12 euros per month. Shall I book a follow-up call?", "timeInCallSeconds": 9 } ], "conversationAnalysis": { "transcriptSummary": "The caller asked about product pricing and requested a follow-up call next week.", "collectedData": { "name": { "value": "Alice Müller", "type": "string" }, "email": { "value": "alice@example.com", "type": "string" }, "message": { "value": "Please send me the pricing sheet.", "type": "string" }, "summary": { "value": "Caller asked about pricing and wants a follow-up.", "type": "string" } } }, "systemNotes": [], "actions": [ { "type": "book_event", "routeId": null, "calledAt": "2026-06-01T10:00:45+00:00" }, { "type": "send_email", "routeId": "9b1c2d3e-4f56-7890-abcd-ef0123456789", "calledAt": "2026-06-01T10:01:20+00:00" } ], "booking": { "uri": "https://api.zeeg.me/v2/scheduled-events/zg-O69bd55e004c8b", "uuid": "zg-O69bd55e004c8b", "title": "30-Minute Meeting with Sales Agent", "type": "ONE_ON_ONE", "startTime": "2026-06-03T14:00:00.000000Z", "endTime": "2026-06-03T14:30:00.000000Z", "duration": 30, "status": "confirmed", "eventTypeUri": "https://api.zeeg.me/v2/event-types/80f46bf5-eb01-4c07-960e-a9a3e18aae5e", "location": { "type": "google_meet", "joinUrl": "https://meet.google.com/abc-defg-hij" }, "maxActiveInvitees": 1, "activeInviteesCount": 1, "invitees": [ { "uuid": "zg-A12cd34ef005a9c", "salutation": null, "fullName": "Alice Müller", "email": "alice@example.com", "guests": [], "timeZone": "Europe/Berlin", "cancellation": { "cancelledAt": null, "cancelledBy": null, "cancellerType": null, "cancellationReason": null }, "payment": null, "questions": [], "scheduledAt": "2026-06-01T10:00:50.000000Z", "utm": { "utm_campaign": null, "utm_source": null, "utm_medium": null, "utm_content": null, "utm_term": null }, "customQueryParams": {}, "agentBookingReference": "123456" } ], "guests": [], "hosts": [ { "firstName": "Lena", "lastName": "Meier", "email": "lena.meier@example.com", "slug": "lena-meier", "url": "https://zeeg.me/lena-meier", "avatarUrl": null } ], "teamName": null, "createdAt": "2026-06-01T10:00:50.000000Z", "updatedAt": "2026-06-01T10:00:50.000000Z", "currentTime": "2026-06-01T10:01:30.000000Z" }, "crmPersonId": "7f3e2a91-1234-4abc-9012-b3c456d78901", "outboundCall": null } ``` #### Key fields | Field | Description | | - | - | | `conversationId` | Unique call identifier. Use this as the dedup key — in rare cases the same call may be delivered more than once. A delivery that fails is not retried. | | `callOutcome` | Terminal outcome: `answered`, `voicemail`, `not_answered`, `busy`, or `unknown`. | | `direction` | `inbound` (caller reached the agent) or `outbound` (agent dialled out). | | `transcript` | Ordered conversation turns, each with `role`, `message`, and `timeInCallSeconds`. Tool-only turns with no spoken message are omitted. | | `conversationAnalysis` | AI-generated analysis of the conversation. Contains `transcriptSummary` (narrative summary) and `collectedData` (a map of collected field id → `{ value, type }`, e.g. `name`, `email`, `phone`, `message`, `summary`, plus any custom fields). | | `systemNotes` | Notes Zeeg adds about the call, each `{ code, message }`; an empty array when there is nothing to note. `caller_phone_not_dialable` means the callback number the caller stated is not a dialable phone number, and `collectedData.phone` holds it as the caller gave it. `code` is `null` for an informational note. New codes can appear, so ignore one you do not recognize. `message` is English and its wording can change. | | `actions` | Ordered list of actions the agent executed. Each entry has a `type`, an optional `routeId` (the route that triggered it), and a `calledAt` timestamp. | | `booking` | The scheduled-event resource created during the call (same shape as `GET /v2/scheduled-events/{uuid}`), or `null` if no booking was made. Each entry in `invitees` carries `agentBookingReference` — the 6-digit reference for AI-agent bookings (`null` for non-AI bookings). | | `crmPersonId` | UUID of the CRM person record matched to this call, if any. | | `contactName` / `contactEmail` | The contact's name and email: what the caller stated on the call, else what you sent to the outbound-call API, else the matched CRM person. `null` when none is known. | | `agentLanguage` / `conversationLanguage` | The agent's primary language code and the language the conversation was held in (`null` if none was detected). | | `outboundCall` | For a call started through the outbound-call API: `id`, `fullName`, `email`, `additionalData` and `scheduledTime` as you sent them. `null` for inbound calls. | Use `conversationId` to deduplicate deliveries and `booking.uuid` to correlate with `invitee.scheduled` webhook events for the same booking. ## Managing webhooks | Operation | Method | Endpoint | | - | - | - | | List webhooks | `GET` | `/webhooks/scope/{scope}` | | Get webhook details | `GET` | `/webhooks/{uuid}` | | Delete webhook | `DELETE` | `/webhooks/{uuid}` | Replace `{scope}` with `user` or `organization`. You can also manage webhooks from the Zeeg dashboard: **[Account Settings > Webhooks](https://app.zeeg.me/account/settings/webhooks)** ## Auto-deletion Zeeg automatically deletes a webhook subscription if any of the following conditions are met: * **DNS cannot be resolved** for the callback URL. * The callback URL returns a **404 Not Found** response. * The callback URL returns a **410 Gone** response. When a webhook is auto-deleted, the subscription owner is notified by email. Monitor your inbox to avoid missing events silently. ## Best practices 1. **Respond quickly.** Return a `2xx` status code as fast as possible. Zeeg expects a timely response from your callback URL. 2. **Process asynchronously.** Queue the payload for background processing rather than doing heavy work inside the request handler. 3. **Use the verification token.** Always set a `token` when creating a webhook and validate it on every incoming request. 4. **Handle duplicates idempotently.** In rare cases, Zeeg may deliver the same event more than once. Use `eventUuid` or `inviteeUuid` to deduplicate. 5. **Monitor for auto-deletion emails.** If your endpoint goes down and Zeeg deletes the subscription, you will only know through the notification email. Set up alerting on your side. # Introduction Source: https://developer.zeeg.me/introduction Overview of the Zeeg REST API — endpoints, scopes, webhooks, and SDKs for building scheduling and booking integrations on the Zeeg platform. Welcome to the Zeeg API. Zeeg is a scheduling platform built in Europe that helps individuals and teams automate their scheduling workflows. Our API gives you programmatic access to the full power of Zeeg, so you can build custom integrations, automate operations, and embed scheduling into your own products. ## Get started Get up and running with your first API call in minutes. Learn how to authenticate your requests using API keys. Explore the full list of available endpoints and parameters. Receive real-time notifications when events happen in Zeeg. ## What can you build? The Zeeg API enables you to: * **Manage scheduled events** — list, cancel, and hand over scheduled events across your workspace. * **Query and create bookings** — programmatically create bookings or retrieve booking data for reporting and syncing. * **Manage availability schedules** — read and update availability rules for users and scheduling pages. * **Set up webhook subscriptions** — subscribe to events like booking creation, cancellation, or rescheduling for real-time notifications. * **Manage workspace users and teams** — invite members, assign roles, and organize teams within your workspace. * **Trigger AI agent outbound calls** — initiate AI-powered outbound calls through the API. ## Base URL All API requests are made to the following base URL: ``` https://api.zeeg.me/v2 ``` You are using **API version 2.0.0**. All endpoints documented here are part of this version. ## Authentication The Zeeg API uses Bearer token authentication. Include your token in the `Authorization` header of every request: ```bash theme={null} curl https://api.zeeg.me/v2/whoami \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Accept: application/json" ``` You can generate and manage your API tokens at [app.zeeg.me/account/settings/api-access](https://app.zeeg.me/account/settings/api-access). # Connect AI apps (MCP) Source: https://developer.zeeg.me/mcp-server Connect Claude, ChatGPT, Cursor, VS Code and other MCP clients to your Zeeg workspace: server URL, sign-in, the tool list, safety, and data processing. Zeeg runs a remote [MCP](https://modelcontextprotocol.io) server, so an AI app you already use — Claude, ChatGPT, Cursor, VS Code, or any other MCP client — can read and act on your Zeeg workspace: check availability, book and reschedule meetings, look up CRM people, read routing form submissions, review AI voice agent calls, and more. The MCP server is served from our own backend in Germany, the same servers and processing region as the rest of Zeeg. ## Server URL ``` https://api.zeeg.me/mcp ``` Every MCP client listed below connects to this one URL. There's nothing to install on our side and no separate account to create — you sign in with your existing Zeeg login. ## Connect from your AI app Go to **Settings → Connectors → Add custom connector**, paste the server URL, and follow the sign-in prompt. ```bash theme={null} claude mcp add --transport http zeeg https://api.zeeg.me/mcp ``` Turn on developer mode (or open **Connectors**) and add a new connector with the server URL above. Add a `zeeg` entry to `.cursor/mcp.json`: ```json theme={null} { "mcpServers": { "zeeg": { "url": "https://api.zeeg.me/mcp" } } } ``` Add the same shape to `.vscode/mcp.json`: ```json theme={null} { "servers": { "zeeg": { "type": "http", "url": "https://api.zeeg.me/mcp" } } } ``` ## Sign-in and consent The first tool call opens your browser to Zeeg. If you're not signed in, you'll see sign-in and create-account buttons; sign in or sign up, and you're sent straight back to finish connecting. You'll then see a Zeeg-branded consent page naming the app, the redirect it will use, the workspace you're connecting, and what it will be able to do. Approve it, and the app acts with **your own dashboard permissions** — a member sees what they see in the dashboard, an admin sees the whole workspace. It can't do anything you can't already do yourself. Manage or revoke a connection any time from **Settings → Connected apps**, which also shows every tool call the app has made. ## Plan requirement MCP access needs a **trial or paid** Zeeg plan. On a free plan, every tool returns a plain error explaining that MCP needs a paid plan or an active trial, with a link to billing — never a bare 401 or 403. Some tools need more than base access: * `find_available_times` and `book_meeting` need a **paid** plan (not trial) — the same premium-scope check the public API applies to available-time and booking-creation requests. * The voice agent tools (`list_voice_agents`, `list_agent_calls`, `get_agent_call` and `start_outbound_call`) need **AI minutes** in the workspace: an AI minutes subscription or enough AI credit. ## Tools Tool names are `snake_case`; every tool declares a title and whether it's read-only or destructive, which AI apps use to decide when to ask you before calling it. | Area | Tool | Does | Plan | | - | - | - | - | | Account | `get_me` | Signed-in user, workspace, plan status, and teammates | Trial/paid | | Event types | `list_event_types` | List your scheduling pages | Trial/paid | | Event types | `get_event_type` | Get one scheduling page's details | Trial/paid | | Event types | `create_single_use_link` | Create a one-time booking link for a scheduling page | Trial/paid | | Event types | `set_event_type_active` | Turn a scheduling page on or off | Trial/paid | | Availability | `find_available_times` | Find open slots for a scheduling page | **Paid** | | Availability | `get_schedules` | List working-hours schedules | Trial/paid | | Availability | `update_schedule` | Change a working-hours schedule | Trial/paid | | Availability | `add_time_off` | Block out days as unavailable | Trial/paid | | Bookings | `list_bookings` | List scheduled meetings | Trial/paid | | Bookings | `get_booking` | Get one meeting's details | Trial/paid | | Bookings | `book_meeting` | Book a meeting | **Paid** | | Bookings | `reschedule_booking` | Move a meeting; finds valid times itself when you pass none | Trial/paid | | Bookings | `cancel_booking` | Cancel a meeting | Trial/paid | | Bookings | `hand_over_booking` | Hand a round-robin meeting to a teammate | Trial/paid | | Bookings | `set_invitee_no_show` | Mark an invitee as a no-show, or undo it | Trial/paid | | Notes | `add_note` | Add a note to a booking or CRM record, written in Markdown | Trial/paid | | Notes | `list_notes` | List notes on a booking or CRM record, read back as Markdown (headings, lists, bold and links kept) | Trial/paid | | Notes | `update_note` | Replace a note's text; the Markdown `list_notes` returns can be passed back unchanged | Trial/paid | | Notes | `delete_note` | Delete a note | Trial/paid | | CRM | `search_crm` | Search people, companies, and custom objects | Trial/paid | | CRM | `get_crm_record` | Get one CRM record, with its owner and every custom field | Trial/paid | | CRM | `upsert_crm_records` | Create or update up to 50 CRM records of one object type per call, matched by id, email, domain or an attribute, with a result for each record | Trial/paid | | Routing forms | `list_routing_forms` | List routing forms | Trial/paid | | Routing forms | `list_form_submissions` | List a routing form's submissions | Trial/paid | | Voice agents | `list_voice_agents` | List your AI voice agents | **AI minutes** | | Voice agents | `list_agent_calls` | List one voice agent's calls | **AI minutes** | | Voice agents | `get_agent_call` | Get one voice agent call, with transcript and collected data on request | **AI minutes** | | Voice agents | `start_outbound_call` | Start an AI voice agent call; spends AI minutes | **AI minutes** | | Guidance | `get_zeeg_guide` | How Zeeg's objects relate, and which tool to call first | Trial/paid | `upsert_crm_records` takes `objectType` (`person`, `company` or a custom-object slug) and a list of up to 50 records. Each record has `values` and optionally `id`, `email` or `domain`. A failed record does not stop the others: the result lists `created`, `updated` and `failed` counts and, for each record, its `status`, `name`, link and `error`. For example, to reassign 30 people to one owner, call it once with 30 records, each `{ "id": "", "values": { "contactOwnerMemberId": "" } }`. `search_crm` and `get_booking` results carry a link straight to the record in your dashboard; so do `list_voice_agents`, `list_agent_calls` and `get_agent_call`. ## Safety * **Confirmation before it matters.** `book_meeting`, `cancel_booking`, `reschedule_booking`, `hand_over_booking`, and `start_outbound_call` are marked destructive, so a well-behaved AI app confirms with you before calling them — booking, cancelling, moving, handing over and calling all have a real-world effect, and `start_outbound_call` spends AI minutes. `update_schedule`, `add_time_off`, `upsert_crm_records`, `update_note`, `delete_note` and `set_event_type_active` are marked destructive too, because they overwrite or remove what's already there or take a booking page offline, and so is `set_invitee_no_show`, because a no-show can start workflows that email the invitee. Only `add_note` and `create_single_use_link` change data without being marked destructive, since they only add. * **Text from outside your workspace is marked as data, not instructions.** Booking answers, notes, and routing form submissions are written by invitees and form respondents. The server labels this content and tells the connected AI app never to treat it as instructions — a booking answer that says "ignore your previous instructions" stays inert. * **Every tool call is logged.** Which app, which tool, what it touched, and whether it succeeded — visible to you (and, for admins, the whole workspace) at **Settings → Connected apps**. Arguments and results aren't stored, only what happened. * **Lean output by default.** Tool results return what the task needs — a list of bookings, for instance, doesn't include phone numbers or form answers unless the app explicitly asks for them. ## Data processing Once you approve a connection, tool results — booking details, CRM records, form answers, and so on — are sent to the AI provider behind the app you connected (Anthropic for Claude, OpenAI for ChatGPT, and so on). That's the same as pasting the data into that app yourself. Zeeg's own processing and hosting stay in Germany, unchanged by connecting an AI app. ## For OAuth client builders The MCP server implements the [MCP authorization spec](https://modelcontextprotocol.io/specification/draft/basic/authorization) on top of our existing Passport OAuth: * **Discovery:** `GET /.well-known/oauth-authorization-server` and `GET /.well-known/oauth-protected-resource/mcp`, both under `https://api.zeeg.me`. * **Resource:** the protected-resource metadata's `resource` is exactly `https://api.zeeg.me/mcp`. * **PKCE:** required, `S256` only. An authorize request with `plain`, or without `code_challenge_method`, is refused with `invalid_request`. * **Issuer identification (RFC 9207):** every authorization response carries `iss`, equal to the metadata `issuer` (`authorization_response_iss_parameter_supported: true`). * **Dynamic client registration:** `POST /oauth/register`, open to any redirect host — the consent page names the redirect host and marks the app unverified unless it's on our first-party allowlist, so don't rely on registration itself as a trust signal. * **Client ID metadata documents (CIMD):** also supported (`client_id_metadata_document_supported: true`), and preferred over DCR for a high-traffic client — the metadata is fetched from the client's own `client_id` URL instead of registering a new row per connection. * **Client type:** public clients only — `token_endpoint_auth_methods_supported: ["none"]`. There's no client-secret flow. * **Scope:** every MCP client is pinned to `mcp:use` regardless of what it requests. A token that carries `mcp:use` is rejected by every `/v1` and `/v2` REST route, and a normal API token is rejected by the MCP endpoint — the two token types don't cross over. * **Token lifetimes:** access tokens expire after 1 hour; refresh tokens after 30 days and rotate on use — reusing a spent refresh token returns `invalid_grant`. * **Loopback redirects:** `http://localhost/callback` and `http://127.0.0.1/callback` match on any port, for local tools like Claude Code. * **Token endpoint:** accepts `application/x-www-form-urlencoded`, per the OAuth spec. * **Tool results:** every tool except `get_zeeg_guide` returns `structuredContent` described by its `outputSchema` in `tools/list`, plus the same JSON as a text block for clients that read only text. # Pagination Source: https://developer.zeeg.me/pagination How page-based pagination works in the Zeeg API. Use the count and page query parameters and walk through results with the nextPage cursor. Most list endpoints in the Zeeg API return paginated results. Instead of returning every record at once, responses are split into pages that you can iterate through. ## Parameters Every paginated endpoint accepts the following query parameters: | Parameter | Type | Default | Description | | - | - | - | - | | `count` | integer | `20` | Number of items per page. Min `1`, max `100`. | | `page` | integer | `1` | The page number to retrieve (1-indexed). | The `/organizations/users` endpoint uses `per_page` instead of `count`. All other paginated endpoints use `count`. ## Response structure Paginated responses include a `collection` array with the requested items and a `pagination` object with metadata for navigating between pages. ```json theme={null} { "collection": [ { "...": "..." } ], "pagination": { "total": 42, "count": 20, "totalPages": 3, "previousPage": "/v2/scheduled-events?page=1", "currentPage": 2, "nextPage": "/v2/scheduled-events?page=3" } } ``` The total number of items across all pages. The number of items returned in the current page. The total number of pages available. The path to the previous page of results. `null` when you are on the first page. The current page number. The path to the next page of results. `null` when you are on the last page. ## Iterating through pages Use `nextPage` to walk through all pages until it returns `null`. ```bash curl theme={null} # Fetch page 1 curl "https://api.zeeg.me/v2/scheduled-events?count=20&page=1" \ -H "Authorization: Bearer YOUR_TOKEN" # Fetch the next page curl "https://api.zeeg.me/v2/scheduled-events?count=20&page=2" \ -H "Authorization: Bearer YOUR_TOKEN" ``` ```python Python theme={null} import requests BASE_URL = "https://api.zeeg.me/v2" headers = {"Authorization": "Bearer YOUR_TOKEN"} page = 1 all_events = [] while True: response = requests.get( f"{BASE_URL}/scheduled-events", headers=headers, params={"count": 20, "page": page}, ) data = response.json() all_events.extend(data["collection"]) if data["pagination"]["nextPage"] is None: break page += 1 print(f"Fetched {len(all_events)} events in total.") ``` ```javascript JavaScript theme={null} const BASE_URL = "https://api.zeeg.me/v2"; const headers = { Authorization: "Bearer YOUR_TOKEN" }; async function fetchAllEvents() { let page = 1; const allEvents = []; while (true) { const res = await fetch( `${BASE_URL}/scheduled-events?count=20&page=${page}`, { headers } ); const data = await res.json(); allEvents.push(...data.collection); if (data.pagination.nextPage === null) break; page++; } return allEvents; } ``` Use a smaller `count` (e.g. `10`) if you need faster individual responses, or a larger `count` (up to `100`) to reduce the total number of requests. # Quickstart Source: https://developer.zeeg.me/quickstart Make your first authenticated request to the Zeeg API in under 5 minutes — generate an API key, verify your token, and list scheduled events. This guide walks you through making your first Zeeg API call. By the end, you will have verified your credentials and retrieved live data from your account. ## Prerequisites * A Zeeg account with an active subscription * An API token (generated in the next step) ## Base URL All requests in this guide use the following base URL: ``` https://api.zeeg.me/v2 ``` Go to **[Account Settings > API](https://app.zeeg.me/account/settings/api-access)** and generate a new token. Select the scopes your integration needs — for this quickstart, `users:read` and `events:read` are enough. Copy the token and store it somewhere safe. You will not be able to see it again. Treat your API token like a password. Do not commit it to version control or expose it in client-side code. Call `GET /whoami` to confirm your token is valid and see which account it belongs to. ```bash cURL theme={null} curl -X GET https://api.zeeg.me/v2/whoami \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Accept: application/json" ``` ```python Python theme={null} import requests response = requests.get( "https://api.zeeg.me/v2/whoami", headers={ "Authorization": "Bearer YOUR_TOKEN", "Accept": "application/json", }, ) print(response.json()) ``` ```javascript JavaScript theme={null} const response = await fetch("https://api.zeeg.me/v2/whoami", { headers: { Authorization: "Bearer YOUR_TOKEN", Accept: "application/json", }, }); const data = await response.json(); console.log(data); ``` ```php PHP theme={null} $ch = curl_init("https://api.zeeg.me/v2/whoami"); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer YOUR_TOKEN", "Accept: application/json", ], ]); $response = curl_exec($ch); curl_close($ch); echo $response; ``` You should receive a response like: ```json theme={null} {"email": "lena.meier@horizondigital.de"} ``` If you get a `401 Unauthorized` response, double-check that your token is correct and has not expired. Call `GET /scheduled-events` to retrieve your upcoming scheduled events. ```bash cURL theme={null} curl -X GET https://api.zeeg.me/v2/scheduled-events \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Accept: application/json" ``` ```python Python theme={null} import requests response = requests.get( "https://api.zeeg.me/v2/scheduled-events", headers={ "Authorization": "Bearer YOUR_TOKEN", "Accept": "application/json", }, ) print(response.json()) ``` ```javascript JavaScript theme={null} const response = await fetch("https://api.zeeg.me/v2/scheduled-events", { headers: { Authorization: "Bearer YOUR_TOKEN", Accept: "application/json", }, }); const data = await response.json(); console.log(data); ``` ```php PHP theme={null} $ch = curl_init("https://api.zeeg.me/v2/scheduled-events"); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer YOUR_TOKEN", "Accept: application/json", ], ]); $response = curl_exec($ch); curl_close($ch); echo $response; ``` This returns a paginated list of your scheduled events, each including the event title, start/end time, status, location, invitees, and hosts. If you have no upcoming events, the `collection` array will be empty. See [Pagination](/pagination) for details on navigating large result sets. Call `GET /event-types` to retrieve your scheduling pages (event types). ```bash cURL theme={null} curl -X GET https://api.zeeg.me/v2/event-types \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Accept: application/json" ``` ```python Python theme={null} import requests response = requests.get( "https://api.zeeg.me/v2/event-types", headers={ "Authorization": "Bearer YOUR_TOKEN", "Accept": "application/json", }, ) print(response.json()) ``` ```javascript JavaScript theme={null} const response = await fetch("https://api.zeeg.me/v2/event-types", { headers: { Authorization: "Bearer YOUR_TOKEN", Accept: "application/json", }, }); const data = await response.json(); console.log(data); ``` ```php PHP theme={null} $ch = curl_init("https://api.zeeg.me/v2/event-types"); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer YOUR_TOKEN", "Accept: application/json", ], ]); $response = curl_exec($ch); curl_close($ch); echo $response; ``` Each scheduling page in the response includes its title, slug, duration, active status, profile, and any custom invitee questions configured for the page. ## Next steps Now that you have made your first API calls, explore these resources to build deeper integrations: Create bookings programmatically through the API. Receive real-time notifications when events are created, cancelled, or rescheduled. Browse the full list of endpoints, parameters, and response schemas. # Rate Limits Source: https://developer.zeeg.me/rate-limits How the Zeeg API rate-limits requests, how to handle 429 responses with exponential backoff, and best practices to stay within your quota. The Zeeg API enforces rate limits to ensure fair usage and platform stability for all integrations. Limits may vary by endpoint and subscription plan. ## Handling Rate Limits When you exceed the rate limit, the API returns a `429 Too Many Requests` response. Wait before retrying — the response will indicate when you can send the next request. ## Retry Strategy Implement **exponential backoff** when you receive a `429` response. Wait progressively longer between retries to avoid overwhelming the API: 1. Wait 1 second, then retry. 2. If still rate-limited, wait 2 seconds, then retry. 3. Continue doubling the wait time up to a reasonable maximum (e.g., 60 seconds). Add a small random jitter to each wait interval to prevent multiple clients from retrying in sync. ## Best Practices * **Cache responses** when possible to reduce unnecessary API calls. * **Use webhooks** instead of polling for event updates such as booking changes or cancellations. * **Use pagination** with reasonable page sizes rather than fetching large datasets in a single request. * **Spread requests over time** rather than sending bursts of parallel requests to the same endpoint. ## Need Higher Limits? If your integration requires higher rate limits, contact [support@zeeg.me](mailto:support@zeeg.me) with details about your use case.