# Pathbound > Pathbound is the customer-context layer for AI agents and humans. It connects data from your CRM, website analytics, email, and other tools into a single profile per contact and company — exposed as REST API, MCP server, and a web events snippet. --- # Introduction to Pathbound > Build with Pathbound — the customer-context layer for AI agents and humans. Source: https://pathbound.ai/docs Pathbound is the customer-context layer for AI agents and humans. It connects data from your CRM, website analytics, email, and other tools into a single profile per contact and company — then exposes that context through three surfaces: - **REST API** — Programmatic access to contacts, companies, deals, events and conversations, and to the actions your connected tools can take: tagging, segments, automations, enrichment and sending email. - **MCP server** — Same data and actions, tool-shaped, for [Claude.ai](https://claude.ai) and other Model Context Protocol clients. - **Events** — A 5 KB JavaScript snippet that captures behavioral events on your website and links them to contacts. These docs cover all three. ## Reading these docs from an AI agent Every page is also published as machine-readable markdown so an assistant can ground its answers in the current product knowledge: - `https://pathbound.ai/llms.txt` — curated index of every page (titles, descriptions, links). - `https://pathbound.ai/llms-full.txt` — the full docs concatenated into one bundle for one-shot ingestion. - `https://pathbound.ai/docs/.md` — the raw markdown for any single page. For step-by-step setup with Claude Projects, ChatGPT, or your own agent, see [Use with AI tools](/docs/getting-started/use-with-ai-tools). ## Where to start | If you want to… | Go to | | ------------------------------------------------ | ------------------------------------------------------ | | Make your first API call | [Quickstart](/docs/getting-started/quickstart) | | Understand the object model | [Concepts](/docs/getting-started/concepts) | | Authenticate | [Authentication](/docs/getting-started/authentication) | | See everything the REST API covers | [API Reference → Overview](/docs/api/overview) | | Browse REST endpoints | [API Reference → Contacts](/docs/api/contacts) | | Push changes to your own systems | [API Reference → Streaming](/docs/api/webhooks) | | Connect Pathbound to Claude.ai | [MCP → Overview](/docs/mcp/overview) | | Capture website behavior | [Events → Install](/docs/events/install) | | Use these docs from Claude, ChatGPT, or your agent | [Use with AI tools](/docs/getting-started/use-with-ai-tools) | ## Base URL All API v1 endpoints are served under: ``` https://api.pathbound.ai/v1 ``` The MCP server lives at `https://mcp.pathbound.ai/mcp`. The events snippet is served from `https://events.pathbound.ai/snippet.js`. ## Error format All REST error responses follow a consistent shape: ```json { "status": "error", "error": "A human-readable message", "code": "a_stable_word", "details": [], "timestamp": "2026-10-12T12:00:00.000Z" } ``` Validation errors (`400`) include a `details` array with per-field messages. `code` is present when the error has one. The statuses and what each means are listed under [Errors](/docs/api/overview#errors). ## Rate limits - REST API: limited per workspace and per IP address, well above what an integration needs in normal use. A request over a limit is answered with `429 Too Many Requests` and a `Retry-After` header. See [Rate limits](/docs/api/overview#rate-limits). - Public form submission: **30/min/IP** globally, **100/min** per workspace. - Event ingestion `/track` and `/recover-visitor`: per-domain and per-IP rate limits, tuned for normal browser traffic. ## Streaming Pathbound can push events and record changes to your own URLs as they happen, or act on them: add a tag, change a Resend segment, email everyone who submits a form. A stream that posts to a URL is set up in the [dashboard](https://app.pathbound.ai/streaming); automations can also be created over the API. See [API Reference → Streaming](/docs/api/webhooks) for the payload format, signature verification and the endpoints. ## Status Live status: [status.pathbound.ai](https://status.pathbound.ai). --- # Quickstart guide > Make your first Pathbound API call in under a minute. Create an API key, list contacts, and verify the response. Source: https://pathbound.ai/docs/getting-started/quickstart You'll need an API key. Create one under **Settings → API keys** in the [dashboard](https://app.pathbound.ai/settings/api-keys), then keep this page open. For this page, tick `contacts:read`, `contacts:write` and `events:read`. (You need a verified email and the workspace admin role to create a key. The dashboard shows a verification banner if your email isn't verified yet. See [Authentication](/docs/getting-started/authentication#prerequisites) for details.) ## 1. Verify your key ```bash title="curl" curl https://api.pathbound.ai/v1/auth/status \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```js title="Node" const res = await fetch('https://api.pathbound.ai/v1/auth/status', { headers: { Authorization: `Bearer ${process.env.PATHBOUND_API_KEY}` }, }); console.log(await res.json()); ``` ```python title="Python" import os, requests res = requests.get( 'https://api.pathbound.ai/v1/auth/status', headers={'Authorization': f"Bearer {os.environ['PATHBOUND_API_KEY']}"}, ) print(res.json()) ``` You should see your `user_id`, `email`, and `tenant_id`, and the key's `scopes`. ## 2. Create a contact ```bash title="curl" curl -X POST https://api.pathbound.ai/v1/contacts \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "email": "ada@example.com", "properties": { "firstname": "Ada", "lastname": "Lovelace", "company": "Analytical Engines", "lifecyclestage": "lead" } }' ``` ```js title="Node" const res = await fetch('https://api.pathbound.ai/v1/contacts', { method: 'POST', headers: { Authorization: `Bearer ${process.env.PATHBOUND_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ email: 'ada@example.com', properties: { firstname: 'Ada', lastname: 'Lovelace', company: 'Analytical Engines', lifecyclestage: 'lead', }, }), }); const { contact } = await res.json(); console.log(contact.contact_id); ``` ```python title="Python" res = requests.post( 'https://api.pathbound.ai/v1/contacts', headers={ 'Authorization': f"Bearer {os.environ['PATHBOUND_API_KEY']}", 'Content-Type': 'application/json', }, json={ 'email': 'ada@example.com', 'properties': { 'firstname': 'Ada', 'lastname': 'Lovelace', 'company': 'Analytical Engines', 'lifecyclestage': 'lead', }, }, ) print(res.json()['contact']['contact_id']) ``` Save the returned `contact.contact_id`. You need it to read the contact, attach notes and tags, or send it an email. (`contact._id` is also returned as a transitional alias for backward compatibility, but prefer `contact_id` in new code.) Running this twice returns `409 Conflict` the second time, with the existing contact's id as `contact_id`. Required scope: `contacts:write`. ## 3. List recent events If you've installed the [events snippet](/docs/events/install) on your site, you'll already have events flowing. Otherwise this returns an empty array. ```bash title="curl" curl "https://api.pathbound.ai/v1/events?limit=5&sort_dir=desc" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ```js title="Node" const url = new URL('https://api.pathbound.ai/v1/events'); url.searchParams.set('limit', '5'); url.searchParams.set('sort_dir', 'desc'); const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.PATHBOUND_API_KEY}` }, }); const { events } = await res.json(); console.log(events); ``` ```python title="Python" res = requests.get( 'https://api.pathbound.ai/v1/events', headers={'Authorization': f"Bearer {os.environ['PATHBOUND_API_KEY']}"}, params={'limit': 5, 'sort_dir': 'desc'}, ) for e in res.json()['events']: print(e['event'], e.get('url')) ``` Required scope: `events:read`. ## 4. Count instead of paging Most questions about your data are "how many". Ask the server to count, and nothing is paged or metered: ```bash title="curl" curl "https://api.pathbound.ai/v1/contacts?lifecycle_stage=lead&count_only=true" \ -H "Authorization: Bearer YOUR_API_KEY" ``` The answer is one number: ```json { "status": "success", "count": 1 } ``` Required scope: `contacts:read`. ## What's next - Read the [REST API overview](/docs/api/overview): everything the API covers, and the conventions every endpoint shares (filters, errors, idempotency, rate limits, what counts against your plan). - Group and count on the server with [`POST /v1/aggregate`](/docs/api/batch#aggregate), and read many records at once with [`POST /v1/batch/details`](/docs/api/batch#batch-get-details). - Have changes pushed to you as they happen with [Streaming](/docs/api/webhooks). - Drop in the [events snippet](/docs/events/install) to start capturing visitor behavior. - Connect Pathbound to Claude.ai via the [MCP server](/docs/mcp/overview). --- # Core concepts > The Pathbound object model — contacts, companies, deals, events, tags and segments, and how they connect. Source: https://pathbound.ai/docs/getting-started/concepts Pathbound is the customer-context layer for AI agents and humans. It connects data from your CRM, website analytics, email, and other tools into a single profile per contact and company — then exposes that context through three surfaces: the [REST API](/docs/api/contacts), the [MCP server](/docs/mcp/overview), and [Events](/docs/events/overview). The object model is small. Once you understand contacts, companies and events, the rest of the docs is the API surface around them. Deals, tags and segments build on those three. ## Contacts A **contact** is a person tracked across all connected sources. Every contact has: - A **unified profile** — properties merged from CRM, enrichment providers, and form submissions. - An **event timeline** — chronological cross-system activity (page views, opens, replies, syncs). - **Notes** — free-form internal annotations. - **Conversations** — email threads, Intercom chats and recorded meetings (when those sources are connected). - An association with one **company**. - **Tags**, and membership of **segments**. → [Contacts API](/docs/api/contacts) ## Companies A **company** is the primary B2B unit. Individual contacts roll up to company-level intelligence. Companies are identified by `domain` and have properties like `industry`, `employee_count`, and `lifecyclestage`. When you query a company, Pathbound returns its associated contacts (up to 25). → [Companies API](/docs/api/companies) ## Events An **event** is a tracked interaction across any connected source — a page view, button click, form submission, video play, email open. Events are linked to a contact (when the visitor is identified) and provide the behavioral signal layer of the unified profile. The [events snippet](/docs/events/overview) is the most common source of events, but events can come from email opens, CRM webhooks, and embedded video players. → [Events API](/docs/api/events) ## Deals A **deal** is a revenue opportunity: a name, a stage, an amount, a close date and a status (open, won or lost). A deal is linked to a company, a contact, or both. Deals are created in Pathbound, through the API or by an AI agent. → [Deals API](/docs/api/deals) ## Tags and segments A **tag** is a short label on a contact, a company or a deal, such as `vip` or `webinar-oct`. Your code, your teammates and your AI agents can all add and remove tags, and filter on them. A **segment** is a named set of filters over contacts or companies, such as "customers in Germany with more than 200 employees". Save it once, then use it by name to list, count, tag, enrich or email everyone in it. → [Tags API](/docs/api/tags) · [Segments API](/docs/api/segments) ## How it fits together ``` Snippet ───▶ Events ───┐ │ Connected sources ───▶ Contact ───▶ Company (CRM, email, etc.) │ ▼ Unified customer profile (read by your code, your AI agents, or Claude.ai over MCP) ``` A typical loop: 1. The snippet captures behavioral events on your site. 2. A form submission ([public form endpoint](/docs/api/forms)) identifies the visitor — events are now linked to a contact. 3. CRM, enrichment, and email sources merge into the contact's unified profile. 4. Your code (or an AI agent connected over MCP) reads the profile and timeline to make a decision. 5. It acts on the decision: a tag, a note, an email, or a [stream](/docs/api/webhooks) that pushes the change to your own systems. --- # Authentication > How to authenticate with the Pathbound REST API and MCP server using API keys, OAuth, and JWTs. Source: https://pathbound.ai/docs/getting-started/authentication Every request to the Pathbound API (except the health-check and public form-submission endpoints) must include a valid API key. ## Obtaining an API Key ### Prerequisites Before you can create an API key: - **Your email must be verified.** Keys can be created only by verified users. A key whose issuing user is unverified, including a key created before the user verified, is rejected at request time with `requires_email_verification: true`. - **You must be a workspace admin.** Other members can view existing keys but cannot create, edit, or revoke them. If you don't have these prerequisites, the dashboard shows a verification banner or hides the button that creates a key. ### Steps 1. Open **Settings → API keys** in the [Pathbound dashboard](https://app.pathbound.ai/settings/api-keys). 2. Create a key, name it, and tick the scopes it needs. A new key starts with the four read scopes for contacts, companies, deals and events ticked. 3. Copy the key. It is shown only once. A workspace can hold several keys. Give each integration its own key with only the scopes it needs, so that you can revoke one without touching the others. A key acts as the user who created it. That matters for the endpoints that act from a person's own account: an email sent with the key through [Gmail](/docs/api/gmail) goes out from a mailbox that user connected. If that user leaves the workspace, their keys stop working. ## Using the API Key Pass the key in the `Authorization` header with the `Bearer` scheme: ```bash curl https://api.pathbound.ai/v1/contacts \ -H "Authorization: Bearer YOUR_API_KEY" ``` ## Verify Your Key Use the auth status endpoint to confirm your key is working: ```bash curl https://api.pathbound.ai/v1/auth/status \ -H "Authorization: Bearer YOUR_API_KEY" ``` A successful response returns your user and tenant information, plus the key's effective scopes: ```json { "status": "success", "authenticated": true, "auth_type": "api_key", "key_id": "1a2b3c4d", "scopes": ["contacts:read", "events:read"], "user": { "user_id": "abc123", "email": "you@example.com", "first_name": "Jane", "last_name": "Doe", "tenant_id": "tenant_xyz" }, "workspace": { "id": "tenant_xyz", "name": "Acme Corp", "ws": "acmecorp" } } ``` `workspace` is the workspace the key reaches. `ws` is the value an [MCP connector URL](/docs/mcp/overview#connecting-several-workspaces) names it by, and `name` is `null` when the workspace's name holds characters the MCP server does not show to a model. For a key created before scopes existed, `scopes` shows what the key can actually do (every scope that is not opt-in) rather than an empty array. ## Scopes A key can do only what its scopes allow. Every endpoint's page names the scope it needs. | Scope | Grants | | -------------------- | -------------------------------------------------------- | | `contacts:read` | List, search, and retrieve [contacts](/docs/api/contacts), their conversations, meetings and notes. | | `contacts:write` | Create, update, delete and tag contacts, and write contact notes. Create segments of contacts. | | `companies:read` | List and retrieve [companies](/docs/api/companies) and their notes. | | `companies:write` | Create, update, delete and tag companies, and write company notes. Create segments of companies. | | `deals:read` | List and retrieve [deals](/docs/api/deals) and their notes. | | `deals:write` | Create, update, delete and tag deals, and write deal notes. | | `events:read` | List and aggregate tracked [events](/docs/api/events), read a contact's timeline, and list [forms](/docs/api/forms). | | `knowledge:read` | List, search and read the workspace's [knowledge pages](/docs/api/knowledge). | | `streams:read` | Read [streams and automations](/docs/api/webhooks#managing-streams-over-the-api): their triggers, state and delivery log. Never where an HTTPS stream delivers. | | `knowledge:write` | **Opt-in.** Create knowledge pages and edit unlocked ones. What a key writes is shown as unverified. | | `streams:write` | **Opt-in.** Create, change and delete automations, which keep acting by themselves after the call that made them. | | `actions:write` | **Opt-in.** Real-world side effects on your workspace's behalf: sending email through [Gmail](/docs/api/gmail) (now or scheduled, and drafts held by Pathbound), acting in your [Resend](/docs/api/resend) account (segments, emails, events, broadcasts), [Apollo sequences](/docs/api/apollo) and single emails, and [Instantly](/docs/api/instantly) leads. It also gates the reads that describe what you can send from: Gmail mailboxes and the outbox, Apollo mailboxes, sequences, schedules and per-contact engagement. | | `enrichment:write` | **Opt-in.** [Enrichment and prospecting](/docs/api/enrichment), which spend your credits with Apollo or Hunter. | Endpoints that require a specific scope return `403 Forbidden` if the key does not have it. Most endpoints declare a single required scope. A few need two (reading a company's conversations needs `companies:read` and `contacts:read`), and a few need a scope that depends on what you ask for (`POST /v1/aggregate` needs the read scope of the entity you aggregate). ### Opt-in scopes Four scopes are marked opt-in because they do more than read and write your own records: they send to real people, spend money, or leave a standing instruction behind. A key has one of them only if you ticked it when you created the key. Ticking "grant all permissions" ticks them too. ### Keys without scopes A key created before scopes existed has none. It keeps working, with every scope that is not opt-in. It never has an opt-in scope: to use one, create a new key. [`GET /v1/auth/status`](#verify-your-key) shows such a key's effective scopes. ### Scope error response ```json { "status": "error", "error": "Insufficient API key scopes. Missing: events:read", "required_scopes": ["events:read"], "timestamp": "2026-10-12T12:34:56.000Z" } ``` ## Using your key with MCP The same API keys work on the [MCP server](/docs/mcp/overview) (`https://mcp.pathbound.ai/mcp`) as static bearer tokens. That is the way to connect headless MCP clients that can't complete a browser login. Interactive clients like Claude.ai instead use Pathbound's native OAuth login and need no key. See [MCP Authentication](/docs/mcp/authentication) for both flows. ## Authentication Errors | Status | Meaning | | ------ | -------------------------------------------------------- | | `401` | Missing or invalid API key. | | `403` | Key is valid but lacks the required scope. | | `429` | Rate limit exceeded. | Example `401` response: ```json { "status": "error", "error": "Invalid Bearer token!", "timestamp": "2026-10-12T12:34:56.000Z" } ``` The `error` text says what was wrong: the `Authorization` header is missing, it is not a `Bearer` token, the token is not a key of any workspace, or the key has been deactivated. ### Unverified-issuer 401 If the user who minted the key is not (or is no longer) email-verified, every request authenticated by the key is rejected: ```json { "status": "error", "error": "Email verification required", "requires_email_verification": true, "timestamp": "2026-10-12T12:34:56.000Z" } ``` Have the user who created the key click the link in their welcome email, then retry. The key does not need to be replaced. --- # Integrate from your backend > Send your accounts, users and deals to Pathbound from your own server, keyed on your own ids, so every call is safe to repeat. Source: https://pathbound.ai/docs/getting-started/backend Your backend knows who signed up, which account they belong to, which plan that account is on and which deals are open. This page is the set of calls that keeps Pathbound in step with it. Each record is written through its own route and keyed on your own id, and sending the same request twice changes nothing, so you can send it again whenever you are not sure the first one arrived. | Something happens | Calls | | --- | --- | | A user registers | [`POST /v1/companies`](#a-registration) for their account, then `POST /v1/contacts` naming it | | An account changes plan | [`POST /v1/companies`](#a-plan-change) again, with the new values | | A user joins a team account | [`POST /v1/contacts`](#a-team-account) naming that account | | A deal is opened or moves | [`POST /v1/deals`](#a-deal) with your deal id | | You load what you already have | [The bulk forms](#a-backfill) of the same calls | ## Before you start - An API key from **Settings → API keys** with the scopes `contacts:write`, `companies:write` and, for deals, `deals:write`. See [Authentication](/docs/getting-started/authentication). - Your own ids: one for each account, one for each user, one for each deal. They should be stable and opaque. See [Choose a safe external ID](/docs/events/identify#choose-a-safe-external-id). ## A registration First the account, keyed on your account id: ```bash curl -X POST https://api.pathbound.ai/v1/companies \ -H "Authorization: Bearer $PATHBOUND_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "external_id": "acct_51c0e7", "domain": "northwind.example", "properties": { "name": "Northwind", "plan": "trial", "seats": 5 } }' ``` Then the user, keyed on your user id, naming the account by the same account id: ```bash curl -X POST https://api.pathbound.ai/v1/contacts \ -H "Authorization: Bearer $PATHBOUND_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "external_id": "usr_8f3a2c9e", "email": "ada@northwind.example", "properties": { "firstname": "Ada", "lastname": "Lovelace", "registered_at": "2026-10-22T09:00:00Z" }, "company": { "external_id": "acct_51c0e7" } }' ``` ```json { "status": "success", "message": "Contact created successfully", "contact_id": "665f1c2ab3d4e5f6012345ab", "created": true, "contact": { "…": "the contact record" }, "timestamp": "2026-10-22T09:00:01.000Z" } ``` - Each call is `201` with `"created": true` when it creates the record and `200` with `"created": false` when it finds it. - A record is found by your id; else it is one with no id yet that holds the domain or the email (a company or a contact your CRM brought in, say), which takes your id; else it is created. - A user who signs up with a personal address (`gmail.com`) still gets an account: a free email provider's domain is left off the company, which is written all the same. - `company` only looks the account up. Write the account first: a user naming an account that does not exist is refused with `422`. [Create a Company](/docs/api/companies#create-a-company) and [Create a Contact](/docs/api/contacts#create-a-contact) have every field and every case. ### In the browser If the [events snippet](/docs/events/install) is on your app, identify the user there when they sign in, with the same user id: ```js window.pathbound = window.pathbound || []; window.pathbound.push(['identify', user.pathboundExternalId, { email: user.email }]); ``` That ties what they do in the browser, before and after signing up, to the contact your backend wrote. It writes no account and changes no property of an existing contact. See [Identify logged-in users](/docs/events/identify). ## A plan change Send the account call again with the new values. Only what you send changes: ```bash curl -X POST https://api.pathbound.ai/v1/companies \ -H "Authorization: Bearer $PATHBOUND_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "external_id": "acct_51c0e7", "properties": { "plan": "pro", "seats": 20 } }' ``` A change to a user is the contact call again in the same way. ## A team account Every member's contact names the same account id, and every member is linked to that one company: ```bash curl -X POST https://api.pathbound.ai/v1/contacts \ -H "Authorization: Bearer $PATHBOUND_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "external_id": "usr_2b9d41", "email": "grace@northwind.example", "company": { "external_id": "acct_51c0e7" } }' ``` A user who moves to another account is sent with the new account id: they are linked to it and no longer listed under the old one. ## A deal Send your deal id and link the deal by the same ids you used above. A deal that already has the id is updated, so the same call opens the deal and moves it later: ```bash curl -X POST https://api.pathbound.ai/v1/deals \ -H "Authorization: Bearer $PATHBOUND_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "external_deal_id": "deal_7731", "properties": { "name": "Northwind annual", "stage": "proposal", "amount": 24000 }, "contact": { "external_id": "usr_8f3a2c9e" }, "company": { "external_id": "acct_51c0e7" } }' ``` Like `company` on a contact, `contact` and `company` here only look records up: a key that finds nothing is a `422`. See [Create a Deal](/docs/api/deals#create-a-deal). ## A backfill To load the accounts, users and deals you already have, send the same items through [`POST /v1/companies/bulk`](/docs/api/companies#create-companies-in-bulk), then [`POST /v1/contacts/bulk`](/docs/api/contacts#create-contacts-in-bulk), then [`POST /v1/deals/bulk`](/docs/api/deals#create-deals-in-bulk), up to 1,000 items a request. An item keyed on your id is created or updated, and reported `created` or `updated`; a bad item is reported on its own and the others go through. Sending a batch again is safe. ## Reading back by your own ids | Record | Call | | --- | --- | | An account | `GET /v1/companies?external_id=acct_51c0e7` | | A user | `GET /v1/contacts?external_id=usr_8f3a2c9e` | | A deal | `GET /v1/deals?external_deal_id=deal_7731` | Each returns zero or one record. Reads are [metered](/docs/api/overview#usage-and-metering). ## What to retry Every call on this page can be sent again with the same body, so retry it when you do not know whether it arrived: - a timeout or a dropped connection; - a `5xx`; - a `429` whose `resource` is `v1_api_requests_per_min`, after the number of seconds in the `Retry-After` header. Do not send the same body again after these. Change the request, or what it depends on: | Answer | What it means | | --- | --- | | `400` | A field is malformed. `code`, `details` or `error` says which. | | `403` | The key lacks a scope. | | `409` from a contact, `code: "EXTERNAL_ID_CONFLICT"` | The email belongs to a contact keyed to another of your ids. `contact_id` is that contact. | | `422`, `code: "COMPANY_NOT_FOUND"` or `"CONTACT_NOT_FOUND"` | The account or the user a key names is not there yet: write it, then send the call again. | | `429` whose `resource` is `contacts`, `companies` or `deals` | The workspace's stored records of that kind reached their limit. Waiting does not lift it. | `skipped_traits` in a successful answer tells you a field was left out and why, and needs no retry: an email another contact holds, or a domain that is a free email provider's, is no host name, or belongs to another company. The rest of the record was written. --- # Use with AI tools > Point Claude, ChatGPT, or your own agent at the Pathbound docs so it answers using current product knowledge. Source: https://pathbound.ai/docs/getting-started/use-with-ai-tools If you're building with Pathbound from inside an AI assistant — Claude, ChatGPT, or a homegrown agent — you can point it at machine-readable copies of these docs so it answers using current product knowledge instead of guessing. We publish three formats following the [llms.txt convention](https://llmstxt.org/): | URL | What it is | When to use it | | ------------------------------------------------- | ------------------------------------------------------------------- | --------------------------------------------------------------- | | `https://pathbound.ai/llms.txt` | Curated index — page titles, descriptions, and links to each doc. | Agents that pull docs on demand (custom RAG, retrieval tools). | | `https://pathbound.ai/llms-full.txt` | The full docs concatenated as one plain-text bundle. | One-shot ingestion (Claude Projects, Custom GPT knowledge). | | `https://pathbound.ai/docs/.md` | The raw markdown for any single page. | Targeted fetches when an agent already knows what it needs. | All three are generated from the same source as the human-facing docs at `/docs/*` and update on every deploy. ## Claude.ai / Claude Projects 1. Open a Claude Project. 2. Add `https://pathbound.ai/llms-full.txt` as a knowledge source (paste the URL into **Add content → URL**). 3. Ask questions normally — Claude grounds its answers in the docs. For interactive access to your live data (contacts, companies, events) — not just docs — connect the [Pathbound MCP server](/docs/mcp/overview) instead. The two complement each other: `llms.txt` gives Claude reference knowledge, MCP gives it your tenant's data. ## ChatGPT / Custom GPTs 1. Download `https://pathbound.ai/llms-full.txt`. 2. Upload it as a knowledge file in the Custom GPT builder. 3. Add an instruction like: *"When answering Pathbound questions, cite the relevant section from the uploaded docs."* ## Your own agent Any URL under `/docs` has a sibling `.md` that returns clean markdown: ```bash curl https://pathbound.ai/docs/api/overview.md curl https://pathbound.ai/docs/api/contacts.md curl https://pathbound.ai/docs/mcp/tools.md curl https://pathbound.ai/docs/events/install.md ``` Or fetch the full bundle in one request: ```bash curl https://pathbound.ai/llms-full.txt ``` Both return `text/plain` (or `text/markdown` for per-page mirrors) — no HTML to strip, no JavaScript to render. ## Two paths, one surface Pathbound exposes itself to agents in two ways: - **Reference path (these docs).** Static product knowledge — endpoints, fields, examples. Fed via `llms.txt`. Doesn't change per request. - **Live path ([MCP server](/docs/mcp/overview)).** Interactive access to your tenant's contacts, companies, and events. Authenticated, real-time. Most useful agents end up using both: the docs to know *how* the API works, the MCP server to actually *call* it. --- # REST API overview > What the Pathbound REST API covers, and the conventions every endpoint shares — authentication, response shapes, filters, errors, idempotency, rate limits and what counts against your plan. Source: https://pathbound.ai/docs/api/overview The REST API gives your own code everything your workspace holds: contacts, companies, deals, events, conversations and meeting notes. It also lets your code act: tag records, save segments, write knowledge pages, set up automations, enrich records, and send email through the tools you have connected. Every request goes to one base URL and carries an API key: ```bash curl https://api.pathbound.ai/v1/contacts?limit=5 \ -H "Authorization: Bearer $PATHBOUND_API_KEY" ``` Create a key under **Settings → API keys** in the [dashboard](https://app.pathbound.ai/settings/api-keys). [Authentication](/docs/getting-started/authentication) explains keys and scopes, and the [Quickstart](/docs/getting-started/quickstart) makes the first three calls with you. ## What the API covers Each page lists its endpoints in a table at the top. ### Read and write your records | Area | What you can do | Scopes | | ---- | --------------- | ------ | | [Contacts](/docs/api/contacts) | Create, list, filter, read, update and delete people. Read a contact's timeline, conversations, meeting transcripts and notes. | `contacts:read`, `contacts:write` | | [Companies](/docs/api/companies) | The same for companies, with conversations rolled up across everyone at the company. | `companies:read`, `companies:write` | | [Deals](/docs/api/deals) | Create, list, read, update and delete deals and their notes. | `deals:read`, `deals:write` | | [Events](/docs/api/events) | List and count tracked events: page views, clicks, form submissions and events written by integrations. | `events:read` | | [Tags](/docs/api/tags) | Add and remove tags on contacts, companies and deals, one record or thousands at a time. | the record's write scope | | [Segments](/docs/api/segments) | Save a set of filters under a name, reuse it on other endpoints, and run an action on everyone in it. | the record's read and write scopes | ### Read at scale | Area | What you can do | Scopes | | ---- | --------------- | ------ | | [Batch and aggregate](/docs/api/batch) | Read up to 25 records in one request, read notes from many meetings, and group and count on the server. | the record's read scope | | [Schema and usage](/docs/api/workspace) | Find out which fields you can filter on, how much of your plan is used, and which integrations are connected. | differs by endpoint | | [Knowledge](/docs/api/knowledge) | Read and write your workspace's own pages about its business. | `knowledge:read`, `knowledge:write` | ### Get data in and out as it happens | Area | What you can do | Scopes | | ---- | --------------- | ------ | | [Streaming](/docs/api/webhooks) | Push changes to your endpoint, or act on matching records automatically. | `streams:read`, `streams:write` | | [Forms](/docs/api/forms) | Post a form on your site to Pathbound, and email everyone who submits it. | `events:read` to list forms | | [Identify](/docs/events/identify#2-identify-from-your-backend-post-v1identify) | Tell Pathbound which of your users a visitor is, from your server. | `contacts:write` | ### Act in your connected tools These endpoints send to real people or spend credits with a provider. Each one needs an opt-in scope, and most need the integration connected and a switch turned on by a workspace admin. | Area | What you can do | Scope | | ---- | --------------- | ----- | | [Gmail](/docs/api/gmail) | Send, schedule and draft email from your own mailbox. | `actions:write` | | [Resend](/docs/api/resend) | Manage segments, send an email, send an event, and send broadcasts in your Resend account. | `actions:write` | | [Apollo sequences](/docs/api/apollo) | Create sequences, enroll contacts and send single emails through Apollo. | `actions:write` | | [Instantly](/docs/api/instantly) | Add a lead to a campaign and update a lead's interest. | `actions:write` | | [Enrichment and prospecting](/docs/api/enrichment) | Enrich your records and find new people and companies through Apollo or Hunter. | `enrichment:write` | If you want an AI agent to do this work instead of your code, connect it to the [MCP server](/docs/mcp/overview). The MCP tools call these same endpoints. ## Requests - Send JSON with `Content-Type: application/json` on every request that has a body. - All times are ISO 8601 in UTC, for example `2026-10-12T09:30:00.000Z`. A date filter accepts any ISO 8601 date or date-time. - Every successful response is a JSON object with `"status": "success"`. ### IDs Every record id is a 24-character hexadecimal string, for example `665f1c2ab3d4e5f601234567`. An id in any other form is refused, on most endpoints with `400`. An id that is well formed but belongs to no record in your workspace is answered with `404`, whether or not it exists in another workspace. Contacts, companies, deals and events name their id `contact_id`, `company_id`, `deal_id` and `event_id`. Responses also carry it as `_id` or `id` for older integrations. Use the named field in new code. ## Lists ### Pagination List endpoints page with `page` (starting at 1) and `limit`, and return a `pagination` object: ```json { "pagination": { "page": 1, "limit": 10, "total_pages": 42, "total_items": 418 } } ``` `limit` goes up to 100 on contacts, companies, deals and events. The default differs by endpoint and is given in each endpoint's parameter table, so pass `limit` rather than relying on it. A few endpoints page differently, and say so on their page: a contact's events use `limit` and `offset`, and long texts (a meeting transcript, a knowledge page) are read in parts. ### Counting For a "how many" question, add `count_only=true`. The response is `{ "status": "success", "count": 1234 }`, with no records. Counting is free and never counts against your plan. For "how many by X", use [`POST /v1/aggregate`](/docs/api/batch#aggregate) instead of paging through a list. ### How much of each record comes back The `fields` parameter on contact, company and deal lists chooses the shape of each row: | `fields` | Each row holds | Counts against your plan | | -------- | -------------- | ------------------------ | | `ids` | The record's id and nothing else. | No | | `summary` | The id and a handful of key properties. | Yes | | `full` (the default) | The whole record. | Yes | | a comma-separated list (contact lists only) | The id and the fields you name. | Yes, unless every field you name is an id | The cheapest way to work through a large set is to filter with `fields=ids`, then read the records you need with [`POST /v1/batch/details`](/docs/api/batch#batch-get-details). ### Filtering List endpoints take three kinds of filter, and you can combine them. Every filter narrows the result: a record must pass all of them. **Named parameters** such as `lifecycle_stage`, `country` or `created_after`. Each endpoint's page lists its own. **Typed filters.** The `filters` parameter is a JSON array of up to 10 conditions, each `{"field", "op", "value"}`: ```bash curl -G https://api.pathbound.ai/v1/contacts \ -H "Authorization: Bearer $PATHBOUND_API_KEY" \ --data-urlencode 'filters=[{"field":"lifecyclestage","op":"in","value":["customer","opportunity"]},{"field":"emailable.score","op":"gte","value":80}]' \ --data-urlencode 'fields=ids' ``` `field` is a property of the record (`lifecyclestage`, `jobtitle`, `industry`) or a field of a connected source written `source.field` (`emailable.score`, `hubspot.hs_lead_status`, and `apollo.employee_count` on a company). [`GET /v1/schema`](/docs/api/workspace#schema) lists the fields your workspace has, with the type of each and the operators it takes. | `op` | Value | Meaning | | ---- | ----- | ------- | | `eq`, `neq` | a string, number or boolean | Equal, not equal. | | `in`, `nin` | an array of 1 to 100 values | Is one of, is none of. | | `gt`, `gte`, `lt`, `lte` | a number or a date | Greater than, at least, less than, at most. | | `contains` | a non-empty string | Contains the text, ignoring case. | | `exists` | `true` or `false` (default `true`) | The field has a value, or has none. | A number or date field takes the comparison operators and not `contains`. A text field takes `contains` and not the comparison operators. A filter that names an unknown source field, or an operator its field does not take, is answered with `400` and the code `invalid_filter`. It is never dropped. `eq`, `neq`, `in` and `nin` compare the whole stored value, capitals included: `"United States"` does not match `united states`. `contains` ignores case. The named parameters are more forgiving: `country=united states` matches whatever the capitals. **Tags and segments.** `tags_any` and `tags_all` filter on [tags](/docs/api/tags). `segment` and `segments_any` scope a list to a [saved segment](/docs/api/segments). ## Errors An error response has a 4xx or 5xx status and `"status": "error"`. A request that fails validation looks like this: ```json { "status": "error", "error": "Validation failed", "details": [ { "type": "field", "path": "limit", "location": "query", "value": "500", "msg": "Limit must be between 1 and 100" } ] } ``` Many other errors carry a `code`: ```json { "status": "error", "code": "not_found", "error": "Contact not found" } ``` `error` is a sentence for a person. `code` is a stable word for your code to branch on, and is present when the error has one. `details` says more when there is more to say: the parameter that failed, the limit that was reached. Many responses also carry a `timestamp`. | Status | Meaning | | ------ | ------- | | `400` | The request is not valid. `details` names the parameter. | | `401` | The API key is missing, wrong or switched off, or the user who created it has not verified their email. See [Authentication](/docs/getting-started/authentication#authentication-errors). | | `402` | Your plan's allowance is used up, or the workspace has no active plan. See [Usage and metering](#usage-and-metering). | | `403` | The key lacks a scope the endpoint needs. The `error` text names the missing scope. | | `404` | No such record in your workspace. | | `409` | The request conflicts with something that exists: a contact with that email, or a request with the same `Idempotency-Key` still in flight. | | `412` | Something has to be set up first: an integration is not connected, or an admin has not switched the action on. `code` says which. | | `422` | The request is well formed but cannot be carried out as asked. `code` says why. | | `429` | A rate limit or a quota was reached. Wait for the number of seconds in the `Retry-After` header when it is present. | | `5xx` | Something failed on our side. A read can be repeated. For a request that sends or spends, read [Idempotency](#idempotency) first. | ## Idempotency A request that sends something or spends something must not happen twice because a network connection dropped. Those endpoints take an `Idempotency-Key` header, and most of them require it: ```bash curl -X POST https://api.pathbound.ai/v1/contacts/665f1c2ab3d4e5f601234567/send_email \ -H "Authorization: Bearer $PATHBOUND_API_KEY" \ -H "Idempotency-Key: 7c1f0d5e-3b0a-4a3e-9a51-2f6f6e0f8f11" \ -H "Content-Type: application/json" \ -d '{ "subject": "Next steps", "body": "Hi Ada, ..." }' ``` - Choose one key per thing you intend to do, for example a UUID you store with the job. Use the same key when you retry that one thing, and a new key for the next. - A request with a key that already succeeded in the last 24 hours returns the first answer and does nothing again. One endpoint remembers a key for longer: a [job on a segment](/docs/api/segments#idempotency), for 30 days. - A request with a key that is still being processed is answered with `409` and the code `ACTION_IN_PROGRESS`. Wait and ask again with the same key. - An endpoint that requires the header answers `400` with the code `IDEMPOTENCY_KEY_REQUIRED` without it. Several sends are at most once, and their pages say so. When the provider does not answer, Pathbound cannot know whether the message went out, and it does not try again. You get the code `SEND_UNCONFIRMED`. Do not repeat such a request under a new key before you have checked, in the provider or in the mailbox's Sent folder, whether it was sent. ## Rate limits Requests are limited per workspace, counted across all of its keys and connected AI agents, and per IP address. The limits are set as a guard against runaway loops and abuse, well above what an integration needs in normal use, so there is nothing to plan around: read with a few requests in parallel and you will not meet them. A request over a limit is answered with `429`. The workspace limit answers with the code `QUOTA_EXCEEDED` and a `Retry-After` header: wait that many seconds and send the request again. Responses carry `RateLimit-*` headers that show how much of the current window is left. Some endpoints have limits of their own, given on their pages: the [public form endpoint](/docs/api/forms#rate-limits), sends through a provider, and enrichment, which is bounded by a daily credit budget. If you plan a large backfill or expect sustained high volume, write to [support@pathbound.ai](mailto:support@pathbound.ai) first. ## Usage and metering Plans are measured in **active profiles**: the customers your workspace read in the current billing cycle. A customer is a company with everyone in it, or a contact that belongs to no company. Reading the same customer again in the same cycle, through any endpoint and any number of times, counts once. Nothing is billed per request. **Free, on every plan:** - Every write: creating, updating, importing in bulk, tagging, notes. - Counts and aggregates: `count_only=true`, `total_items`, `POST /v1/aggregate`. - Filtering, sorting, searching and segment scoping when the response holds ids only (`fields=ids`). - `GET /v1/schema`, `GET /v1/usage`, `GET /v1/tags`, and listing segments. - Listing events across the workspace with `fields=summary`. - A `dry_run`, on the endpoints that take one. **Counts as reading a customer:** any response that returns something about them. That includes a single contact, company or deal, a list with `fields=summary` or `fields=full`, a timeline, conversations, and `POST /v1/batch/details`. A deal counts as the company or contact it belongs to. **Check before you read.** Contact and company lists, a single contact and [`POST /v1/batch/details`](/docs/api/batch#batch-get-details) take `dry_run=true`, which tells you what the read would cost. Nothing is returned and nothing is counted: ```json { "status": "success", "dry_run": true, "match_count": 25, "estimated_activations": 7 } ``` `estimated_activations` is how many customers the read would add to this cycle's count. [`GET /v1/usage`](/docs/api/workspace#usage) returns how many you have used and how many remain. **At the limit.** A plan's allowance is a hard limit and never a charge. A read that would take you over it is answered with `402` and one of these codes, and everything you have already read this cycle stays readable: | `code` | Meaning | | ------ | ------- | | `BILLING_PLAN_LIMIT` | The plan's active profiles for this cycle are used up. | | `BILLING_TRIAL_EXPIRED` | The trial ended and no plan was chosen. | | `BILLING_SUBSCRIPTION_PAUSED` | The subscription is paused. | | `BILLING_SUBSCRIPTION_INACTIVE` | The subscription was canceled, or there never was one. | Plans and what each includes are on the [pricing page](https://pathbound.ai/pricing). ## REST keys and AI agents An AI agent connected through the [MCP server](/docs/mcp/overview) calls these same endpoints, with a few deliberate differences: - **Deleting a contact or a company** is available to an API key and refused for an agent. - **Hiding contact PII.** A workspace can hide contacts' personal details from AI agents. That setting applies to agents only: an API key always receives the full record. - **Switches for agents.** A workspace admin switches groups of tools on and off for agents under **MCP Tools** in the dashboard. Some of those switches also apply to an API key. Each page says which ones apply to its endpoints. --- # Contacts REST API > Create, list, search, and manage identity-resolved contact records in Pathbound via the REST API. Source: https://pathbound.ai/docs/api/contacts Contacts represent people in your Pathbound workspace. Each contact has a unified profile, an event timeline, conversation history, and a list of notes. Contacts are associated with a [company](/docs/api/companies) and have a chronological [event timeline](/docs/api/events). | Endpoint | Scope | What it does | | -------- | ----- | ------------ | | [`POST /v1/contacts`](#create-a-contact) | `contacts:write` | Create one contact. | | [`POST /v1/contacts/bulk`](#create-contacts-in-bulk) | `contacts:write` | Create up to 1,000 contacts in one request. | | [`GET /v1/contacts`](#list-contacts) | `contacts:read` | List, filter, search and count contacts, or check which emails you already have. | | [`GET /v1/contacts/:contact_id`](#get-a-contact) | `contacts:read` | One contact with its unified profile and recent events. | | [`PUT /v1/contacts/:contact_id`](#update-a-contact) | `contacts:write` | Change properties. | | [`DELETE /v1/contacts/:contact_id`](#delete-a-contact) | `contacts:write` | Delete the contact. | | [`GET /v1/contacts/events/:contact_id`](#get-contact-events) | `events:read` | The contact's event timeline. | | [`GET /v1/contacts/:contact_id/conversations`](#get-contact-conversations) | `contacts:read` | Email and chat threads, and meetings. | | [`GET /v1/contacts/:contact_id/meetings/:meeting_id/transcript`](#get-meeting-transcript) | `contacts:read` | A recorded meeting's transcript. | | [`GET /v1/contacts/:contact_id/email-threads`](#list-contact-email-threads) | `contacts:read` | A compact list of the contact's Gmail threads. | | [`GET`, `POST` `/v1/contacts/:contact_id/notes`](#contact-notes) | `contacts:read`, `contacts:write` | List the contact's notes, add one. | | [`PATCH`, `DELETE` `/v1/contacts/:contact_id/notes/:note_id`](#update-a-note) | `contacts:write` | Change or delete one note. | Related endpoints on other pages: [tags on a contact](/docs/api/tags), [sending a contact an email](/docs/api/gmail), [enriching contacts](/docs/api/enrichment), and [identifying a logged-in user](/docs/events/identify#2-identify-from-your-backend-post-v1identify) from your server. ## Create a Contact ``` POST /v1/contacts ``` **Required scope:** `contacts:write` Without `external_id`, this creates a contact and an email that already exists is a `409`. With `external_id`, your own id for the person, it is an upsert on that id, so the same request sent twice leaves one contact, unchanged by the second: 1. A contact that has this `external_id` is updated. 2. Else a contact that has no external id yet and holds the `email` takes your id and is updated. If that contact is already keyed to another id, the answer is `409` with `code: "EXTERNAL_ID_CONFLICT"` and its `contact_id`, and nothing changes. 3. Else the contact is created. An update merges what you send: a property you send replaces the stored value, and one you leave out keeps it. An `email` that another contact already holds is not written, and `skipped_traits` says so (`"email": "email_belongs_to_contact:"`); the other fields are. ### Request Body | Field | Type | Required | Description | | ------------ | ---------------- | -------- | ----------------------------------------------- | | `email` | string | without `external_id` | Contact email address (must be unique). Optional with `external_id`. | | `external_id` | string | no | Your own id for the person, up to 100 characters, stored as `external_contact_id`. The same id [identify](/docs/events/identify) takes. Placeholder values (`"undefined"`, `"null"`, …) are a `400`. | | `properties` | object | no | Arbitrary key-value properties for the contact. `lifecyclestage`, if set, must be one of the [lifecycle stages](#lifecycle-stages). | | `visitor_id` | string \| string[] | no | Website visitor ID(s) from the [events snippet](/docs/events/overview) to associate. A visitor already tied to another contact is left with it. | | `company_id` | string | no | Associate the contact with this company. An id that is not a company in your workspace is a `404`. | | `company` | object | no | The company by your own key instead of its Pathbound id: `{ "external_id": "…" }` (the id you gave it on [Create a Company](/docs/api/companies#create-a-company)) or `{ "domain": "…" }`. It only looks a company up: one that finds nothing is a `422` with `code: "COMPANY_NOT_FOUND"`, and nothing is written. Not with `company_id`. | A visitor is tied to a contact only through `visitor_id`: a `sb_visitor_ids` key in `properties` is ignored, here and on [Update a Contact](#update-a-contact). With `external_id`, a company you name replaces the contact's company, and the company it had is removed from `associations.companies`: a user who moves to another account is no longer listed under the old one. Without a company, a contact on a corporate email domain is linked to the company that holds that domain, if there is one and the contact has no company yet. ### Response — `201 Created` `201` when the contact was created. A contact updated through its `external_id` is answered `200 OK` in the same shape, with `"created": false`. ```json { "status": "success", "message": "Contact created successfully", "contact_id": "665f1c2ab3d4e5f601234567", "created": true, "contact": { "contact_id": "665f1c2ab3d4e5f601234567", "_id": "665f1c2ab3d4e5f601234567", "properties": { "email": "ada@example.com", "firstname": "Ada", "lastname": "Lovelace" }, "associations": { "companies": ["665f1c2ab3d4e5f6012345aa"] }, "first_seen_at": "2026-10-12T10:00:00.000Z", "segment_ids": [], "creation_source": "manual", "created_at": "2026-10-12T10:00:00.000Z", "updated_at": "2026-10-12T10:00:00.000Z", "notes": [], "tags": [] }, "timestamp": "2026-10-12T10:00:00.000Z" } ``` `_id` is a transitional alias for `contact_id` and will be removed in a future release. Prefer `contact_id` in new integrations. Without `external_id`, returns `409 Conflict` if a contact with the same email already exists. The response carries that contact's id as `contact_id`, so you can update it instead. | Status | `code` | Meaning | | ------ | ------ | ------- | | `400` | `INVALID_EXTERNAL_ID` | `external_id` is empty, longer than 100 characters, or a placeholder value. | | `400` | `INVALID_LINK` | `company` is not an object with exactly one of `external_id` and `domain`, or is sent beside `company_id`. | | `409` | `EXTERNAL_ID_CONFLICT` | With `external_id`: the `email` belongs to a contact keyed to another id. `contact_id` is that contact. | | `422` | `COMPANY_NOT_FOUND` | The `company` key finds no company in your workspace. | --- ## Create Contacts in Bulk ``` POST /v1/contacts/bulk ``` **Required scope:** `contacts:write` Creates up to 1,000 contacts in one request. It is safe to send the same list twice: a contact whose email already exists is reported as `existing` and is not changed. An item with an `external_id` is an upsert on it, as on [Create a Contact](#create-a-contact), and comes back `created` or `updated`. One bad item never fails the others. ### Request Body | Field | Type | Required | Description | | ---------- | -------- | -------- | ----------- | | `contacts` | object[] | yes | 1 to 1,000 items. | | `contacts[].email` | string | without `external_id` | The contact's email. | | `contacts[].external_id` | string | no | As in [Create a Contact](#create-a-contact). | | `contacts[].properties` | object | no | As in [Create a Contact](#create-a-contact). | | `contacts[].company_id` | string | no | A company to associate the contact with. | | `contacts[].company` | object | no | The company by your own key, as in [Create a Contact](#create-a-contact). | | `contacts[].tags` | string[] | no | [Tags](/docs/api/tags) to put on the contact. Applied to contacts the request creates or updates, not to an `existing` one. | ```bash curl -X POST https://api.pathbound.ai/v1/contacts/bulk \ -H "Authorization: Bearer $PATHBOUND_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "contacts": [ { "email": "ada@example.com", "properties": { "firstname": "Ada" }, "tags": ["webinar-oct"] }, { "email": "grace@example.com", "properties": { "firstname": "Grace", "lifecyclestage": "lead" } }, { "email": "not-an-email" } ] }' ``` ### Response — `200 OK` ```json { "status": "success", "requested": 3, "created": 1, "updated": 0, "existing": 1, "errors": 1, "results": [ { "index": 0, "email": "ada@example.com", "status": "existing", "id": "665f1c2ab3d4e5f601234567" }, { "index": 1, "email": "grace@example.com", "status": "created", "id": "665f1c2ab3d4e5f601234568" }, { "index": 2, "email": "not-an-email", "status": "error", "error": "Valid email is required" } ], "timestamp": "2026-10-12T10:00:00.000Z" } ``` `results` has one entry per item you sent, in the same order, and `index` is the item's position in your array. An entry's `status` is `created`, `updated` (an item with an `external_id` that found its contact), `existing` (an item without one whose email exists) or `error`, with the `code` of [Create a Contact](#create-a-contact) when it has one, and `skipped_traits` when a field was not written. The request is a `200` even when some items failed, so read `errors` and the entries with `"status": "error"`. A `429` with `code: "QUOTA_EXCEEDED"` means the new contacts in the batch would take the workspace past its limit; an item that updates a contact through its `external_id` does not count toward it. Visitor ids are not bound by this endpoint. To tie a visitor to a contact, use [Create a Contact](#create-a-contact), [Update a Contact](#update-a-contact) or [`POST /v1/identify`](/docs/events/identify#2-identify-from-your-backend-post-v1identify). --- ## List Contacts ``` GET /v1/contacts ``` **Required scope:** `contacts:read` ### Query Parameters | Parameter | Type | Default | Description | | ----------------- | ------- | ------- | ---------------------------------------------------------- | | `page` | number | 1 | Page number. | | `limit` | number | 10 | Results per page (1–100). | | `fields` | string | `full` | `ids` (the id only), `summary` (the id and key properties), `full`, or a comma-separated list of field names. See [How much of each record comes back](/docs/api/overview#how-much-of-each-record-comes-back). Internal fields (`tenant_id`, `user_id`, `__v`, `properties_provenance`) cannot be requested. | | `count_only` | boolean | `false` | If `true`, return only `{ count }` and no records. | | `dry_run` | boolean | `false` | If `true`, return what reading this page would add to your plan's count, and no records. See [Usage and metering](/docs/api/overview#usage-and-metering). | | `search` | string | — | Free-text search across name, email, company, job title. | | `sort_by` | string | `lastmodifieddate` | `email`, `firstname`, `lastname`, `company`, `jobtitle`, `createdate`, `lastmodifieddate`, or an indexed source field written `source.field`. | | `sort_dir` | string | `desc` | Sort direction (`asc` or `desc`). | | `status` | string | — | Filter: `enriched` or `not_enriched`. | | `lifecycle_stage` | string | — | Filter by lifecycle stage (`lead`, `prospect`, `mql`, `sql`, `opportunity`, `customer`, `evangelist`, `subscriber`, `other`). Whole value, case-insensitive. | | `lead_status` | string | — | Filter by lead status (whole value, case-insensitive). | | `country` | string | — | Filter by country (whole value, case-insensitive). | | `industry` | string | — | Filter by industry (whole value, case-insensitive). | | `city` | string | — | Filter by city (whole value, case-insensitive). | | `state` | string | — | Filter by state/region (whole value, case-insensitive). | | `job_title` | string | — | Filter by job title (case-insensitive match). | | `has_linkedin` | boolean | — | Filter contacts with/without a LinkedIn profile URL. | | `company_id` | string | — | Filter by associated company ID (24 hex characters; any other value is a `400`). | | `external_id` | string | — | Your own id for the person, the one [identify](/docs/events/identify#2-identify-from-your-backend-post-v1identify) stores as `external_contact_id`. Exact match, so zero or one contact. One id of up to 255 characters; a repeated parameter is a `400`. | | `created_after` | string | — | Only contacts created after this ISO 8601 date. | | `created_before` | string | — | Only contacts created before this ISO 8601 date. | | `tags_any` | string | — | Comma-separated [tags](/docs/api/tags), up to 50. Contacts that carry at least one of them. | | `tags_all` | string | — | Comma-separated tags, up to 50. Contacts that carry every one of them. When both are sent, `tags_all` is used. | | `data_sources` | string | — | Comma-separated sources. Contacts that have data from at least one of them: `hubspot`, `intercom`, `apollo`, `pipedrive`, `zoho`, `resend`, `zendesk`, `gmail`, `tldv`, `calendly`, `emailable`, `postgres` (a connected database). An unknown name is a `400`. | | `segment` | string | — | The id or handle of a [saved segment](/docs/api/segments). Only contacts in it. | | `segments_any` | string | — | Comma-separated segment ids or handles. Contacts in at least one of them. | | `filters` | string | — | [Typed filters](/docs/api/overview#filtering) as a JSON array `[{"field","op","value"}]`, up to 10, on properties or source fields (for example `emailable.score`, `hubspot.hs_lead_status`). [`GET /v1/schema?entity=contacts`](/docs/api/workspace#schema) lists the fields available to your workspace. | | `exclude_internal` | boolean | `false` | Leave out your own team and test contacts, as defined in your workspace's settings. | | `email_verification_state` | string | — | Deliverability filter: a comma-separated union of `deliverable`, `undeliverable`, `risky`, `unknown`, `unverified` (no verdict on file). `deliverable` gives a safe-to-email list; `undeliverable,risky` a suppression list. | | `email_verified_after` | string | — | Only contacts whose email verdict was recorded at or after this ISO 8601 date. | | `email_verified_before` | string | — | Only contacts whose email verdict was recorded at or before this ISO 8601 date (stale verdicts). Neither date bound can be combined with `unverified` (no verdict on file, so no verdict date); the request returns `400`. | | `has_event_type` | string | — | Only contacts who have a tracked event of this type (e.g. `button_click`). | | `has_event_text` | string | — | Only contacts who have an event with this exact element text/label (`data.text`), e.g. `Go!`. | | `has_event_text_contains`| string | — | Only contacts who have an event whose element text/label contains this substring. | | `has_event_url_contains` | string | — | Only contacts who have an event whose URL contains this substring. | | `has_event_class_contains`| string | — | Only contacts who interacted with an element whose CSS class (`data.class`) contains this substring. This is the way to target elements without text or an id (icon buttons). Use a stable class token. | | `has_event_element_id` | string | — | Only contacts who have an event on the DOM element with this id (`data.id`). | | `has_event_after` | string | — | Only contacts whose matching event occurred at or after this ISO 8601 date. | | `has_event_before` | string | — | Only contacts whose matching event occurred at or before this ISO 8601 date. | | `include_source_fields` | boolean | `false` | With `fields=summary`: add `source_fields`, a selection of each connected source's own values for the contact (for example `hubspot.lifecyclestage`). Personal fields are left out. | | `include` | string | — | Comma-separated list of optional fields to include. Currently supports `provenance` to add the per-field source/timestamp record (`properties_provenance`). Default omits provenance for response-size hygiene. | | `emails` | string | — | Switches the endpoint to [checking which emails exist](#check-which-emails-exist). No other parameter is applied, though each one sent is still validated. | The `has_event_*` filters return the contacts who performed a matching [tracked event](/docs/api/events). For example `?has_event_type=button_click&has_event_text=Go!` lists the known people who clicked the `Go!` button. They compose with every other filter (`&lifecycle_stage=lead`), with `count_only`, and with pagination/sorting. Notes: - **Identified contacts only.** Events are matched by their linked contact, so anonymous visitors never appear. To count distinct *visitors* (identified + anonymous), use [`POST /v1/aggregate`](/docs/api/batch#aggregate) with `group_by=visitor_id`. - **Scope:** using any `has_event_*` filter requires `events:read` in addition to `contacts:read`. - If an event filter matches an unusually large number of contacts, the request returns `400`. Narrow it with `has_event_after` / `has_event_before`. Every listed contact carries `email_verification_state`, the deliverability verdict on file (`deliverable`, `undeliverable`, `risky`, `unknown`, or `null` when nobody has verified the address). It is the verdict whoever reached it: Emailable, or a data provider such as Hunter that verified the address it found. `emailable: { state, score } | null` adds Emailable's own detail and is `null` for an address Emailable did not check. The `email_verification_state` query parameter filters on the verdict server-side, so `?email_verification_state=deliverable&count_only=true` answers "how many contacts are safe to email" without paging any records. The finer verdict fields (`score`, `reason`, `role`, `free`, `disposable`, `accept_all`, `smtp_provider`) are available through `filters` as `emailable.` once Emailable is connected. ### Response — `200 OK` ```json { "status": "success", "contacts": [ { "contact_id": "665f1c2ab3d4e5f601234567", "_id": "665f1c2ab3d4e5f601234567", "properties": { "email": "ada@example.com", "firstname": "Ada", "lastname": "Lovelace" }, "associations": { "companies": ["665f1c2ab3d4e5f6012345aa"] }, "first_seen_at": "2026-09-15T10:00:00.000Z", "created_at": "2026-09-15T10:00:00.000Z", "updated_at": "2026-10-12T10:00:00.000Z", "tags": ["webinar-oct"], "emailable": { "state": "deliverable", "score": 97 } } ], "pagination": { "page": 1, "limit": 10, "total_pages": 10, "total_items": 100 }, "timestamp": "2026-10-12T10:00:00.000Z" } ``` `_id` is a transitional alias for `contact_id` and will be removed in a future release. With `fields=summary` each row is flat: ```json { "contact_id": "665f1c2ab3d4e5f601234567", "_id": "665f1c2ab3d4e5f601234567", "email": "ada@example.com", "firstname": "Ada", "lastname": "Lovelace", "company": "Analytical Engines", "lifecyclestage": "customer", "last_activity_at": "2026-10-12T10:00:00.000Z", "emailable": { "state": "deliverable", "score": 97 }, "email_verification_state": "deliverable", "tags": ["webinar-oct"] } ``` With `fields=ids` each row is `{ "contact_id", "_id" }` and the page does not count against your plan. A page in any other shape counts every contact on it as read. To walk a large list cheaply, filter with `fields=ids` and read what you need through the [batch API](/docs/api/batch). For a "how many" question, prefer `count_only=true`. ### Check which emails exist Pass `emails`, a comma-separated list of up to 50 addresses, to learn which of them are already contacts. This is the cheap way to avoid creating duplicates before an import. It is free, and it matches the whole address exactly after normalizing it, unlike `search`. ```bash curl -G https://api.pathbound.ai/v1/contacts \ -H "Authorization: Bearer $PATHBOUND_API_KEY" \ --data-urlencode "emails=ada@example.com,nobody@example.com" ``` ```json { "status": "success", "mode": "exists", "requested": 2, "found": 1, "results": [ { "email": "ada@example.com", "exists": true, "id": "665f1c2ab3d4e5f601234567" }, { "email": "nobody@example.com", "exists": false, "id": null } ], "timestamp": "2026-10-12T10:00:00.000Z" } ``` Addresses past the 50th are not checked, and the response then carries `"truncated": true`. --- ## Get a Contact ``` GET /v1/contacts/:contact_id ``` **Required scope:** `contacts:read` Returns the contact along with its unified profile and (optionally) recent events. ### Query Parameters | Parameter | Type | Default | Description | | ----------------- | ------- | ------- | ---------------------------------------------------- | | `include_events` | boolean | `true` | If `false`, omit the `events` array and `total_events`. | | `events_limit` | number | `20` | Max events to include (1–50). Ignored when `include_events=false`. | | `include` | string | — | Comma-separated list of optional fields to include. Supports `provenance` (per-field provenance on the base `contact` record) and `raw` (adds `unified_profile.raw_data`, the un-deduplicated per-source field dumps; large, omitted by default). | | `dry_run` | boolean | `false` | If `true`, return whether reading this contact would add to your plan's count, and no record. | ### Response — `200 OK` ```json { "status": "success", "contact": { "contact_id": "665f1c2ab3d4e5f601234567", "_id": "665f1c2ab3d4e5f601234567", "properties": { "email": "ada@example.com", "firstname": "Ada", "lastname": "Lovelace" }, "associations": { "companies": ["665f1c2ab3d4e5f6012345aa"] }, "_summary": "Ada Lovelace | CTO | at Analytical Engines | London | Stage: customer", "created_at": "2026-09-15T10:00:00.000Z", "updated_at": "2026-10-12T12:00:00.000Z" }, "unified_profile": { "_id": "665f1c2ab3d4e5f601234567", "source_count": 3, "sources": { "internal": { "id": "665f1c2ab3d4e5f601234567", "updated_at": "...", "first_seen_at": "..." }, "hubspot": { "id": "12345", "synced_at": "...", "first_seen_at": "..." }, "apollo": { "id": "ap_678", "enriched_at": "..." } }, "profile": { "email": { "unique_values": [ { "value": "ada@example.com", "sources": [{ "source": "hubspot", "updated_at": "..." }, { "source": "apollo", "updated_at": "..." }] } ], "all_sources": ["hubspot", "apollo"], "has_conflict": false }, "jobtitle": { "unique_values": [ { "value": "CTO", "sources": [{ "source": "hubspot", "updated_at": "..." }] }, { "value": "Chief Technology Officer", "sources": [{ "source": "apollo", "updated_at": "..." }] } ], "all_sources": ["hubspot", "apollo"], "has_conflict": true } }, "created_at": "...", "updated_at": "...", "enriched_at": "..." }, "events": [ { "event_id": "665f1c2ab3d4e5f6012345e1", "_id": "665f1c2ab3d4e5f6012345e1", "event": "page_view", "timestamp": "...", "url": "...", "title": "..." } ], "total_events": 42, "timestamp": "2026-10-12T12:00:00.000Z" } ``` `_summary` is a human-readable one-liner the backend stitches together from the contact's name, title, company, industry, location, and lifecycle stage. Parts are joined with ` | ` and prefixed where applicable (`at `, `Stage: `). Convenient for list views, but not a stable contract, so don't parse it. `unified_profile` is the full deduplicated cross-source profile. Each `profile` field is a multi-source object: `unique_values` holds every distinct value found across sources (deduplicated), each annotated with the `sources` that supplied it; `all_sources` lists every source that had any value for the field; and `has_conflict` is `true` when sources disagree (more than one distinct value). `sources` is a per-source map (presence, external IDs, and sync/enrichment timestamps), and `source_count` is how many sources contributed. Pass `?include=raw` to additionally get `raw_data`, the verbatim, un-deduplicated per-source field dumps. `events` is a compact projection of the contact's most recent events and is omitted (along with `total_events`) when the request is made with `?include_events=false`. When PII redaction is enabled for an AI-agent (MCP) caller, the personal values inside `unified_profile.profile` are masked (the field and its source attribution remain visible) and `raw_data` is never returned, even with `?include=raw`. An API key is never redacted. --- ## Update a Contact ``` PUT /v1/contacts/:contact_id ``` **Required scope:** `contacts:write` ### Request Body | Field | Type | Required | Description | | ------------ | ---------------- | -------- | ------------------------------------ | | `properties` | object | yes | Properties to update or add. They are merged into the contact: a property you do not send keeps its value. Send `{}` to change only the fields below. | | `visitor_id` | string \| string[] | no | Visitor ID(s) to associate. | | `company_id` | string | no | A company to associate the contact with. An id that is not a company in your workspace is a `404`. | To change a contact's lifecycle stage, set `properties.lifecyclestage` to one of the [allowed values](#lifecycle-stages). The response is the updated contact, in the shape of [Create a Contact](#create-a-contact). ### Lifecycle stages `lead`, `prospect`, `mql`, `sql`, `opportunity`, `customer`, `evangelist`, `subscriber`, `other`. --- ## Delete a Contact ``` DELETE /v1/contacts/:contact_id ``` **Required scope:** `contacts:write` Permanently removes the contact and its notes. The contact's events are kept. This cannot be undone. An AI agent connected through the MCP server cannot call this endpoint: it is answered `403` with the code `AGENT_DELETE_FORBIDDEN`. An API key can. --- ## Get Contact Events ``` GET /v1/contacts/events/:contact_id ``` **Required scope:** `events:read` Returns the events associated with a contact, newest first by default. For aggregate event queries (across many contacts), use the [Events API](/docs/api/events) directly. ### Query Parameters | Parameter | Type | Default | Description | | ------------ | ------ | ------- | ------------------------------------ | | `limit` | number | 50 | Events to return (1–200). | | `offset` | number | 0 | Events to skip. Page with `offset` and `limit`. | | `event_type` | string | — | Filter by event type. | | `date_after` | string | — | Only events at or after this ISO 8601 date. | | `date_before`| string | — | Only events at or before this ISO 8601 date. | | `sort_dir` | string | `desc` | Sort direction (`asc` or `desc`). | | `fields` | string | — | `summary` for a compact projection (`event_id`, `_id`, `event`, `timestamp`, `url`, `contact_id`); omit for full. | | `count_only` | boolean | `false` | If `true`, return only `{ count }`: how many of the contact's events match. Free. | ### Response — `200 OK` ```json { "status": "success", "events": [ { "event_id": "665f1c2ab3d4e5f6012345e1", "_id": "665f1c2ab3d4e5f6012345e1", "event": "page_view", "timestamp": "2026-10-12T11:23:45.000Z", "url": "https://example.com/pricing", "domain": "example.com", "data": { "title": "Pricing — Example", "path": "/pricing" } } ], "contact_id": "665f1c2ab3d4e5f601234567", "total_items": 212, "returned": 50, "timestamp": "2026-10-12T12:00:00.000Z" } ``` `total_items` is how many events match in all, and `returned` how many are in this response. Ask for the next page with `offset` raised by `limit` until you have `total_items`. --- ## Get Contact Conversations ``` GET /v1/contacts/:contact_id/conversations ``` **Required scope:** `contacts:read` Returns the contact's conversation history, one block per channel: `email` (Gmail/SMTP), `intercom`, `hubspot` (logged 1:1 emails), `instantly` (cold-email messages), and `meetings` (tl;dv recordings and Calendly bookings). Each block has its own `conversations[]` and `pagination`. Blocks for integrations that aren't connected come back empty. The same history across everyone at a company is [`GET /v1/companies/:company_id/conversations`](/docs/api/companies#get-company-conversations). ### Query Parameters | Parameter | Type | Default | Description | | ------------------ | ------- | ------- | ----------- | | `source` | string | `all` | One channel: `email`, `intercom`, `hubspot`, `instantly`, `meetings`, or `all`. | | `include_messages` | boolean | `false` | Include message bodies (email/HubSpot/Instantly truncated to 5,000 characters per message, Intercom parts to 2,000; the last 20 messages per email thread). For meetings, adds tl;dv's AI notes or Calendly's booking-form answers. Never the transcript. | | `page` | number | 1 | Page number. Each channel pages independently. | | `limit` | number | 20 | Threads per channel per page (1–50). | | `sort_dir` | string | `desc` | `asc` or `desc`, by last-message date (meetings: by meeting date). | | `date_after` | string | — | ISO 8601. Only threads with activity (or meetings held) after this date. | | `date_before` | string | — | ISO 8601. Only threads with activity (or meetings held) before this date. | With `include_messages=true`, an Intercom conversation's `parts` start with its opening message (`part_type: "source"`, dated with the conversation's `created_at`), followed by the conversation parts in order. A tl;dv meeting in the `meetings` block reports `has_transcript`, `transcript_segment_count`, and `has_notes`. Read the transcript itself with [Get Meeting Transcript](#get-meeting-transcript). To read notes from many meetings at once, use [`POST /v1/meetings/query`](/docs/api/batch#query-meetings). --- ## Get Meeting Transcript ``` GET /v1/contacts/:contact_id/meetings/:meeting_id/transcript ``` **Required scope:** `contacts:read` Returns one page of a tl;dv recording's transcript. `meeting_id` is the `_id` of a meeting in the `meetings` block of the conversations endpoint, and `contact_id` must be a contact who was on the call. Any other combination returns `404`. Calendly bookings have no transcript. Pages are cut by size, not by segment count. To read the whole transcript, pass each response's `next_offset` back as `offset` until `next_offset` is `null`. ### Query Parameters | Parameter | Type | Default | Description | | ----------- | ------ | ------- | ----------- | | `offset` | number | 0 | Segment index to start from. | | `max_chars` | number | 8000 | Page size in characters (1,000–50,000). | ### Response ```json { "status": "success", "contact_id": "665f1c2ab3d4e5f601234567", "meeting": { "_id": "6650f1c2e4b0a1b2c3d4e5f6", "name": "QBR — Acme", "happened_at": "2026-09-22T15:00:00.000Z", "duration_seconds": 2280 }, "transcript": { "segments": [ { "speaker": "Dana Lee", "start_time": 0, "text": "Thanks for making time today." }, { "speaker": "Sam Ortiz", "start_time": 4, "text": "Happy to. Let's start with the security review." } ], "offset": 0, "returned": 2, "total_segments": 412, "next_offset": 2 } } ``` `start_time` is seconds from the start of the recording. A meeting with no transcript returns an empty page with `unavailable_reason`: `not_available` (tl;dv hasn't produced one yet) or `access_denied` (tl;dv refused this connection access to the recording). --- ## List Contact Email Threads ``` GET /v1/contacts/:contact_id/email-threads ``` **Required scope:** `contacts:read` Returns a compact list of the contact's Gmail threads, in `threads`. `count` is the number of threads in this response, at most `limit`: it is not the contact's total, and no total is returned. Each entry names the thread and its last message, which is what you need to [reply inside a thread](/docs/api/gmail). ### Query Parameters | Parameter | Type | Default | Description | | --------- | ------ | ------- | ----------- | | `limit` | number | 10 | Threads to return (1–50). | --- ## Contact Notes Each contact has a list of free-form notes. Notes are written directly (no approval flow) and are visible to anyone with `contacts:read`. ### List Notes ``` GET /v1/contacts/:contact_id/notes ``` **Required scope:** `contacts:read` ### Create a Note ``` POST /v1/contacts/:contact_id/notes ``` **Required scope:** `contacts:write` | Field | Type | Required | Description | | --------- | ------ | -------- | --------------------- | | `content` | string | yes | Note body (markdown), up to 10,000 characters. | ### Update a Note ``` PATCH /v1/contacts/:contact_id/notes/:note_id ``` **Required scope:** `contacts:write` Takes the same body as [Create a Note](#create-a-note) and replaces the note's text. ### Delete a Note ``` DELETE /v1/contacts/:contact_id/notes/:note_id ``` **Required scope:** `contacts:write` --- # Companies API > Create, list, search, and manage company records in Pathbound via the REST API. Source: https://pathbound.ai/docs/api/companies Companies represent organizations in your Pathbound workspace, identified by their domain. Contacts roll up to companies for account-level intelligence. | Endpoint | Scope | What it does | | -------- | ----- | ------------ | | [`POST /v1/companies`](#create-a-company) | `companies:write` | Create one company. | | [`POST /v1/companies/bulk`](#create-companies-in-bulk) | `companies:write` | Create up to 1,000 companies in one request. | | [`GET /v1/companies`](#list-companies) | `companies:read` | List, filter, search and count companies, or check which domains you already have. | | [`GET /v1/companies/:company_id`](#get-a-company) | `companies:read` | One company with its unified profile and contacts. | | [`PUT /v1/companies/:company_id`](#update-a-company) | `companies:write` | Change properties. | | [`DELETE /v1/companies/:company_id`](#delete-a-company) | `companies:write` | Delete the company. | | [`GET /v1/companies/:company_id/conversations`](#get-company-conversations) | `companies:read` and `contacts:read` | Email and chat threads and meetings with everyone at the company. | | [`GET`, `POST` `/v1/companies/:company_id/notes`](#company-notes) | `companies:read`, `companies:write` | List the company's notes, add one. | | [`PATCH`, `DELETE` `/v1/companies/:company_id/notes/:note_id`](#update-a-note) | `companies:write` | Change or delete one note. | Related endpoints on other pages: [tags on a company](/docs/api/tags), [deals](/docs/api/deals), and [enriching companies and finding people at them](/docs/api/enrichment). ## Create a Company ``` POST /v1/companies ``` **Required scope:** `companies:write` Without `external_id`, this creates a company and a domain that already exists is a `409`. With `external_id`, your own id for the account, it is an upsert on that id, so the same request sent twice leaves one company, unchanged by the second, and two sent at the same moment make one: 1. A company that has this `external_id` is updated. 2. Else a company that has no external id yet and holds the `domain` (one your CRM brought in, say) takes your id and is updated. 3. Else the company is created. An update merges what you send: a property you send replaces the stored value, and one you leave out keeps it. A plan change is this call again with the new values. With `external_id` the domain is optional, and a domain that cannot be stored is left off while the company is still written; `skipped_traits.domain` says why: | `skipped_traits.domain` | Why | | --- | --- | | `free_mail_domain` | The domain is a free email provider's, such as `gmail.com`: a user who signs up with a personal address still gets an account, without a domain. | | `invalid_domain` | The domain is not a host name (no dot). | | `domain_belongs_to_company:` | Another company holds the domain, for example a second account at the same firm. The domain stays with that company. | ### Request Body | Field | Type | Required | Description | | ------------ | ------ | -------- | -------------------------------------------- | | `domain` | string | without `external_id` | Company domain (must be unique). It is stored normalized: `https://www.Example.com/about` is `example.com`. A `domain` inside `properties` is ignored. | | `external_id` | string | no | Your own id for the account, up to 100 characters, stored as `external_company_id`. Placeholder values (`"undefined"`, `"null"`, …) are a `400` with `code: "INVALID_EXTERNAL_ID"`. | | `properties` | object | no | Arbitrary key-value properties. `lifecyclestage`, if set, must be one of `lead`, `prospect`, `mql`, `sql`, `opportunity`, `customer`, `evangelist`, `subscriber`, `other`. | ### Response — `201 Created` `201` when the company was created. A company updated through its `external_id` is answered `200 OK` in the same shape, with `"created": false`. ```json { "status": "success", "message": "Company created successfully", "company_id": "665f1c2ab3d4e5f6012345aa", "created": true, "company": { "company_id": "665f1c2ab3d4e5f6012345aa", "_id": "665f1c2ab3d4e5f6012345aa", "properties": { "domain": "example.com", "name": "Example Corp" }, "created_at": "2026-09-15T10:00:00.000Z", "updated_at": "2026-09-15T10:00:00.000Z" } } ``` `_id` is a transitional alias for `company_id` and will be removed in a future release. Prefer `company_id` in new integrations. Without `external_id`, returns `409 Conflict` if a company with the same domain already exists, with its id as `company_id`. Without `external_id`, returns `400` with a `code` when the domain cannot name a company, and nothing is created: | `code` | When | | ------ | ---- | | `INVALID_DOMAIN` | The domain is not a host name: no dot, as in `acme`. | | `FREE_MAIL_DOMAIN` | The domain is a free email provider's, such as `gmail.com` or `outlook.com`: it is where a person's mail is, not a company. | ```json { "status": "error", "code": "FREE_MAIL_DOMAIN", "error": "gmail.com is a free email provider, not a company's domain", "timestamp": "2026-10-22T10:00:00.000Z" } ``` --- ## Create Companies in Bulk ``` POST /v1/companies/bulk ``` **Required scope:** `companies:write` Creates up to 1,000 companies in one request. It is safe to send the same list twice: a company whose domain already exists is reported as `existing` and is not changed. An item with an `external_id` is an upsert on it, as on [Create a Company](#create-a-company), and comes back `created` or `updated`. One bad item never fails the others. ### Request Body | Field | Type | Required | Description | | ----------- | -------- | -------- | ----------- | | `companies` | object[] | yes | 1 to 1,000 items. | | `companies[].domain` | string | without `external_id` | The company's domain. | | `companies[].external_id` | string | no | As in [Create a Company](#create-a-company). | | `companies[].properties` | object | no | As in [Create a Company](#create-a-company). | | `companies[].tags` | string[] | no | [Tags](/docs/api/tags) to put on the company. Applied to companies the request creates or updates, not to an `existing` one. | ### Response — `200 OK` ```json { "status": "success", "requested": 2, "created": 1, "updated": 0, "existing": 1, "errors": 0, "results": [ { "index": 0, "domain": "example.com", "status": "existing", "id": "665f1c2ab3d4e5f6012345aa" }, { "index": 1, "domain": "analyticalengines.example", "status": "created", "id": "665f1c2ab3d4e5f6012345ab" } ], "timestamp": "2026-10-12T10:00:00.000Z" } ``` `results` has one entry per item you sent, in the same order. An entry's `status` is `created`, `updated` (an item with an `external_id` that found its company), `existing` (an item without one whose domain exists) or `error`. An item that could not be written has an `error` text, and the `code` of [Create a Company](#create-a-company) when it has one (`INVALID_DOMAIN`, `FREE_MAIL_DOMAIN`, `INVALID_EXTERNAL_ID`); `skipped_traits` appears when a domain was left off. The request is a `200` even when some items failed, so read `errors`. A `429` with `code: "QUOTA_EXCEEDED"` means the new companies in the batch would take the workspace past its limit; an item that updates a company through its `external_id` does not count toward it. --- ## List Companies ``` GET /v1/companies ``` **Required scope:** `companies:read` ### Query Parameters | Parameter | Type | Default | Description | | ---------- | ------ | ------- | ------------------------------------------ | | `page` | number | 1 | Page number. | | `limit` | number | 10 | Results per page (1–100). | | `fields` | string | `full` | `ids` (the id only), `summary` (`company_id`, `id`, `name`, `domain`, `industry`, `last_activity_at`, `tags`) or `full`. See [How much of each record comes back](/docs/api/overview#how-much-of-each-record-comes-back). | | `count_only` | boolean | `false` | If `true`, return only `{ count }` and no records. | | `dry_run` | boolean | `false` | If `true`, return what reading this page would add to your plan's count, and no records. See [Usage and metering](/docs/api/overview#usage-and-metering). | | `search` | string | — | Free-text search by name, domain, industry.| | `sort_by` | string | `lastmodifieddate` | `domain`, `name`, `createdate`, `lastmodifieddate`, or an indexed source field written `source.field` (for example `apollo.employee_count`). | | `sort_dir` | string | `desc` | Sort direction (`asc` or `desc`). | | `industry` | string | — | Filter by industry (whole value, case-insensitive). | | `country` | string | — | Filter by country (whole value, case-insensitive). | | `external_id` | string | — | Your own id for the account, the one [Create a Company](#create-a-company) stores as `external_company_id`. Exact match, so zero or one company. One id of up to 255 characters; a repeated parameter is a `400`. | | `tags_any` | string | — | Comma-separated [tags](/docs/api/tags), up to 50. Companies that carry at least one of them. | | `tags_all` | string | — | Comma-separated tags, up to 50. Companies that carry every one of them. When both are sent, `tags_all` is used. | | `data_sources` | string | — | Comma-separated sources. Companies that have data from at least one of them: `hubspot`, `intercom`, `apollo`, `pipedrive`, `zendesk`, `zoho`, `tldv`, `calendly`, `postgres` (a connected database). An unknown name is a `400`. | | `segment` | string | — | The id or handle of a [saved segment](/docs/api/segments) of companies. Only companies in it. | | `segments_any` | string | — | Comma-separated segment ids or handles. Companies in at least one of them. | | `filters` | string | — | [Typed filters](/docs/api/overview#filtering) as a JSON array `[{"field","op","value"}]`, up to 10, on properties or source fields (for example `apollo.employee_count`, `hubspot.numberofemployees`). [`GET /v1/schema?entity=companies`](/docs/api/workspace#schema) lists the fields available to your workspace. | | `exclude_internal` | boolean | `false` | Leave out your own company and test companies, as defined in your workspace's settings. | | `include_source_fields` | boolean | `false` | With `fields=summary`: add `source_fields`, a selection of each connected source's own firmographics for the company. | | `include` | string | — | Comma-separated list of optional fields to include. Currently supports `provenance` to add the per-field source/timestamp record (`properties_provenance`). | | `domains` | string | — | Switches the endpoint to [checking which domains exist](#check-which-domains-exist). No other parameter is applied, though each one sent is still validated. | ### Response — `200 OK` ```json { "status": "success", "companies": [ { "company_id": "665f1c2ab3d4e5f6012345aa", "_id": "665f1c2ab3d4e5f6012345aa", "properties": { "domain": "example.com", "name": "Example Corp", "industry": "Software" }, "created_at": "2026-09-15T10:00:00.000Z", "updated_at": "2026-10-12T12:00:00.000Z", "tags": ["enterprise"] } ], "pagination": { "page": 1, "limit": 10, "total_pages": 3, "total_items": 25 }, "timestamp": "2026-10-12T12:00:00.000Z" } ``` `_id` is a transitional alias for `company_id` and will be removed in a future release. With `fields=ids` each row is `{ "company_id", "id" }` and the page does not count against your plan. A page in any other shape counts every company on it as read. To walk a large list cheaply, filter with `fields=ids` and read what you need through the [batch API](/docs/api/batch). ### Check which domains exist Pass `domains`, a comma-separated list of up to 50 domains, to learn which of them are already companies. It is free, and it matches the whole domain exactly after normalizing it, unlike `search`. ```bash curl -G https://api.pathbound.ai/v1/companies \ -H "Authorization: Bearer $PATHBOUND_API_KEY" \ --data-urlencode "domains=example.com,unknown.example" ``` ```json { "status": "success", "mode": "exists", "requested": 2, "found": 1, "results": [ { "domain": "example.com", "exists": true, "id": "665f1c2ab3d4e5f6012345aa" }, { "domain": "unknown.example", "exists": false, "id": null } ], "timestamp": "2026-10-12T12:00:00.000Z" } ``` Domains past the 50th are not checked, and the response then carries `"truncated": true`. --- ## Get a Company ``` GET /v1/companies/:company_id ``` **Required scope:** `companies:read` Returns the company along with its full deduplicated cross-source `unified_profile` and its associated contacts. ### Query Parameters | Parameter | Type | Default | Description | | ------------------ | ------- | ------- | -------------------------------------------------------- | | `include_contacts` | boolean | `true` | If `false`, omit the `contacts` array from the response. | | `contacts_limit` | number | `10` | How many associated contacts to return (1–25). | | `include` | string | — | Comma-separated list of optional fields to include. Supports `provenance` (per-field provenance on the base `company` record) and `raw` (adds `unified_profile.raw_data`, the un-deduplicated per-source field dumps; large, omitted by default). | ### Response — `200 OK` ```json { "status": "success", "company": { "company_id": "665f1c2ab3d4e5f6012345aa", "_id": "665f1c2ab3d4e5f6012345aa", "properties": { "domain": "example.com", "name": "Example Corp", "industry": "Software", "employee_count": 250, "lifecyclestage": "customer" }, "created_at": "2026-09-15T10:00:00.000Z" }, "unified_profile": { "_id": "665f1c2ab3d4e5f6012345aa", "source_count": 2, "sources": { "internal": { "id": "665f1c2ab3d4e5f6012345aa", "updated_at": "...", "first_seen_at": "..." }, "hubspot": { "id": "98765", "synced_at": "...", "first_seen_at": "..." } }, "profile": { "name": { "unique_values": [ { "value": "Example Corp", "sources": [{ "source": "internal", "updated_at": "..." }, { "source": "hubspot", "updated_at": "..." }] } ], "all_sources": ["internal", "hubspot"], "has_conflict": false }, "numberofemployees": { "unique_values": [ { "value": 250, "sources": [{ "source": "internal", "updated_at": "..." }] }, { "value": 300, "sources": [{ "source": "hubspot", "updated_at": "..." }] } ], "all_sources": ["internal", "hubspot"], "has_conflict": true } }, "created_at": "...", "updated_at": "..." }, "contacts": [ { "id": "665f1c2ab3d4e5f601234567", "email": "ada@example.com", "firstname": "Ada", "lastname": "Lovelace", "job_title": "CTO", "lifecycle_stage": "customer" } ], "total_contacts": 12 } ``` `unified_profile` is the full deduplicated cross-source profile. Each `profile` field is a multi-source object: `unique_values` holds every distinct value found across sources (deduplicated), each annotated with the `sources` that supplied it; `all_sources` lists every source that had any value; and `has_conflict` is `true` when sources disagree. `sources` is a per-source map (presence, external IDs, sync/enrichment timestamps), and `source_count` is how many sources contributed. Pass `?include=raw` for `raw_data` (the verbatim, un-deduplicated per-source dumps). Company firmographics are not PII and are never masked. The embedded `contacts` array is a compact projection (intentionally uses `id` rather than the full contact serializer). For the full contact shape, fetch the contact directly via `GET /v1/contacts/:contact_id`. --- ## Update a Company ``` PUT /v1/companies/:company_id ``` **Required scope:** `companies:write` ### Request Body | Field | Type | Required | Description | | ------------ | ------ | -------- | ------------------------------------ | | `properties` | object | yes | Properties to update or add. They are merged into the company: a property you do not send keeps its value. | ### Response — `200 OK` ```json { "status": "success", "company": { "company_id": "665f1c2ab3d4e5f6012345aa", "_id": "665f1c2ab3d4e5f6012345aa", "properties": { "domain": "example.com", "name": "Example Corp", "industry": "Software" }, "updated_at": "2026-10-12T12:00:00.000Z" } } ``` --- ## Delete a Company ``` DELETE /v1/companies/:company_id ``` **Required scope:** `companies:write` Permanently removes the company and its notes. Its contacts are not deleted. This cannot be undone. An AI agent connected through the MCP server cannot call this endpoint: it is answered `403` with the code `AGENT_DELETE_FORBIDDEN`. An API key can. --- ## Get Company Conversations ``` GET /v1/companies/:company_id/conversations ``` **Required scopes:** `companies:read` and `contacts:read` Returns the conversation history of everyone at the company in one response: the same channel blocks as [Get Contact Conversations](/docs/api/contacts#get-contact-conversations), each holding the threads of all the company's contacts. It takes the same query parameters (`source`, `include_messages`, `page`, `limit`, `sort_dir`, `date_after`, `date_before`). The response also carries `company_id` and `contact_count`, the number of contacts whose history was read. Use it to answer "what have we discussed with this account" without walking the contacts one by one. --- ## Company Notes Each company can have free-form notes attached, just like contacts. ### List Notes ``` GET /v1/companies/:company_id/notes ``` **Required scope:** `companies:read` ### Create a Note ``` POST /v1/companies/:company_id/notes ``` **Required scope:** `companies:write` | Field | Type | Required | Description | | --------- | ------ | -------- | --------------------- | | `content` | string | yes | Note body (markdown), up to 10,000 characters. | ### Update a Note ``` PATCH /v1/companies/:company_id/notes/:note_id ``` **Required scope:** `companies:write` ### Delete a Note ``` DELETE /v1/companies/:company_id/notes/:note_id ``` **Required scope:** `companies:write` --- # Deals > Create, list, update and delete deals in Pathbound, and keep notes and tags on them, through the REST API. Source: https://pathbound.ai/docs/api/deals A deal is a revenue opportunity. It has a name, a stage, an amount, a currency, a close date and a status (`open`, `won` or `lost`), and it can be linked to a [company](/docs/api/companies), a [contact](/docs/api/contacts), or both. Deals exist only in Pathbound. No integration brings them in from a CRM: a deal is created through this API, by an AI agent through the MCP server, or by a person in the dashboard. To mirror deals from another system, create them here and put that system's id in `external_deal_id`. A deal cannot be a member of a [segment](/docs/api/segments), and a change to a deal is not delivered to a [stream](/docs/api/webhooks). The MCP tools `list_deals`, `get_deal`, `create_deal`, `create_deals_bulk`, `update_deal`, `delete_deal`, `list_deal_notes`, `create_deal_note`, `update_deal_note`, `delete_deal_note`, `add_deal_tags` and `remove_deal_tags` call the endpoints on this page. See the [tools reference](/docs/mcp/tools). | Endpoint | Scope | What it does | | -------- | ----- | ------------ | | `POST /v1/deals` | `deals:write` | Creates a deal, or updates the one that has your `external_deal_id`. | | `POST /v1/deals/bulk` | `deals:write` | Creates or updates up to 1,000 deals in one request. | | `GET /v1/deals` | `deals:read` | Lists and filters deals. | | `GET /v1/deals/:deal_id` | `deals:read` | Returns one deal with its company and contact. | | `PUT /v1/deals/:deal_id` | `deals:write` | Changes a deal's properties or links. | | `DELETE /v1/deals/:deal_id` | `deals:write` | Deletes a deal. | | `GET /v1/deals/:deal_id/notes` | `deals:read` | Lists a deal's notes. | | `POST /v1/deals/:deal_id/notes` | `deals:write` | Adds a note to a deal. | | `PATCH /v1/deals/:deal_id/notes/:note_id` | `deals:write` | Changes a note's text. | | `DELETE /v1/deals/:deal_id/notes/:note_id` | `deals:write` | Deletes a note. | | `POST /v1/deals/:deal_id/tags` | `deals:write` | Adds tags to one deal. See [Tags](/docs/api/tags#add-tags-to-one-record). | | `DELETE /v1/deals/:deal_id/tags` | `deals:write` | Removes tags from one deal. See [Tags](/docs/api/tags#remove-tags-from-one-record). | | `POST /v1/deals/tags/bulk` | `deals:write` | Adds and removes tags on up to 1,000 deals. See [Tags](/docs/api/tags#add-or-remove-tags-in-bulk). | Neither scope is opt-in. See [Scopes](/docs/getting-started/authentication#scopes). Deals can also be read through [`POST /v1/batch/details`](/docs/api/batch#batch-get-details) (up to 25 by id) and summed or counted through [`POST /v1/aggregate`](/docs/api/batch#aggregate). To total a pipeline by stage, use the aggregate endpoint instead of paging through this list. ## Usage and Metering Reading a deal counts as reading the customer it belongs to: the deal's company or, failing that, its contact. A deal linked to neither counts as a customer of its own. `GET /v1/deals/:deal_id` always counts. `GET /v1/deals` counts for every deal on the page, unless you ask for `fields=ids` or `count_only=true`. A customer you have already read in the billing cycle is not counted again. The notes endpoints, the tag endpoints and every write are free. The deal endpoints have no `dry_run`: to learn what a read would cost, use `dry_run` on [`POST /v1/batch/details`](/docs/api/batch#batch-get-details). See [Usage and metering](/docs/api/overview#usage-and-metering). ## The Deal Object ```json { "deal_id": "6acd7a3e9b1c4d2e8f012345", "_id": "6acd7a3e9b1c4d2e8f012345", "properties": { "name": "Northwind annual plan", "stage": "negotiation", "amount": 24000, "currency": "USD", "status": "open", "close_date": "2026-10-30T00:00:00.000Z", "createdate": "2026-10-13T09:14:02.118Z", "lastmodifieddate": "2026-10-13T09:14:02.118Z" }, "company_id": "665f1c2ab3d4e5f601234567", "contact_id": "665f1c2ab3d4e5f6012345ab", "external_deal_id": "crm-48213", "notes": [], "tags": ["renewal", "q4"], "creation_source": "api", "first_seen_at": "2026-10-13T09:14:02.118Z", "created_at": "2026-10-13T09:14:02.118Z", "updated_at": "2026-10-13T09:14:02.118Z" } ``` | Field | Description | | ----- | ----------- | | `deal_id` | The deal's id. `_id` repeats it; use `deal_id`. | | `properties.name` | The deal's name. | | `properties.stage` | The pipeline stage. Stages are your own names: Pathbound keeps no list of them. | | `properties.amount` | The deal's value, a number from 0 to 1e15. | | `properties.currency` | A three-letter code, uppercase. | | `properties.status` | `open`, `won` or `lost`. | | `properties.close_date` | The expected or actual close date. | | `properties.createdate`, `properties.lastmodifieddate` | Set by Pathbound when the deal is created and when its properties change. | | `properties.*` | Any other key you stored. | | `company_id`, `contact_id` | The linked company and contact. Absent when the deal is not linked. | | `external_deal_id` | Your own id for the deal, when you gave one. | | `notes` | Every note on the deal. See [List Deal Notes](#list-deal-notes) for a note's fields. | | `tags` | The deal's [tags](/docs/api/tags). Always an array. | | `creation_source` | `api` for a deal created through this API or the MCP server, `manual` for one created in the dashboard. | | `first_seen_at`, `created_at` | When the deal was created. | | `updated_at` | When the deal last changed. | ## Create a Deal ``` POST /v1/deals ``` **Required scope:** `deals:write` Creates one deal. No field is required: an empty body creates a deal with `status: "open"`, `currency: "USD"` and nothing else. With an `external_deal_id` the call is an upsert on it: when a deal in your workspace already has that id, the call updates that deal and is answered `200` with `"created": false`. The same request sent twice therefore leaves one deal, unchanged by the second call. On an update: - a property you send replaces the stored value, and one you leave out, or send as `null`, keeps it; - `status` and `currency` are not reset to their defaults; - a `company_id`, `contact_id`, `company` or `contact` you send replaces the link, and one you leave out keeps it. A company inherited from the contact only fills a deal that has none; - `tags` are added to the deal's own. :::caution Without an `external_deal_id`, creating a deal is not idempotent: two identical requests create two deals, and this endpoint does not read an `Idempotency-Key` header. To make a create safe to repeat, send your own `external_deal_id`. ::: ### Request Body | Field | Type | Required | Description | | ----- | ---- | -------- | ----------- | | `properties` | object | no | The deal's fields. See the table below. | | `company_id` | string | no | The company to link. It must exist in your workspace. | | `company` | object | no | The company to link, by your own key instead of its Pathbound id: `{ "external_id": "…" }` (the id you gave the account on [Create a Company](/docs/api/companies#create-a-company)) or `{ "domain": "…" }`. Not with `company_id`. | | `contact_id` | string | no | The contact to link. It must exist in your workspace. When you send a contact and no company, the deal is also linked to that contact's company, if the contact has one. | | `contact` | object | no | The contact to link, by your own key: `{ "external_id": "…" }` (the id you gave the person on [Create a Contact](/docs/api/contacts#create-a-contact) or [identify](/docs/events/identify)) or `{ "email": "…" }`. Not with `contact_id`. | | `external_deal_id` | string | no | Your own id for the deal, up to 255 characters, unique in the workspace. It cannot be changed afterwards. A deal that already has it is updated. | | `tags` | string[] | no | Up to 50 [tags](/docs/api/tags) to put on the deal. | Fields of `properties`: | Field | Type | Description | | ----- | ---- | ----------- | | `name` | string | The deal's name. | | `stage` | string | The pipeline stage, in your own words (`discovery`, `negotiation`). Use the same spelling each time. | | `amount` | number | From 0 to 1e15. A numeric string is converted to a number. | | `currency` | string | Three letters, stored uppercase. Defaults to `USD`. It is not checked against a list of currencies. | | `status` | string | `open` (the default), `won` or `lost`. Stored lowercase. | | `close_date` | string | An ISO 8601 date. It may be in the future. | | any other key | | Stored as a custom property. A string value can be at most 1,000 characters. | Pathbound sets `createdate` and `lastmodifieddate` itself, whatever you send for them. A `company` or `contact` key only looks a record up: one that finds nothing is a `422` and no deal is created or changed, never a new company or contact. Write the account and the person first, then their deal. ```bash curl -X POST https://api.pathbound.ai/v1/deals \ -H "Authorization: Bearer $PATHBOUND_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "properties": { "name": "Northwind annual plan", "stage": "negotiation", "amount": 24000, "close_date": "2026-10-30" }, "company_id": "665f1c2ab3d4e5f601234567", "contact_id": "665f1c2ab3d4e5f6012345ab", "external_deal_id": "crm-48213", "tags": ["renewal", "q4"] }' ``` ### Response — `201 Created` A deal created. A deal updated through its `external_deal_id` is answered `200 OK` in the same shape, with `"created": false` and `"message": "Deal updated successfully"`. ```json { "status": "success", "message": "Deal created successfully", "created": true, "deal": { "deal_id": "6acd7a3e9b1c4d2e8f012345", "_id": "6acd7a3e9b1c4d2e8f012345", "properties": { "name": "Northwind annual plan", "stage": "negotiation", "amount": 24000, "close_date": "2026-10-30T00:00:00.000Z", "status": "open", "currency": "USD", "createdate": "2026-10-13T09:14:02.118Z", "lastmodifieddate": "2026-10-13T09:14:02.118Z" }, "company_id": "665f1c2ab3d4e5f601234567", "contact_id": "665f1c2ab3d4e5f6012345ab", "external_deal_id": "crm-48213", "notes": [], "tags": ["renewal", "q4"], "creation_source": "api", "first_seen_at": "2026-10-13T09:14:02.118Z", "created_at": "2026-10-13T09:14:02.118Z", "updated_at": "2026-10-13T09:14:02.118Z" }, "timestamp": "2026-10-13T09:14:02.201Z" } ``` ### Error Codes | Status | Meaning | | ------ | ------- | | `400` | A field has the wrong type or a property was refused. See [Errors](#errors). `code: "INVALID_LINK"` when `company` or `contact` is not an object with exactly one of its two keys, or is sent beside `company_id` or `contact_id`. | | `404` | `company_id` or `contact_id` names no record in your workspace. The `error` text is `Company not found` or `Contact not found`. | | `422` | `code: "CONTACT_NOT_FOUND"` or `"COMPANY_NOT_FOUND"`: the `contact` or `company` key finds no record. | | `429` | The workspace has reached its limit on stored deals (`code: "QUOTA_EXCEEDED"`). An update of an existing deal is never refused for it. | ## Create Deals in Bulk ``` POST /v1/deals/bulk ``` **Required scope:** `deals:write` Creates up to 1,000 deals in one request. Each item is handled on its own: a bad item is reported as that item's error and the others are still created. The same rules apply as for a single create. Sending the same request twice creates every deal twice, except the items that carry an `external_deal_id`: an item whose id a deal already has updates that deal, as [Create a Deal](#create-a-deal) does, and comes back as `updated`. ### Request Body | Field | Type | Required | Description | | ----- | ---- | -------- | ----------- | | `deals` | object[] | yes | 1 to 1,000 items. Each takes the fields of [Create a Deal](#create-a-deal): `properties`, `company_id`, `company`, `contact_id`, `contact`, `external_deal_id`, `tags`. | An item's `tags` are put on the deal it creates or updates. ### Response — `200 OK` ```json { "status": "success", "requested": 3, "created": 1, "updated": 1, "errors": 1, "results": [ { "index": 0, "external_deal_id": "crm-48213", "name": "Northwind annual plan", "status": "updated", "id": "6acd7a3e9b1c4d2e8f012345" }, { "index": 1, "external_deal_id": "crm-48214", "name": "Northwind add-on seats", "status": "created", "id": "6acd7b109b1c4d2e8f012377" }, { "index": 2, "name": "Contoso pilot", "status": "error", "error": "Invalid amount. Must be a number >= 0." } ], "timestamp": "2026-10-13T09:15:40.512Z" } ``` | Field | Description | | ----- | ----------- | | `requested` | The number of items you sent. `created`, `updated` and `errors` add up to it. | | `results[].index` | The item's position in `deals`, from 0. | | `results[].external_deal_id`, `results[].name` | Echoed from the item when it had them. | | `results[].status` | `created`, `updated` or `error`. | | `results[].id` | The deal's id, for `created` and `updated`. | | `results[].error` | Why the item was refused, for `error`. | | `results[].code` | For an item refused over its `company` or `contact`: `INVALID_LINK`, `CONTACT_NOT_FOUND` or `COMPANY_NOT_FOUND`, as on [Create a Deal](#create-a-deal). | The request is answered with `200` even when every item failed, so read `errors` and `results`. A `400` means `deals` itself is missing, empty or longer than 1,000. A `429` with `code: "QUOTA_EXCEEDED"` means the new deals in the batch would take the workspace past its limit on stored deals, and nothing was created; an item that updates a deal does not count toward it. ## List Deals ``` GET /v1/deals ``` **Required scope:** `deals:read` Returns deals, most recently changed first, one page at a time. All filters combine with AND. :::note Without `fields`, each row is the full deal, and reading it is [metered](#usage-and-metering). Ask for `fields=ids` to find deals for free and read the ones you need afterwards. The MCP tool `list_deals` asks for `ids` unless told otherwise; this endpoint does not. ::: ### Query Parameters | Parameter | Type | Default | Description | | --------- | ---- | ------- | ----------- | | `page` | number | 1 | Page number. See [Pagination](/docs/api/overview#pagination). | | `limit` | number | 25 | Deals per page (1 to 100). | | `fields` | string | | The shape of each row: `ids`, `summary`, or omit it for the full deal. The table under the response describes each. A comma-separated list of field names is not a projection here, as it is for contacts. | | `count_only` | boolean | `false` | If `true`, return only `{ "status": "success", "count": 62 }`. Not metered. | | `status` | string | | `open`, `won` or `lost`. | | `stage` | string | | The stage, whole value, case-insensitive. | | `company_id` | string | | Only deals linked to this company. | | `contact_id` | string | | Only deals linked to this contact. | | `external_deal_id` | string | | The deal with exactly this id of yours. | | `close_date_after` | string | | Only deals closing on or after this ISO 8601 date. | | `close_date_before` | string | | Only deals closing on or before this ISO 8601 date. | | `min_amount` | number | | Only deals worth at least this much. | | `max_amount` | number | | Only deals worth at most this much. | | `tags_any` | string | | Only deals with at least one of these tags, comma-separated. See [Filter Lists by Tag](/docs/api/tags#filter-lists-by-tag). | | `tags_all` | string | | Only deals with every one of these tags. When both are sent, `tags_any` is ignored. | | `search` | string | | Text to find in the name, the stage or `external_deal_id`, case-insensitive. At most 100 characters. | | `filters` | string | | Typed filters as a JSON array `[{"field","op","value"}]`, at most 10. `field` is `name`, `stage`, `status`, `currency`, `amount`, `close_date`, `createdate`, `lastmodifieddate` or the key of a custom property. `op` is one of `eq`, `neq`, `in`, `nin`, `exists`, `contains`, `gt`, `gte`, `lt`, `lte`. [`GET /v1/schema?entity=deals`](/docs/api/workspace#schema) lists the typed fields and the operators each one takes. Use the parameters above for the company, the contact and tags. | | `sort_by` | string | `lastmodifieddate` | `name`, `stage`, `status`, `amount`, `close_date`, `createdate`, `lastmodifieddate`, `created_at` or `updated_at`. | | `sort_dir` | string | `desc` | `asc` or `desc`. | | `include` | string | | `provenance` adds `properties_provenance` to each full row: for each property, where its value came from (`api` or `manual`) and when. | ```bash curl "https://api.pathbound.ai/v1/deals?status=open&close_date_before=2026-12-31&sort_by=amount&fields=summary" \ -H "Authorization: Bearer $PATHBOUND_API_KEY" ``` ### Response — `200 OK` ```json { "status": "success", "deals": [ { "deal_id": "6acd7a3e9b1c4d2e8f012345", "id": "6acd7a3e9b1c4d2e8f012345", "name": "Northwind annual plan", "stage": "negotiation", "status": "open", "amount": 24000, "currency": "USD", "close_date": "2026-10-30T00:00:00.000Z", "company_id": "665f1c2ab3d4e5f601234567", "company_name": "Northwind Traders", "contact_id": "665f1c2ab3d4e5f6012345ab", "contact_name": "Dana Lee", "tags": ["renewal", "q4"] } ], "pagination": { "page": 1, "limit": 25, "total_pages": 3, "total_items": 62 }, "timestamp": "2026-10-13T09:16:12.730Z" } ``` What a row holds depends on `fields`: | `fields` | Each row | Counts against your plan | | -------- | -------- | ------------------------ | | omitted, or `full` | [The deal object](#the-deal-object), plus `company_name` and `contact_name`. | yes | | `summary` | The row shown above. | yes | | `ids` | `deal_id`, `id`, `company_id` and `contact_id` only. | no | `company_name` is the linked company's name, or its domain when it has no name. `contact_name` is the linked contact's first and last name, or their email address when they have neither. Both are `null` when the deal has no such link. In the `summary` and `ids` shapes, `company_id` and `contact_id` are `null` for a deal that is not linked, and `id` repeats `deal_id`. ## Get a Deal ``` GET /v1/deals/:deal_id ``` **Required scope:** `deals:read` Returns one deal with a short description of the company and the contact it is linked to. This read is [metered](#usage-and-metering). ### Path Parameters | Parameter | Type | Description | | --------- | ---- | ----------- | | `deal_id` | string | The deal's id (24 hex characters). Any other value is a `400`. | ### Query Parameters | Parameter | Type | Default | Description | | --------- | ---- | ------- | ----------- | | `include` | string | | `provenance` adds `properties_provenance` to the deal. | ### Response — `200 OK` ```json { "status": "success", "deal": { "deal_id": "6acd7a3e9b1c4d2e8f012345", "_id": "6acd7a3e9b1c4d2e8f012345", "properties": { "name": "Northwind annual plan", "stage": "negotiation", "amount": 24000, "currency": "USD", "status": "open", "close_date": "2026-10-30T00:00:00.000Z", "createdate": "2026-10-13T09:14:02.118Z", "lastmodifieddate": "2026-10-13T09:14:02.118Z" }, "company_id": "665f1c2ab3d4e5f601234567", "contact_id": "665f1c2ab3d4e5f6012345ab", "external_deal_id": "crm-48213", "notes": [], "tags": ["renewal", "q4"], "creation_source": "api", "first_seen_at": "2026-10-13T09:14:02.118Z", "created_at": "2026-10-13T09:14:02.118Z", "updated_at": "2026-10-13T09:14:02.118Z" }, "company": { "company_id": "665f1c2ab3d4e5f601234567", "name": "Northwind Traders", "domain": "example.com" }, "contact": { "contact_id": "665f1c2ab3d4e5f6012345ab", "firstname": "Dana", "lastname": "Lee", "email": "dana@example.com", "jobtitle": "VP Operations" }, "timestamp": "2026-10-13T09:17:03.044Z" } ``` `company` and `contact` are `null` when the deal has no such link, or when the linked record no longer exists. A deal that is not in your workspace is a `404` with `code: "not_found"`. ## Update a Deal ``` PUT /v1/deals/:deal_id ``` **Required scope:** `deals:write` Changes a deal's properties, its links, or both. Only what you send changes, so an update that leaves out `status` does not reopen a won deal. ### Request Body | Field | Type | Required | Description | | ----- | ---- | -------- | ----------- | | `properties` | object | no | Properties to set, with the same fields as in [Create a Deal](#create-a-deal). They are merged into the stored ones. One rule differs from a create: only `lastmodifieddate` is set by Pathbound here, so a `createdate` you send is stored. Do not send it. | | `company_id` | string \| null | no | An id links the deal to that company. `null` removes the link. Leave it out to keep the link. | | `contact_id` | string \| null | no | The same, for the contact. | Things this endpoint does not do: - **It has no supported way to remove a property.** A property sent as `null` or `""` is skipped and the stored value stays. A string that holds only spaces is the exception, and an unintended one: it removes the property, `status` and `currency` included, so trim what you send. `close_date` is stricter: when it is present it must be a valid date. - **It cannot change `external_deal_id`.** A request that carries the field is refused with `400`. - **It does not read `tags`.** Use the [tag endpoints](#deal-tags). - **It does not fill in the company.** Linking a contact here leaves `company_id` as it was. Only [Create a Deal](#create-a-deal) takes the company from the contact. ```bash curl -X PUT https://api.pathbound.ai/v1/deals/6acd7a3e9b1c4d2e8f012345 \ -H "Authorization: Bearer $PATHBOUND_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "properties": { "stage": "closed", "status": "won", "amount": 26500 } }' ``` ### Response — `200 OK` ```json { "status": "success", "message": "Deal updated successfully", "deal": { "deal_id": "6acd7a3e9b1c4d2e8f012345", "_id": "6acd7a3e9b1c4d2e8f012345", "properties": { "name": "Northwind annual plan", "stage": "closed", "amount": 26500, "currency": "USD", "status": "won", "close_date": "2026-10-30T00:00:00.000Z", "createdate": "2026-10-13T09:14:02.118Z", "lastmodifieddate": "2026-10-28T15:02:44.390Z" }, "company_id": "665f1c2ab3d4e5f601234567", "contact_id": "665f1c2ab3d4e5f6012345ab", "external_deal_id": "crm-48213", "notes": [], "tags": ["renewal", "q4"], "creation_source": "api", "first_seen_at": "2026-10-13T09:14:02.118Z", "created_at": "2026-10-13T09:14:02.118Z", "updated_at": "2026-10-28T15:02:44.390Z" }, "timestamp": "2026-10-28T15:02:44.431Z" } ``` `lastmodifieddate` moves when the request carries `properties`. `updated_at` moves on every update. A `404` means the deal, or the company or contact you asked to link, is not in your workspace. The `error` text says which. ## Delete a Deal ``` DELETE /v1/deals/:deal_id ``` **Required scope:** `deals:write` :::caution This deletes the deal for good, with its notes and tags. It cannot be undone. The linked company and contact are not touched. ::: To record a deal that did not close, set its `status` to `lost` instead. Delete a deal that should never have existed, such as the copy a repeated import made: a copy marked `lost` still counts in every total of amounts. ### Response — `200 OK` ```json { "status": "success", "message": "Deal deleted successfully", "timestamp": "2026-10-28T15:10:09.118Z" } ``` ## List Deal Notes ``` GET /v1/deals/:deal_id/notes ``` **Required scope:** `deals:read` Returns every note on the deal, newest first. There is no pagination. This read is not metered. ### Response — `200 OK` ```json { "status": "success", "notes": [ { "_id": "6acd80f29b1c4d2e8f0123a1", "content": "Procurement wants the DPA signed before the order form.", "created_at": "2026-10-14T11:32:18.006Z", "created_by": "b3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "source": "api" } ], "timestamp": "2026-10-14T11:40:00.251Z" } ``` | Field | Description | | ----- | ----------- | | `_id` | The note's id. It is the `note_id` of the two endpoints below. | | `content` | The note's text. | | `created_at` | When the note was written. Changing the text does not move it. | | `created_by` | The id of the user who wrote it. For a note written through this API, that is the user the API key belongs to. | | `source` | `api` for a note written through this API or the MCP server, `manual` for one written in the dashboard. | ## Create a Deal Note ``` POST /v1/deals/:deal_id/notes ``` **Required scope:** `deals:write` ### Request Body | Field | Type | Required | Description | | ----- | ---- | -------- | ----------- | | `content` | string | yes | The note's text, 1 to 10,000 characters. Surrounding whitespace is removed. | ```bash curl -X POST https://api.pathbound.ai/v1/deals/6acd7a3e9b1c4d2e8f012345/notes \ -H "Authorization: Bearer $PATHBOUND_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Procurement wants the DPA signed before the order form." }' ``` ### Response — `201 Created` ```json { "status": "success", "note": { "_id": "6acd80f29b1c4d2e8f0123a1", "content": "Procurement wants the DPA signed before the order form.", "created_at": "2026-10-14T11:32:18.006Z", "created_by": "b3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "source": "api" }, "timestamp": "2026-10-14T11:32:18.019Z" } ``` A `429` with `code: "QUOTA_EXCEEDED"` and `resource: "notes_per_deal"` means the deal has reached its limit on notes. ## Update a Deal Note ``` PATCH /v1/deals/:deal_id/notes/:note_id ``` **Required scope:** `deals:write` Replaces a note's text. The body takes `content`, with the same rule as in [Create a Deal Note](#create-a-deal-note). ### Response — `200 OK` ```json { "status": "success", "message": "Note updated", "timestamp": "2026-10-14T11:45:27.660Z" } ``` The response does not return the note. A `404` means the deal or the note was not found. ## Delete a Deal Note ``` DELETE /v1/deals/:deal_id/notes/:note_id ``` **Required scope:** `deals:write` Deletes the note for good. ### Response — `200 OK` ```json { "status": "success", "message": "Note deleted", "timestamp": "2026-10-14T11:50:03.305Z" } ``` ## Deal Tags A deal carries tags like a contact or a company does, with the same rules and the same limit of 50. Three endpoints change them, all with `deals:write`: ``` POST /v1/deals/:deal_id/tags DELETE /v1/deals/:deal_id/tags POST /v1/deals/tags/bulk ``` They are documented with the other tag endpoints on the [Tags](/docs/api/tags) page. You can also set tags when you [create a deal](#create-a-deal), and filter [the list](#list-deals) with `tags_any` and `tags_all`. ## Errors The error format and the errors every endpoint shares (a missing or invalid key, rate limits) are described in the [API overview](/docs/api/overview#errors). These are the ones specific to deals. | Status | `code` | Meaning | | ------ | ------ | ------- | | `400` | | `Validation failed`: a parameter or a body field has the wrong type or format, for example a `deal_id` that is not an id, a `limit` over 100 or a `close_date` that is not ISO 8601. `details` lists each problem. | | `400` | | A property was refused. The `error` text says why: an unknown `status`, a `currency` that is not three letters, an `amount` that is not a number from 0 to 1e15, a string value over 1,000 characters, too many or too large properties, or `external_deal_id` sent on an update. | | `400` | | `Invalid search parameter`: `search` is longer than 100 characters or contains characters that are not allowed, such as `$`. | | `400` | `invalid_filter` | An entry of `filters` is malformed: not a JSON array, more than 10 entries, an unknown operator, or a value of the wrong type for the field. | | `402` | `BILLING_PLAN_LIMIT`, `BILLING_TRIAL_EXPIRED`, `BILLING_SUBSCRIPTION_PAUSED`, `BILLING_SUBSCRIPTION_INACTIVE` | A read would take the workspace over its plan's allowance, or the workspace has no active plan. No deal is returned. See [Usage and metering](/docs/api/overview#usage-and-metering). | | `403` | | The API key lacks `deals:read` or `deals:write`. | | `404` | `not_found` on [Get a Deal](#get-a-deal), none elsewhere | The deal or the note is not in your workspace, or the company or contact you asked to link is not. | | `400` | `INVALID_LINK` | [Create a Deal](#create-a-deal): `company` or `contact` is not an object with exactly one of its keys, or is sent beside `company_id` or `contact_id`. | | `422` | `CONTACT_NOT_FOUND`, `COMPANY_NOT_FOUND` | [Create a Deal](#create-a-deal): the `contact` or `company` key finds no record in your workspace. | | `429` | `QUOTA_EXCEEDED` | A storage limit was reached. `resource` says which: `deals` for the workspace's deals, `notes_per_deal` for one deal's notes. | --- # Events REST API > List, filter, and aggregate tracked events across contacts and companies in Pathbound via the REST API. Source: https://pathbound.ai/docs/api/events Events are tracked interactions across all connected sources — page views, form submissions, button clicks, video plays, email opens, custom events. The [events snippet](/docs/events/overview) is the primary source, but events can also come from email integrations and CRM syncs. All endpoints require `events:read`. ## List Events ``` GET /v1/events ``` ### Query Parameters | Parameter | Type | Default | Description | | ------------------ | ------- | ----------- | ------------------------------------------------------------ | | `page` | number | 1 | Page number. | | `limit` | number | 20 | Results per page (1–100). | | `event_type` | string | — | Filter by exact event name (e.g. `page_view`). | | `event_types` | string | — | Comma-separated event names — matches any of them (e.g. `button_click,link_click`). | | `contact_id` | string | — | Filter to a single contact's events. | | `visitor_id` | string | — | Filter to a single anonymous visitor (includes unidentified events). | | `session_id` | string | — | Filter to a single session. | | `domain` | string | — | Filter by domain (host where the event was recorded). | | `url_contains` | string | — | Filter where event URL contains this substring. | | `text` | string | — | Exact match on the interacted element's text/label (`data.text`), e.g. a button label like `Go!`. Safe for values containing `$`. | | `text_contains` | string | — | Substring match on the element text/label (`data.text`). | | `title` | string | — | Exact match on the page title (`data.title`). | | `title_contains` | string | — | Substring match on the page title (`data.title`). | | `element_type` | string | — | Filter by interacted element type (`data.elementType`, e.g. `button`, `a`). | | `element_id` | string | — | Exact match on the DOM element id (`data.id`). | | `class_contains` | string | — | Substring match on the element CSS class (`data.class`). The way to target elements that have no text or id (icon buttons, etc.) — use a **stable** class token like `MuiIconButton-root`, not a hashed one (`css-*`, `jss*`). | | `referring_domain` | string | — | Filter by referring domain. | | `device_type` | string | — | Filter by device type (e.g. `Desktop`, `Mobile`). | | `browser` | string | — | Filter by browser (e.g. `Chrome`). | | `os` | string | — | Filter by operating system (e.g. `Windows`). | | `platform` | string | — | `web`, `ios` or `android` — a browser, or inside a native app's webview. Any other value is a `400`. Events with no `platform` match none of the three. | | `is_bot` | boolean | — | `true` → only bot/crawler traffic; `false` → only human traffic (matches events with no `is_bot` flag). Omit for both. | | `date_after` | string | — | ISO 8601 — events at or after this timestamp. | | `date_before` | string | — | ISO 8601 — events at or before this timestamp. | | `identified_only` | boolean | `false` | Only include events linked to a known contact. | | `search` | string | — | Free-text across event name, URL, domain. | | `sort_by` | string | `timestamp` | One of `timestamp`, `event`, `createdAt`. | | `sort_dir` | string | `desc` | `asc` or `desc`. | | `count_only` | boolean | `false` | If `true`, return only `{ count }` — no records. | | `fields` | string | — | Set to `summary` for a compact projection (no enrichment); summary rows include `text`. | | `include` | string | — | Comma-separated list of optional fields to include. Currently supports `provenance` to add the per-field source/timestamp record (`properties_provenance`). | The `text`, `title`, `element_type`, `element_id`, and `class_contains` filters match fields inside each event's `data` payload — see [Event types](/docs/events/types) for which event types carry them (e.g. `button_click` and `link_click` carry `text`/`class`/`id`/`elementType`). Exact-match filters (`text`, `title`, `element_id`, …) are literal equality, so labels containing `$` or `.` work; `*_contains` filters are case-insensitive substring matches. To **count** events by a payload value (e.g. how many `Go!` button clicks) or to count **distinct** visitors/contacts, prefer [`POST /v1/aggregate`](/docs/api/batch#aggregate) — it returns `distinct_groups` in one call instead of paginating here. ### Response — `200 OK` By default, events are enriched with the associated contact and company: ```json { "status": "success", "events": [ { "event_id": "665f1c2ab3d4e5f6012345e1", "_id": "665f1c2ab3d4e5f6012345e1", "event": "page_view", "timestamp": "2026-04-29T11:23:45.000Z", "url": "https://example.com/pricing", "domain": "example.com", "data": { "title": "Pricing — Example", "path": "/pricing" }, "referring_domain": "google.com", "browser": "Chrome", "browser_version": "120", "os": "Mac OS X", "device_type": "Desktop", "platform": "web", "contact": { "contact_id": "665f1c2ab3d4e5f601234567", "_id": "665f1c2ab3d4e5f601234567", "email": "ada@example.com", "firstname": "Ada", "lastname": "Lovelace" }, "company": { "company_id": "665f1c2ab3d4e5f6012345aa", "_id": "665f1c2ab3d4e5f6012345aa", "name": "Analytical Engines", "domain": "example.com" } } ], "pagination": { "page": 1, "limit": 20, "total_pages": 12, "total_items": 234 } } ``` `_id` is a transitional alias for `event_id` (and the equivalent on the embedded contact / company) and will be removed in a future release — prefer the namespaced identifier in new integrations. Internal fields (`tenant_id`, `user_id`, `__v`, `internal_contact_id`, `properties_provenance`) are stripped by default. Each event also carries the server-derived fields `browser`, `browser_version`, `os`, `device_type`, `platform`, and `referring_domain` (computed from `userAgent` / `referrer` / `url`), plus `is_bot` — present and `true` only when the request matched a known bot/crawler (bot traffic is flagged, not dropped; absent on human traffic). See [Event types → Server-added fields](/docs/events/types#server-added-fields). In `fields=summary` mode, each event is reduced to `{ id, event, timestamp, url, contact_id }` — useful for high-volume listings. The summary projection intentionally uses `id` (not `event_id`) to stay compact; the enriched contact / company are omitted entirely. --- ## Get Event Types ``` GET /v1/events/types ``` Returns the distinct event names recorded for the tenant. Useful for populating a filter dropdown. ### Response — `200 OK` ```json { "status": "success", "types": ["button_click", "first_visit", "form_submit", "page_view", "video_play"], "total_items": 5 } ``` --- ## Get Event Stats ``` GET /v1/events/stats ``` Aggregated counts, optionally grouped by type, domain, or day. Use this for dashboards instead of paginating the list endpoint. ### Query Parameters | Parameter | Type | Default | Description | | ----------------- | ------- | ------- | ------------------------------------------------- | | `event_type` | string | — | Filter to a single event type. | | `domain` | string | — | Filter to a single domain. | | `date_after` | string | — | ISO 8601 lower bound. | | `date_before` | string | — | ISO 8601 upper bound. | | `identified_only` | boolean | `false` | Only count events linked to a known contact. | | `group_by` | string | — | One of `type`, `domain`, `day`. Omit for all three.| ### Response — `200 OK` ```json { "status": "success", "stats": { "total": 12_503, "by_type": [ { "type": "page_view", "count": 9_812 }, { "type": "button_click", "count": 1_204 } ], "by_domain": [ { "domain": "example.com", "count": 11_900 } ], "by_day": [ { "date": "2026-04-29", "count": 432 }, { "date": "2026-04-28", "count": 511 } ] } } ``` `by_day` is capped at the most recent 90 days. `by_type` and `by_domain` are capped at 50 entries each. --- ## Get a Single Event ``` GET /v1/events/:event_id ``` Returns the full event document, enriched with associated contact and company. ### Response — `200 OK` ```json { "status": "success", "event": { "event_id": "665f1c2ab3d4e5f6012345e1", "_id": "665f1c2ab3d4e5f6012345e1", "event": "page_view", "timestamp": "2026-04-29T11:23:45.000Z", "url": "https://example.com/pricing", "domain": "example.com", "data": { "title": "Pricing — Example", "path": "/pricing" }, "referring_domain": "google.com", "browser": "Chrome", "browser_version": "120", "os": "Mac OS X", "device_type": "Desktop", "platform": "web", "contact": { "contact_id": "665f1c2ab3d4e5f601234567", "_id": "665f1c2ab3d4e5f601234567", "email": "ada@example.com", "firstname": "Ada", "lastname": "Lovelace" }, "company": { "company_id": "665f1c2ab3d4e5f6012345aa", "_id": "665f1c2ab3d4e5f6012345aa", "name": "Analytical Engines", "domain": "example.com" } } } ``` For richer aggregations across contacts, companies, or events with custom dimensions and metrics, use the [Aggregate endpoint](/docs/api/batch#aggregate). --- # Tags > Label contacts, companies and deals with free-form tags, add or remove them on one record or on thousands, and filter lists by them. Source: https://pathbound.ai/docs/api/tags A tag is a short label on a [contact](/docs/api/contacts), a [company](/docs/api/companies) or a [deal](/docs/api/deals). Use tags for what your team or your AI agents decide about a record: the campaign it belongs to, where it came from, the tier you put it in. Tags need no setup. A tag exists from the moment a record carries it, and it leaves the workspace's tag list when no record carries it anymore. Every contact, company and deal the API returns has a `tags` array, empty when the record has none. The MCP tools `list_tags`, `add_contact_tags`, `remove_contact_tags`, `add_company_tags`, `remove_company_tags`, `add_deal_tags` and `remove_deal_tags` call the endpoints on this page. See [MCP tools](#mcp-tools) below and the [tools reference](/docs/mcp/tools). | Endpoint | Scope | What it does | | -------- | ----- | ------------ | | `GET /v1/tags` | one of `contacts:read`, `companies:read`, `deals:read` | Lists every tag in the workspace with how many records carry it. | | `POST /v1/contacts/:contact_id/tags` | `contacts:write` | Adds tags to one contact. | | `DELETE /v1/contacts/:contact_id/tags` | `contacts:write` | Removes tags from one contact. | | `POST /v1/contacts/tags/bulk` | `contacts:write` | Adds and removes tags on up to 1,000 contacts, or on every contact in a segment. | | `POST /v1/companies/:company_id/tags` | `companies:write` | Adds tags to one company. | | `DELETE /v1/companies/:company_id/tags` | `companies:write` | Removes tags from one company. | | `POST /v1/companies/tags/bulk` | `companies:write` | Adds and removes tags on up to 1,000 companies, or on every company in a segment. | | `POST /v1/deals/:deal_id/tags` | `deals:write` | Adds tags to one deal. | | `DELETE /v1/deals/:deal_id/tags` | `deals:write` | Removes tags from one deal. | | `POST /v1/deals/tags/bulk` | `deals:write` | Adds and removes tags on up to 1,000 deals. | None of these scopes is opt-in. See [Scopes](/docs/getting-started/authentication#scopes). No endpoint on this page counts against your plan. See [Usage and metering](/docs/api/overview#usage-and-metering). ## Tag Rules Every tag is rewritten the same way before it is stored, and before it is compared in a filter or a removal: 1. Whitespace at the start and the end is removed. 2. Letters are lowercased. 3. Each run of whitespace inside the tag becomes one space. 4. The result is cut to 64 characters. So `" VIP Customer "` is stored as `vip customer`, and asking for `VIP Customer` later finds it. - **A record holds at most 50 tags.** - **A tag longer than 64 characters is cut, not refused.** - **A blank tag is dropped,** and two tags that come out the same are one tag. - **Tags keep the order in which they were added.** - **Do not put a comma in a tag.** A comma is stored like any other character, but the [list filters](#filter-lists-by-tag) split their value on commas, so such a tag cannot be filtered on. Nothing else is enforced about a tag's form. A `key:value` pattern, such as `campaign:oct-2026` or `source:webinar`, keeps a workspace's tags readable. Before you introduce a new tag, [list the existing ones](#list-tags) and reuse a spelling that is already there. ## List Tags ``` GET /v1/tags ``` **Required scope:** any one of `contacts:read`, `companies:read`, `deals:read` Returns every tag that at least one contact, company or deal carries, with a count for each record type. The most used tags come first. The endpoint takes no parameters and returns the whole list in one response. ```bash curl https://api.pathbound.ai/v1/tags \ -H "Authorization: Bearer $PATHBOUND_API_KEY" ``` ### Response — `200 OK` ```json { "status": "success", "tags": [ { "tag": "customer", "contact_count": 412, "company_count": 96, "deal_count": 0 }, { "tag": "campaign:oct-2026", "contact_count": 180, "company_count": 0, "deal_count": 0 }, { "tag": "renewal", "contact_count": 0, "company_count": 12, "deal_count": 31 } ], "timestamp": "2026-10-13T09:20:11.482Z" } ``` All three counts are returned whichever of the three scopes the key holds. The counts can be up to 30 seconds old. ## Add Tags to One Record ``` POST /v1/contacts/:contact_id/tags POST /v1/companies/:company_id/tags POST /v1/deals/:deal_id/tags ``` **Required scope:** `contacts:write`, `companies:write` or `deals:write`, for the record type in the path Adds tags to one record and returns the record's tags as they are afterwards. ### Path Parameters | Parameter | Type | Description | | --------- | ---- | ----------- | | `contact_id`, `company_id` or `deal_id` | string | The record's id (24 hex characters). Any other value is a `400`. | ### Request Body | Field | Type | Required | Description | | ----- | ---- | -------- | ----------- | | `tags` | string[] | yes | 1 to 50 tags to add. | ```bash curl -X POST https://api.pathbound.ai/v1/contacts/665f1c2ab3d4e5f6012345ab/tags \ -H "Authorization: Bearer $PATHBOUND_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "tags": ["Campaign:Oct-2026", "VIP"] }' ``` ### Response — `200 OK` ```json { "status": "success", "tags": ["customer", "campaign:oct-2026", "vip"], "timestamp": "2026-10-13T09:21:40.117Z" } ``` The tags the record already had stay where they are, and the new ones follow in the order you sent them. A tag the record already has is not added again, so repeating the request changes nothing. :::note When an add would take a record past 50 tags, the record keeps what it has and takes new tags only until it holds 50. The rest are dropped without an error. Compare the returned `tags` with what you sent. ::: A request whose tags are all blank is refused with `400`. ## Remove Tags from One Record ``` DELETE /v1/contacts/:contact_id/tags DELETE /v1/companies/:company_id/tags DELETE /v1/deals/:deal_id/tags ``` **Required scope:** `contacts:write`, `companies:write` or `deals:write`, for the record type in the path Removes tags from one record and returns the tags that remain. The record itself is not touched. The tags to remove go in a JSON body, not in the query string. Some HTTP clients send no body with `DELETE` unless told to. ### Request Body | Field | Type | Required | Description | | ----- | ---- | -------- | ----------- | | `tags` | string[] | yes | 1 to 50 tags to remove. | ```bash curl -X DELETE https://api.pathbound.ai/v1/contacts/665f1c2ab3d4e5f6012345ab/tags \ -H "Authorization: Bearer $PATHBOUND_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "tags": ["vip"] }' ``` ### Response — `200 OK` ```json { "status": "success", "tags": ["customer", "campaign:oct-2026"], "timestamp": "2026-10-13T09:23:02.560Z" } ``` A tag the record does not carry is ignored, so repeating the request changes nothing. ## Add or Remove Tags in Bulk ``` POST /v1/contacts/tags/bulk POST /v1/companies/tags/bulk POST /v1/deals/tags/bulk ``` **Required scope:** `contacts:write`, `companies:write` or `deals:write`, for the record type in the path Adds tags, removes tags, or does both, on many records in one request. You name the records in one of two ways: by a list of ids, or, for contacts and companies, by a saved [segment](/docs/api/segments). ### Request Body | Field | Type | Required | Description | | ----- | ---- | -------- | ----------- | | `contact_ids`, `company_ids` or `deal_ids` | string[] | yes, unless `segment` is sent | 1 to 1,000 ids of the record type in the path. For more, send several requests. | | `segment` | string | no | The id or handle of a segment, static or dynamic, up to 128 characters. The change applies to every record that is in the segment at that moment. Contacts and companies only: `/v1/deals/tags/bulk` has no `segment` and always needs `deal_ids`. | | `add` | string[] | at least one of the two | Up to 50 tags to add. | | `remove` | string[] | at least one of the two | Up to 50 tags to remove. | - When you send both `segment` and a list of ids, the segment is used and the ids are ignored. - A tag that appears in both `add` and `remove` is added. - To rename a tag, send the new name in `add` and the old one in `remove`. By ids: ```bash curl -X POST https://api.pathbound.ai/v1/contacts/tags/bulk \ -H "Authorization: Bearer $PATHBOUND_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "contact_ids": [ "665f1c2ab3d4e5f6012345ab", "665f1c2ab3d4e5f6012345ac", "665f1c2ab3d4e5f6012345ad" ], "add": ["campaign:oct-2026"], "remove": ["campaign:sep-2026"] }' ``` By segment: ```bash curl -X POST https://api.pathbound.ai/v1/companies/tags/bulk \ -H "Authorization: Bearer $PATHBOUND_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "segment": "enterprise-accounts", "add": ["tier:enterprise"] }' ``` ### Response — `200 OK` ```json { "status": "success", "matched": 3, "added": ["campaign:oct-2026"], "removed": ["campaign:sep-2026"], "timestamp": "2026-10-13T09:25:48.903Z" } ``` | Field | Description | | ----- | ----------- | | `matched` | With ids: how many of them are records of that type in your workspace. An id that is not is skipped without an error. With a segment: how many records are in the segment. It is not the number of records that changed. | | `added` | The tags that were added, as stored. | | `removed` | The tags that were removed, as stored. | The response does not list records. A record that already holds 50 tags takes no new one, as in [Add Tags to One Record](#add-tags-to-one-record), and the response does not say which records those were. A segment must hold the record type in the path. A dynamic segment of the other type is refused with `400 segment_entity_mismatch`. See [Use a Segment on Other Endpoints](/docs/api/segments#use-a-segment-on-other-endpoints). ## Filter Lists by Tag `GET /v1/contacts`, `GET /v1/companies` and `GET /v1/deals` take two tag filters. | Parameter | Type | Description | | --------- | ---- | ----------- | | `tags_any` | string | Comma-separated tags. Returns records that carry at least one of them. | | `tags_all` | string | Comma-separated tags. Returns records that carry every one of them. | ```bash # How many contacts are tagged vip or customer curl "https://api.pathbound.ai/v1/contacts?tags_any=vip,customer&count_only=true" \ -H "Authorization: Bearer $PATHBOUND_API_KEY" # The ids of the companies tagged both tier:enterprise and renewal curl "https://api.pathbound.ai/v1/companies?tags_all=tier:enterprise,renewal&fields=ids" \ -H "Authorization: Bearer $PATHBOUND_API_KEY" ``` - Each tag in the value goes through the [tag rules](#tag-rules) first, so `VIP` matches `vip`. A tag matches as a whole, never as a part of another tag. - When both parameters are sent, `tags_all` is applied and `tags_any` is ignored. - A parameter uses its first 50 tags and ignores the rest. A value longer than 8,000 characters is a `400`. - The tag filters combine with every other filter of the list with AND. Filtering costs nothing. What the list then returns is metered as on any list call, so add `count_only=true` or `fields=ids` to count or select records for free. ## Where Else Tags Appear - **When records are written.** [`POST /v1/deals`](/docs/api/deals#create-a-deal) takes `tags`. So does each item of `POST /v1/contacts/bulk`, `POST /v1/companies/bulk` and `POST /v1/deals/bulk`. The tags go on the records the request creates, and are added to a record an item updates through its own id (`external_id`, `external_deal_id`). An item reported `existing` (a contact's email or a company's domain that was already there) gets none. `POST /v1/contacts` and `POST /v1/companies` take no tags: add them with a second request. - **In aggregations.** [`POST /v1/aggregate`](/docs/api/batch#aggregate) takes `tags_any` and `tags_all` as filters and can group contacts, companies or deals by `tag`. - **In streaming.** A tag automation adds or removes tags on every contact or company that matches conditions you set, and a stream's conditions can test a record's tags. See [Automations](/docs/api/webhooks#automations). - **In integration settings.** On the Apollo and Emailable integration pages in the dashboard you can name tags to exclude. The integration's sync then leaves a record that carries one of them out of paid Apollo enrichment, or out of Emailable verification. ## MCP Tools `list_tags` calls `GET /v1/tags`. The six `add_*_tags` and `remove_*_tags` tools call the bulk endpoints, for one record as for many, and send either `add` or `remove`, never both. Given more than 1,000 ids, a tool sends one request per 1,000 and reports the sum of `matched`. The contact and company tools take a `segment` in place of ids. The deal tools do not. ## Errors The error format and the errors every endpoint shares (a missing or invalid key, rate limits) are described in the [API overview](/docs/api/overview#errors). These are the ones specific to tags. | Status | `code` | Meaning | | ------ | ------ | ------- | | `400` | | `Validation failed`: an id that is not an id, more than 1,000 ids, more than 50 tags in one list, a tag that is not a string, or a missing `tags` or `deal_ids`. `details` lists each problem. | | `400` | | `Provide at least one tag in add or remove`: a bulk request with neither. | | `400` | | `Provide contact_ids or a segment` (or `company_ids`): a bulk request that names no records. | | `400` | | `No valid tags after normalization`: every tag to add, or every tag of a bulk request, was blank. | | `400` | `segment_entity_mismatch` | A dynamic segment holds the other record type. A static segment, which holds contacts, on `/v1/companies/tags/bulk` is answered `200` with `matched: 0`. | | `403` | | The API key lacks the scope for the record type in the path. For `GET /v1/tags`, it holds none of the three read scopes. | | `404` | | `Contact not found`, `Company not found` or `Deal not found`: the record in the path is not in your workspace. | | `404` | `segment_not_found` | No segment in your workspace has this id or handle. | | `422` | `segment_invalid` | The segment's definition holds no usable filter, so it selects nothing. | --- # Segments > Save a set of filters over contacts or companies as a segment, scope other endpoints to it, and run an enrichment, a Resend audience sync or an Apollo sequence enrollment over every member as a background job. Source: https://pathbound.ai/docs/api/segments A segment is a named group of contacts or companies in your workspace. You create one from a list of filters, and from then on you refer to it by its id or its handle wherever an endpoint takes a `segment` parameter: to list its members, count them, aggregate over them, tag them, or read their meetings. A segment can also be the target of an action that runs over every member as a background job: enrich them with Apollo, add them to a Resend segment, or enroll them in an Apollo sequence. You start the job, get a job id back, and poll it. The MCP tools `create_segment`, `list_segments`, `get_segment`, `enrich_segment`, `broadcast_to_segment`, `apollo_add_segment_to_sequence` and `get_job` call the same endpoints. See the [MCP tools reference](/docs/mcp/tools#segments). | Endpoint | Scope | What it does | | -------- | ----- | ------------ | | `POST /v1/segments` | `contacts:write` or `companies:write`, by what the segment holds | Create a dynamic segment from filters. | | `GET /v1/segments` | `contacts:read` or `companies:read`, either one | List the workspace's segments. | | `GET /v1/segments/:ref` | `contacts:read` or `companies:read`, by what the segment holds | One segment with its current member count. | | `POST /v1/segments/:ref/actions` | `enrichment:write` or `actions:write`, by action | Start a background job over every member. | | `GET /v1/jobs/:id` | any valid key | Status, progress and result of a job. | Creating, listing and counting a segment never counts against your plan. Reading its members counts like any other read of contacts or companies. See [Usage and metering](/docs/api/overview#usage-and-metering). ## Static and Dynamic Segments Every segment has a `type`. - **`dynamic`**: a saved list of filters over contacts or over companies. It stores no member list. Its members are whichever records match the filters at the moment you use it, so a contact that starts to match is in the segment on the next request. - **`static`**: a fixed list of contacts that someone picked by hand on the **Segments** page of the dashboard. It holds contacts only. `POST /v1/segments` creates dynamic segments. Static segments are created in the dashboard. Both kinds are listed, read and used through the API in the same way. A segment is addressed by a `ref`: its `id`, or its `handle` if you gave it one. A handle is a short name you choose (`us-customers`), which is easier to keep in a script than an id. The API has no endpoint that changes or deletes a segment. Its name, description and color are edited, and the segment is deleted, on the **Segments** page of the dashboard. The filters of a dynamic segment cannot be changed: create a new segment instead. ## Create a Segment ``` POST /v1/segments ``` **Required scope:** `contacts:write` for a contacts segment, `companies:write` for a companies segment. Creates a dynamic segment. The filters are checked before anything is stored: a filter that is not valid is refused with `400 invalid_filter`, and no segment is created. ### Request Body | Field | Type | Required | Description | | ------------- | ------ | -------- | ----------- | | `name` | string | yes | Up to 100 characters. Unique in the workspace. | | `entity` | string | yes | `contacts` or `companies`. | | `filters` | array | yes | 1 to 10 filters, each `{ "field", "op", "value" }`. A record is a member when it matches all of them. See [Filters](#filters). | | `handle` | string | no | A short name to address the segment by: 1 to 64 lowercase letters, digits or hyphens, starting with a letter or a digit. Unique in the workspace. | | `description` | string | no | Up to 500 characters. | | `color` | string | no | A color for the segment in the dashboard. Up to 32 characters. | ### Example ```bash curl -X POST https://api.pathbound.ai/v1/segments \ -H "Authorization: Bearer $PATHBOUND_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "US customers", "handle": "us-customers", "entity": "contacts", "filters": [ { "field": "lifecyclestage", "op": "eq", "value": "customer" }, { "field": "country", "op": "eq", "value": "United States" } ] }' ``` ### Response — `201 Created` ```json { "status": "success", "segment": { "id": "670b8f2e4c1a9d3e5f607182", "name": "US customers", "handle": "us-customers", "type": "dynamic", "entity": "contact", "filters": [ { "field": "lifecyclestage", "op": "eq", "value": "customer" }, { "field": "country", "op": "eq", "value": "United States" } ], "description": null, "color": null, "contact_count": 0, "created_at": "2026-10-13T09:02:11.000Z" } } ``` | Field | Description | | ----- | ----------- | | `id` | The segment's id. | | `handle` | The handle, or `null`. | | `type` | `dynamic` or `static`. | | `entity` | `contact` or `company`. The request takes the plural (`contacts`), the response answers in the singular. A static segment is always `contact`. | | `filters` | The stored filters. Only on a dynamic segment. | | `contact_count` | For a static segment, the number of contacts on its list. A dynamic segment keeps no count here: read `count` from [Get a Segment](#get-a-segment). | A name or a handle that is already taken is answered with `409` and code `conflict`. ## Filters A segment's filters are the API's typed filters: each is `{ "field": "...", "op": "...", "value": ... }`, with the fields, operators and values described under [Filtering](/docs/api/overview#filtering). They are the same as the `filters` parameter of [`GET /v1/contacts`](/docs/api/contacts#list-contacts) and [`GET /v1/companies`](/docs/api/companies#list-companies), so you can try a set of filters on a list first and save it as a segment once it returns what you want. [`GET /v1/schema`](/docs/api/workspace#schema) lists the fields your workspace can filter on, with the type of each. Take field names from there: a `source.field` that is not on that list is either refused with `400 invalid_filter` or matches nothing. Which operators a field takes depends on its type. A property with no known type takes all of them. | Type | Operators | | ---- | --------- | | Text | `eq`, `neq`, `in`, `nin`, `contains`, `exists` | | Number, date | `eq`, `neq`, `in`, `nin`, `gt`, `gte`, `lt`, `lte`, `exists` | | Boolean | `eq`, `neq`, `exists` | | List of text | `contains`, `in`, `exists` | A segment's filters are stored as you send them and applied on every later read, so three details are worth getting right: - **Case.** `eq`, `neq`, `in` and `nin` compare the whole stored value, including case: `"United States"` does not match `united states`. `contains` ignores case. - **Values.** A value is converted to the field's type, so `"50"` is accepted for a number and `"2026-10-01"` for a date. A value that cannot be converted is refused with `400 invalid_filter`, and `details` names the field. - **`exists`.** With `false` it matches the records where the field is missing or null. ## List Segments ``` GET /v1/segments ``` **Required scope:** `contacts:read` or `companies:read`. Either one is enough. Returns the workspace's segments, static and dynamic, sorted by name. The list holds at most 200 segments and is not paged. ### Response — `200 OK` ```json { "status": "success", "segments": [ { "id": "670b8f2e4c1a9d3e5f607182", "name": "US customers", "handle": "us-customers", "type": "dynamic", "entity": "contact", "filters": [ { "field": "lifecyclestage", "op": "eq", "value": "customer" }, { "field": "country", "op": "eq", "value": "United States" } ], "description": null, "color": null, "contact_count": 0, "created_at": "2026-10-13T09:02:11.000Z" }, { "id": "66f41d7a2b9c8e0f1a2b3c4d", "name": "Webinar attendees", "handle": null, "type": "static", "entity": "contact", "description": "Picked after the October webinar", "color": "#4f46e5", "contact_count": 38, "created_at": "2026-10-02T14:20:45.000Z" } ] } ``` The fields are those of [Create a Segment](#create-a-segment). ## Get a Segment ``` GET /v1/segments/:ref ``` **Required scope:** `companies:read` for a companies segment, `contacts:read` for any other. Returns one segment and how many records are in it right now. The members themselves are not in the response: list them with [`GET /v1/contacts?segment=`](#use-a-segment-on-other-endpoints). ### Path Parameters | Parameter | Type | Description | | --------- | ------ | ----------- | | `ref` | string | The segment's `id` or its `handle`. | ### Example ```bash curl https://api.pathbound.ai/v1/segments/us-customers \ -H "Authorization: Bearer $PATHBOUND_API_KEY" ``` ### Response — `200 OK` ```json { "status": "success", "segment": { "id": "670b8f2e4c1a9d3e5f607182", "name": "US customers", "handle": "us-customers", "type": "dynamic", "entity": "contact", "filters": [ { "field": "lifecyclestage", "op": "eq", "value": "customer" }, { "field": "country", "op": "eq", "value": "United States" } ], "description": null, "color": null, "contact_count": 0, "created_at": "2026-10-13T09:02:11.000Z" }, "count": 412 } ``` `count` is counted when you ask. For a dynamic segment it is the number of records that match the filters now. The filters of a dynamic segment are checked again every time the segment is used, here and on every endpoint that takes a `segment`. If one of them names a source field that no longer exists, the request is answered with `400 invalid_filter`. ## Use a Segment on Other Endpoints | Endpoint | Parameter | What it does | | -------- | --------- | ------------ | | [`GET /v1/contacts`](/docs/api/contacts#list-contacts), [`GET /v1/companies`](/docs/api/companies#list-companies) | `segment` (query) | Only the members of one segment. Combines with every other filter, with `count_only` and with paging. | | The same two | `segments_any` (query) | Records that are in at least one of several segments: a comma-separated list of ids or handles. The first 50 are read. | | [`POST /v1/aggregate`](/docs/api/batch#aggregate) | `segment` (body), `filters.segments_any` | Group and count inside a segment. For `entity` `contacts` or `companies` only. | | [`POST /v1/contacts/tags/bulk`, `POST /v1/companies/tags/bulk`](/docs/api/tags) | `segment` (body) | Add or remove tags on every member, in place of a list of ids. When both are sent, `segment` is used. | | [`POST /v1/meetings/query`](/docs/api/batch#query-meetings) | `segment` (body) | Meetings with the segment's contacts. A companies segment matches anyone at its companies, and needs `companies:read` as well. | ```bash curl "https://api.pathbound.ai/v1/contacts?segment=us-customers&limit=50" \ -H "Authorization: Bearer $PATHBOUND_API_KEY" ``` Three rules hold on all of them: - **The segment has to be for the records you ask for.** A dynamic contacts segment on `GET /v1/companies`, or a companies segment on `GET /v1/contacts`, is refused with `400 segment_entity_mismatch`. A static segment is not checked this way: it holds contacts, and on an endpoint that reads companies it is answered `200` and matches nothing. Use it where contacts are read. - **An unknown `segment` is an error.** It is answered with `404 segment_not_found`, never with every record in the workspace. - **An unknown entry in `segments_any` adds no members.** If none of the entries names a segment, nothing matches. ## Run an Action on a Segment ``` POST /v1/segments/:ref/actions ``` **Required scope:** `enrichment:write` for `apollo_enrich`, `actions:write` for `resend_add_to_segment` and `apollo_add_to_sequence`. Both scopes are opt-in: a key has them only if they were selected when it was created. See [Scopes](/docs/getting-started/authentication#scopes). Starts a background job that works through every member of the segment. The request returns at once with a job. Poll it with [Get a Job](#get-a-job). :::caution These actions leave Pathbound. `apollo_enrich` spends Apollo credits. `apollo_add_to_sequence` puts every member into a sequence that sends real email. `resend_add_to_segment` writes contacts into your Resend account. The API has no way to cancel a job, so check the member count first with [Get a Segment](#get-a-segment). ::: ### Path Parameters | Parameter | Type | Description | | --------- | ------ | ----------- | | `ref` | string | The segment's `id` or its `handle`. | ### Headers | Header | Required | Description | | ----------------- | -------- | ----------- | | `Idempotency-Key` | yes | Any non-empty string that is unique to this job. See [Idempotency](#idempotency). | ### Request Body `action` is required and selects one of three jobs. The other fields belong to one action each. Each action also needs its integration connected and one switch turned on by a workspace admin under **MCP Tools** in the dashboard. For these jobs the switches apply to API keys as well as to AI agents. | Field | Type | Required | Description | | -------- | ------ | -------- | ----------- | | `action` | string | yes | `apollo_enrich`, `resend_add_to_segment` or `apollo_add_to_sequence`. | #### `apollo_enrich` Enriches every member with Apollo: contacts for a contacts segment, companies for a companies segment. Needs `enrichment:write`. | Field | Type | Required | Description | | ------- | ------- | -------- | ----------- | | `force` | boolean | no | `true` enriches members that were enriched before, and pays for them again. Default `false`: they are skipped. | A member that Apollo finds costs about one Apollo credit. A member that was enriched before, or a company without a domain, is not sent to Apollo and costs nothing. The other skips can cost: a member that Apollo found and that could not be saved is counted in `skipped`, and its credit is in `credits_consumed`. The job draws on the workspace's [daily credit budget](/docs/api/enrichment#limits-and-costs) for enrichment, and stops when the budget is reached. The switch is **Enrich contacts & companies**, for Apollo. This action always runs on Apollo, whichever data provider serves [enrichment of single records](/docs/api/enrichment). #### `resend_add_to_segment` Adds every contact of the segment that has an email address to a segment in your [Resend](/docs/api/resend) account. A contact that is not in Resend yet is created there. This sends no email: it fills the audience you then send a broadcast to. Needs `actions:write` and a contacts segment. | Field | Type | Required | Description | | ------------------- | ------ | -------- | ----------- | | `resend_segment_id` | string | yes | The id of the Resend segment to add to. `GET /v1/integrations/resend/segments` lists them. | The switch is **Manage segments**, for Resend. #### `apollo_add_to_sequence` Enrolls every contact of the segment in an existing [Apollo sequence](/docs/api/apollo). Apollo then emails them on the sequence's schedule. Needs `actions:write` and a contacts segment. | Field | Type | Required | Description | | ------------------- | -------- | -------- | ----------- | | `sequence_id` | string | yes | The Apollo sequence id: 24 hexadecimal characters. | | `sender_account_id` | string | yes | The id of the Apollo mailbox the sequence sends from: 24 hexadecimal characters. There is no default. `GET /v1/integrations/apollo/senders` lists the mailboxes you may use. | | `start_at` | string | no | An ISO 8601 time in the future. Contacts are enrolled paused and start then. | | `tags` | string[] | no | Up to 20 Pathbound tags, each up to 64 characters, added to every contact that was enrolled. | Contacts without an email address, contacts already in the sequence, and contacts that Apollo's own safeguards refuse are skipped and counted by reason. Enrollments are capped per workspace per day, and a job that reaches the cap stops there. The switch is **Create and run sequences**, for Apollo. This action is checked before a job is created: if Apollo is not connected, the switch is off, or the mailbox may not be used, the request is refused with the error codes described on the [Apollo page](/docs/api/apollo), and nothing starts. ### Example ```bash curl -X POST https://api.pathbound.ai/v1/segments/us-customers/actions \ -H "Authorization: Bearer $PATHBOUND_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: enrich-us-customers-2026-10-13" \ -d '{ "action": "apollo_enrich" }' ``` ### Response — `202 Accepted` ```json { "status": "success", "job_id": "670b91a74c1a9d3e5f6071a3", "action": "apollo_enrich", "job_status": "pending", "entity": "contact", "segment_ref": "us-customers", "progress": { "processed": 0, "total": 412 }, "result": null, "error": null, "created_at": "2026-10-13T09:14:02.000Z", "updated_at": "2026-10-13T09:14:02.000Z", "match_count": 412, "estimated_credits": 412 } ``` | Field | Description | | ----- | ----------- | | `job_id` | The id to poll. | | `match_count` | How many members the segment has now. | | `estimated_credits` | Only for `apollo_enrich`. An estimate of one credit per member, so it equals `match_count`. The job reports what was really spent in `result.credits_consumed`. | The other fields are those of [Get a Job](#get-a-job). For `apollo_enrich` and `resend_add_to_segment`, whether the integration is connected and its switch is on is checked when the job runs, not when you start it. A job that cannot run ends with `job_status: "failed"` and the reason in `error`. The job reads the segment's members when it runs. For a dynamic segment that can be a slightly different set from the one `match_count` counted. ### Idempotency The `Idempotency-Key` identifies the job. A second request with the same key starts nothing: it is answered with `200 OK`, `"replay": true` and the job as it stands now, without `match_count` or `estimated_credits`. This endpoint differs from the [convention across the API](/docs/api/overview#idempotency) in three ways: - **Only the key is compared.** A request that reuses a key with a different segment or a different body still returns the first job. Use a new key for every job you mean to run. - **A key is remembered for 30 days**, as long as the job itself is kept, and not for 24 hours. To run the same action on the same segment again within that time, send a new key. - **A replay returns the job in whatever state it is in**, one that is still running or one that failed included. There is no `409` for a job in progress. To try a failed job again, send a new key. A new key always starts a new job, and the job spends again. ## Get a Job ``` GET /v1/jobs/:id ``` **Required scope:** none. Any valid API key of the workspace can read the workspace's jobs. ### Path Parameters | Parameter | Type | Description | | --------- | ------ | ----------- | | `id` | string | The `job_id` returned by [Run an Action on a Segment](#run-an-action-on-a-segment). | ### Example ```bash curl https://api.pathbound.ai/v1/jobs/670b91a74c1a9d3e5f6071a3 \ -H "Authorization: Bearer $PATHBOUND_API_KEY" ``` ### Response — `200 OK` ```json { "status": "success", "job_id": "670b91a74c1a9d3e5f6071a3", "action": "apollo_enrich", "job_status": "running", "entity": "contact", "segment_ref": "us-customers", "progress": { "processed": 150, "total": 412 }, "result": { "requested": 150, "enriched": 96, "not_found": 21, "skipped": 33, "failed": 0, "credits_consumed": 96, "failures": [] }, "error": null, "created_at": "2026-10-13T09:14:02.000Z", "updated_at": "2026-10-13T09:15:40.000Z" } ``` `status` says that this request succeeded. The state of the job is `job_status`. | Field | Description | | ----- | ----------- | | `action` | The action the job runs. | | `job_status` | See [Job statuses](#job-statuses). | | `entity` | `contact` or `company`: what the segment holds. | | `segment_ref` | The id or handle the job was started with. | | `progress` | `processed` members out of `total`. | | `result` | Counters for the action, or `null` before the first batch. It is updated while the job runs. | | `error` | Why the job failed, or `null`. | ### Job statuses | `job_status` | Meaning | | ------------ | ------- | | `pending` | Accepted and waiting to start. | | `running` | Working through the segment. | | `succeeded` | Finished, and no member failed. | | `partial` | Finished or stopped early with some members not done: a batch failed, a daily cap was reached, or Apollo refused the rest. `result` says how many and why. | | `failed` | Could not run, or stopped on an error. `error` says why. `result` keeps what was done before that. | Poll until `job_status` is `succeeded`, `partial` or `failed`. A job has no fixed duration: it works in batches (25 members at a time for the two Apollo actions, 100 for Resend), paced to the provider's rate limits, and `progress` moves once per batch. A job that fails is not retried. A job can be read for 30 days after it was created. After that `GET /v1/jobs/:id` answers `404`. ### Result fields **`apollo_enrich`** | Field | Description | | ----- | ----------- | | `requested` | Members in the batches that have completed so far. | | `enriched` | Members Apollo found and enriched. | | `not_found` | Members Apollo has no match for. | | `skipped` | Members not enriched for another reason: enriched before (unless `force` is `true`), a company without a domain, or a request to Apollo that failed. | | `failed` | Members in a batch that failed as a whole. | | `credits_consumed` | Apollo credits spent. | | `failures` | Up to 50 failed batches, each `{ "ids": [...], "error": "..." }`. | | `stopped_reason` | `apollo_daily_credit_cap_reached` when the job stopped at the daily cap. The job is then `partial`. | **`resend_add_to_segment`** | Field | Description | | ----- | ----------- | | `added` | Contacts added to the Resend segment. | | `failed` | Contacts Resend refused. | | `no_email` | Contacts without an email address, which were left out. | | `skipped` | Always `0` for this action. | | `failures` | Always empty for this action. | **`apollo_add_to_sequence`** | Field | Description | | ----- | ----------- | | `requested` | Contacts in the batches that have completed so far. | | `enrolled` | Contacts now in the sequence. | | `skipped` | Contacts not enrolled, with the count per reason in `skip_reasons` (for example `no_email`, `already_enrolled`, or the reason Apollo gave). | | `failed` | Contacts in a batch that failed. | | `sequence_id`, `sequence_name` | The sequence. | | `sender_email` | The mailbox the sequence sends from. | | `failures` | Up to 50 failed batches, each `{ "ids": [...], "error": "...", "code": "..." }`. | | `stopped_reason`, `error` | Set when the job stopped early: `apollo_daily_enroll_cap_reached`, or the code of the Apollo error that would have repeated for every batch. The job is then `partial`. | ## Errors Errors use the [common error format](/docs/api/overview#errors). | Status | `code` | Meaning | | ------ | ------ | ------- | | `400` | none | A field failed validation. `details` lists each one. | | `400` | `invalid_filter` | A filter names a source field that does not exist, uses an operator the field's type does not take, or carries a value of the wrong type. `details` names the field. | | `400` | `IDEMPOTENCY_KEY_REQUIRED` | An action was sent without the `Idempotency-Key` header. | | `400` | `validation_error` | The action does not fit: `resend_add_to_segment` or `apollo_add_to_sequence` on a companies segment, a missing `resend_segment_id`, `sequence_id` or `sender_account_id`, or a `start_at` that is not in the future. | | `400` | `segment_entity_mismatch` | A dynamic contacts segment was used where companies are read, or the reverse. A static segment used where companies are read is not an error: it matches nothing. | | `403` | `auth_error` | The key lacks the scope the request needs. The message names it. | | `404` | `not_found` | No segment has that id or handle, or no job has that id. | | `404` | `segment_not_found` | The `segment` parameter of another endpoint names no segment. | | `409` | `conflict` | A segment with that name or that handle already exists. | | `412` | `field_redacted` | Only for calls made through the MCP server in a workspace that masks personal data for AI agents: a filter names a masked contact field. A direct call with an API key never gets it. | | `412` | `integration_missing`, `invalid_credentials`, `needs_reconsent`, `tool_disabled` | `apollo_add_to_sequence` only: Apollo is not connected, its connection has to be renewed, or **Create and run sequences** is off. No job was created. | | `400`, `403` | `sender_inactive`, `sender_not_allowed` | `apollo_add_to_sequence` only: the mailbox is inactive in Apollo, or is not one this workspace may send from. No job was created. The [Apollo page](/docs/api/apollo) lists the other errors Apollo can answer with. | | `422` | `segment_invalid` | The segment's stored filters no longer resolve to any condition, so it is refused instead of matching every record. | | `429` | `rate_limited` | The workspace has too many jobs waiting. Nothing was started: retry with the same `Idempotency-Key` after `retry_after` seconds. | | `500` | none | The request could not be completed. | --- # Batch & Aggregate > Multi-entity lookups, bulk meeting notes, and server-side aggregations in one request. Fetch up to 25 profiles or deals, read notes from many meetings, or aggregate across contacts, companies, deals, and events. Source: https://pathbound.ai/docs/api/batch When you need to look up many contacts at once, or roll events up by some dimension, paginating the list endpoints is wasteful. Use the batch and aggregate endpoints instead — both perform the work server-side and return a single response. ## Batch Get Details ``` POST /v1/batch/details ``` **Required scope:** `contacts:read` (works for `contacts` and `companies`); `entity_type: "deals"` also needs `deals:read`. Fetch up to 25 contacts, companies or deals by ID in one request. Reading a record activates its customer for the billing cycle, as the single-record endpoints do; a deal activates the company (or, failing that, the contact) it belongs to. Pass `dry_run: true` to learn how many new activations a read would cost without reading or metering anything. ### Request Body | Field | Type | Required | Description | | ------------- | -------- | -------- | ------------------------------------------------ | | `entity_type` | string | yes | `contacts`, `companies` or `deals`. | | `ids` | string[] | yes | Array of 1–25 IDs. | | `dry_run` | boolean | no | Return `{ dry_run, match_count, estimated_activations }` instead of the records. Never meters. | ### Example ```bash curl -X POST https://api.pathbound.ai/v1/batch/details \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "entity_type": "contacts", "ids": ["665f1c2ab3d4e5f601234567", "665f1c2ab3d4e5f601234568", "665f1c2ab3d4e5f601234569"] }' ``` ### Response — `200 OK` ```json { "status": "success", "contacts": [ /* up to 25 contact objects */ ] } ``` IDs that don't exist (or belong to a different tenant) are silently dropped from the response. --- ## Batch Get Activity ``` POST /v1/batch/activity ``` **Required scope:** `contacts:read`. Fetch recent events for up to 25 contacts in one request — without one busy contact starving the others. Each contact gets up to `events_limit` events independently. ### Request Body | Field | Type | Required | Description | | -------------- | -------- | -------- | ------------------------------------------------- | | `contact_ids` | string[] | yes | Array of 1–25 contact IDs. | | `events_limit` | number | no | Max events per contact (1–20, default 5). | | `date_after` | string | no | ISO 8601 lower bound on `timestamp`. | | `date_before` | string | no | ISO 8601 upper bound on `timestamp`. | ### Response — `200 OK` ```json { "status": "success", "contact_count": 3, "activity": { "665f1c2ab3d4e5f601234567": { "events": [ { "id": "665f1c2ab3d4e5f6012345e1", "event": "page_view", "url": "https://example.com/pricing", "timestamp": "...", "title": "Pricing" } ], "total_events": 18 }, "665f1c2ab3d4e5f601234568": { "events": [], "total_events": 0 }, "665f1c2ab3d4e5f601234569": { "events": [ /* … */ ], "total_events": 4 } } } ``` Empty arrays are returned for contacts with no events in the window. --- ## Query Meetings ``` POST /v1/meetings/query ``` **Required scope:** `contacts:read`. A company filter or a companies segment also needs `companies:read`. Read many tl;dv recordings in one request, across customers, with each meeting's AI notes cut down to the sections you ask for. Calendly bookings aren't included; list those per customer with [Get Contact Conversations](/docs/api/contacts#get-contact-conversations). Filters combine with AND. With no company, contact, or segment filter, every customer call in the workspace matches. Calls with only your own team on them are left out, and your team never appears in a meeting's `contact_ids`. Your team means workspace members' addresses, their company domains, your verified domains, and the internal domains and email patterns in your workspace settings. ### Request Body | Field | Type | Default | Description | | ---------------- | -------- | ------- | ----------- | | `company_ids` | string[] | — | Only meetings attended by someone at these companies (1–25). | | `contact_ids` | string[] | — | Only meetings these contacts attended (1–25). | | `segment` | string | — | Saved segment id or handle. A companies segment matches anyone at its companies. | | `meeting_ids` | string[] | — | Only these meetings (1–25). | | `date_after` | string | — | ISO 8601. Only meetings on or after this date. | | `date_before` | string | — | ISO 8601. Only meetings on or before this date. | | `has_notes` | boolean | — | `true` for meetings with AI notes, `false` for meetings without. | | `has_transcript` | boolean | — | `true` for meetings with a transcript, `false` for meetings without. | | `sections` | string[] | — | Note sections to return (1–10). See [Sections](#sections). Omit to get `section_catalog` instead of notes. | | `sort_dir` | string | `desc` | By meeting date, `asc` or `desc`. | | `offset` | number | 0 | Meetings to skip. Pass the previous page's `next_offset`. | | `limit` | number | 25 | Most meetings per page (1–50). | | `max_chars` | number | 9000 | Page size in characters (2,000–50,000). | | `count_only` | boolean | `false` | Return only `{ "count": n }`. | | `dry_run` | boolean | `false` | Return `match_count` and `estimated_activations` for the page without reading it. | ### Sections tl;dv writes notes from your workspace's notes template: markdown headings such as `4) Commercials`, and field labels such as `Top pains (ranked):` inside them. Many meetings carry a topic list with bullets instead. `sections` accepts: - **A heading or field name.** Matching ignores case, numbering, and punctuation, and a name can be part of a longer one: `pains` finds `Top pains (ranked)`. A heading returns everything under it. On a meeting that only has topics, names match topic titles. - **`topics`** returns the topic list with its bullets. - **`all`** returns the whole notes. A requested name that a meeting doesn't have is listed in that meeting's `missing_sections`. Without `sections`, the response carries `section_catalog`: the section names on the page and how many meetings have each, most common first. ### Paging Pages are cut by size, so a page can hold fewer than `limit` meetings. Pass each response's `next_offset` back as `offset` until `next_offset` is `null`. A meeting that doesn't fit a page on its own comes back alone, with its longest sections cut and marked `"truncated": true`. Read it again with `meeting_ids` and fewer sections, or with a larger `max_chars`. ### Response — `200 OK` ```json { "status": "success", "meetings": [ { "_id": "6650f1c2e4b0a1b2c3d4e5f6", "name": "Acme discovery", "happened_at": "2026-09-22T15:00:00.000Z", "duration_seconds": 2280, "organizer": { "email": "dana@yourco.com", "name": "Dana Lee" }, "invitees": [{ "email": "sam@acme.com", "name": "Sam Ortiz" }], "contact_ids": ["665f1c2ab3d4e5f601234567"], "has_transcript": true, "has_notes": true, "notes_format": "markdown", "sections": [ { "name": "Top pains (ranked)", "text": "1. Dispatch is manual\n2. No route history" } ], "missing_sections": ["Decision process"] } ], "offset": 0, "returned": 1, "next_offset": 1 } ``` `contact_ids` are the customers on the call, within your filter. Pass one with the meeting `_id` to [Get Meeting Transcript](/docs/api/contacts#get-meeting-transcript). `notes_format` is `markdown`, `topics`, or `none`. Each customer on a returned meeting counts as a profile activation for the billing cycle, once per company, as with the other endpoints that return customer records. Your team never counts. `count_only` and `dry_run` never activate anything. --- ## Aggregate ``` POST /v1/aggregate ``` **Required scope:** depends on `entity` — `contacts:read`, `companies:read`, `deals:read`, or `events:read`. Group-by aggregation across contacts, companies, deals, or events, with optional filters and metrics. Aggregations never activate a profile. This is the engine behind dashboard charts and `aggregate_data` in [MCP](/docs/mcp/tools). ### Request Body | Field | Type | Required | Description | | -------------- | ------ | -------- | ---------------------------------------------------------------------------- | | `entity` | string | yes | `contacts`, `companies`, `deals`, or `events`. | | `group_by` | string | yes | Field to group on (e.g. `lifecycle_stage`, `industry`, `country`, `stage`, `event_type`, `day`). | | `filters` | object | no | Entity-specific filters applied before grouping (see below). | | `field_filters`| array | no | Typed filters on canonical or materialized source fields: `[{ "field", "op", "value" }]`, max 10. | | `segment` | string | no | Scope to a saved segment (id or handle). Contacts and companies only. | | `metric` | string | no | `count` (default), `avg`, `sum`, `min`, `max`. | | `metric_field` | string | no | Required for non-`count` metrics — the field to aggregate. | | `limit` | number | no | Max buckets returned (1–50, default 25). | ### Valid `group_by` dimensions - **Contacts**: `lifecycle_stage`, `lead_status`, `industry`, `country`, `state`, `city`, `company`, `company_id`, `job_title`, `email_verification_state`, `tag`, `segment_id`, `source`, or a materialized source field written as `source.field` (e.g. `apollo.industry`). - **Companies**: `industry`, `country`, `state`, `size_bucket`, `revenue_bucket`, `tag`, `segment_id`, `source`, or a materialized source field. - **Deals**: `stage`, `status`, `currency`, `company_id`, `contact_id`, `close_month`, `amount_bucket`, `tag`. - **Events**: `event_type`, `domain`, `url`, `url_path`, `title`, `text`, `element_type`, `element_id`, `class`, `contact_id`, `visitor_id`, `session_id`, `company_id`, `day`, `week`, `month`, `device_type`, `browser`, `os`, `platform`, `referring_domain`, `is_bot`. An unknown `group_by` (or another invalid combination, such as `segment` on deals) is a `400` with code `invalid_aggregate` and the reason in the message. `size_bucket` and `revenue_bucket` are derived buckets — `1-10` / `11-50` / `51-200` / etc. for `numberofemployees`, and `<$1M` / `$1M-$10M` / etc. for `annualrevenue`. `day`, `week`, and `month` are derived from each event's timestamp. `class` groups on the full CSS class string, which is noisy (hashed tokens) — for targeting a specific element prefer the `class_contains` filter with a stable token. Text buckets are lowercased (`united states`). Deal `stage` buckets merge case variants but show a stored spelling (`Closed Won`). An `(unknown)` bucket means the field is empty; to select those records use `field_filters` with `{ "field": "country", "op": "exists", "value": false }`. ### Valid `filters` - **Contacts**: `company` (substring), `company_id`, `industry`, `lifecycle_stage`, `lead_status`, `city`, `state`, `country`, `job_title` (substring), `created_after`, `created_before`, `is_identified`, `email_verification_state`, `email_verified_after`, `email_verified_before`, `search`, `has_linkedin`, `tags_any`, `tags_all`, `data_sources` (comma string or array), `segments_any` (ids or handles, static or dynamic), `exclude_internal`, `contact_ids` (capped at 100). - **Companies**: `name` (substring), `domain` (substring), `industry`, `country`, `state`, `min_employees`, `max_employees`, `search`, `tags_any`, `tags_all`, `data_sources`, `segments_any`, `exclude_internal`. - **Deals**: `name` (substring), `status`, `stage`, `currency`, `company_id`, `contact_id`, `min_amount`, `max_amount`, `close_date_after`, `close_date_before`, `created_after`, `created_before`, `tags_any`, `tags_all`. - **Events**: `event_type`, `event_types` (array or comma-separated string), `domain`, `url`, `url_path`, `url_contains`, `text`, `text_contains`, `title`, `title_contains`, `element_type`, `element_id`, `class_contains`, `contact_id`, `contact_ids`, `visitor_id`, `session_id`, `referring_domain`, `device_type`, `browser`, `os`, `platform` (`web`, `ios` or `android`; anything else is a `400`), `is_bot`, `identified_only`, `date_after`, `date_before`. Labels such as `country`, `state`, `city`, `industry`, `lifecycle_stage` and deal `stage` match the **whole value, case-insensitively**: `"united states"` (as a bucket shows it) finds `United States`, and `"US"` does not find `Australia`. The same holds for the list endpoints' filters. Filter keys that aren't listed here are not applied; the response names them in `ignored_filters`. ### Example — events per day for the last 30 days ```bash curl -X POST https://api.pathbound.ai/v1/aggregate \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "entity": "events", "group_by": "day", "filters": { "event_type": "page_view", "date_after": "2026-03-30T00:00:00Z" }, "metric": "count", "limit": 30 }' ``` ### Response — `200 OK` ```json { "status": "success", "entity": "events", "group_by": "day", "total_matching": 11900, "distinct_groups": 30, "buckets": [ { "day": "2026-04-28", "count": 511 }, { "day": "2026-04-29", "count": 432 } ] } ``` Each bucket is keyed by the `group_by` field, with a `count` (and, for a non-`count` metric, a `_` value). Contacts, companies and deals return `total_records` (everything in the workspace) and `total_matching` (records matching your filters); events return `total_matching`. With `group_by: "source"`, buckets overlap (a record can carry several sources), so they sum to more than `total_matching`. **Event aggregations** additionally return `distinct_groups` — the total number of unique `group_by` values, **not** capped by `limit`. This answers "how many distinct X" in the same call: `group_by=visitor_id` with `filters: { "event_type": "button_click", "text": "Go!" }` returns `total_matching` (how many `Go!` clicks) alongside `distinct_groups` (how many distinct visitors clicked it at least once). Use `group_by=contact_id` to count distinct *identified* contacts instead of anonymous visitors. --- # Schema, usage and capabilities > Ask your workspace which fields you can filter on, how much of your plan is used, how fresh each source is, and which actions are switched on. Source: https://pathbound.ai/docs/api/workspace These endpoints describe your workspace instead of returning its records. Call them when you build a query, before a large read, or when an action is refused and you want to know what is missing. None of them counts against your plan. The MCP tools `describe_schema` and `get_usage` call the first two. See the [tools reference](/docs/mcp/tools). | Endpoint | Scope | Returns | | -------- | ----- | ------- | | [`GET /v1/schema`](#schema) | the entity's read scope | The fields you can filter and group on, per connected source. | | [`GET /v1/usage`](#usage) | none | How much of your plan is used this cycle, and when each source last synced. | | [`GET /v1/tenant/capabilities`](#capabilities) | `actions:write` | Which integrations are connected and which actions are switched on. | | [`GET /v1/auth/status`](/docs/getting-started/authentication#verify-your-key) | none | Who the key belongs to, its workspace and its scopes. | ## Schema ``` GET /v1/schema ``` **Required scope:** `contacts:read`, `companies:read` or `deals:read`, matching `entity`. Returns every field you can use in a [typed filter](/docs/api/overview#filtering), and the source fields you can sort by or group by in [`POST /v1/aggregate`](/docs/api/batch#aggregate). The list depends on your workspace: it holds the standard properties, the fields of each source you have connected, and the columns of a connected database. ### Query Parameters | Parameter | Type | Default | Description | | ----------------- | ------- | ------- | ----------- | | `entity` | string | — | Required. `contacts`, `companies` or `deals`. | | `include_samples` | boolean | `false` | Add a few example values to fields where that is cheap and the field holds nothing personal. | ```bash curl "https://api.pathbound.ai/v1/schema?entity=contacts" \ -H "Authorization: Bearer $PATHBOUND_API_KEY" ``` ### Response — `200 OK` ```json { "status": "success", "entity": "contact", "connected_sources": ["apollo", "hubspot"], "filterable_fields": [ { "field": "lifecyclestage", "source": "canonical", "type": "string", "ops": ["eq", "neq", "in", "nin", "exists", "contains"], "group_by": true, "pii": false, "filterable": true, "via": "materialized" }, { "field": "createdate", "source": "canonical", "type": "date", "ops": ["eq", "neq", "in", "nin", "exists", "gt", "gte", "lt", "lte"], "group_by": true, "pii": false, "filterable": true, "via": "materialized" }, { "field": "hubspot.hs_lead_status", "source": "hubspot", "type": "string", "ops": ["eq", "neq", "in", "nin", "exists", "contains"], "group_by": true, "pii": false, "filterable": true, "indexed": true, "via": "materialized" } ] } ``` | Field | Description | | ----- | ----------- | | `entity` | `contact`, `company` or `deal`. | | `connected_sources` | The sources whose fields are listed. Always empty for deals, which are created in Pathbound and have no source fields. | | `filterable_fields[].field` | The name to use in a filter. A standard property is written plainly (`lifecyclestage`). A source's field is written `source.field`. | | `filterable_fields[].source` | `canonical` for a standard property, otherwise the source's id. | | `filterable_fields[].type` | `string`, `number`, `boolean`, `date` or `string[]`. Filter values are converted to this type, and a value that cannot be converted is a `400`. | | `filterable_fields[].ops` | The operators the field takes. | | `filterable_fields[].group_by` | For a `source.field` entry: whether it can be the `group_by` of an aggregate, written as it is here. For a standard property the flag is no guide: an aggregate groups standard properties by its own dimension names (`lifecycle_stage`, `job_title`), which are [listed with the aggregate](/docs/api/batch#valid-group_by-dimensions), and `group_by: "lifecyclestage"` is a `400`. | | `filterable_fields[].pii` | Whether the field holds personal data about a contact. | | `filterable_fields[].filterable` | `false` only for an AI agent in a workspace that hides contact PII from agents. Always `true` for an API key. | | `filterable_fields[].custom`, `label` | Present on a custom field found in a source. `label` is the name the source shows for it. | | `filterable_fields[].samples` | Example values, with `include_samples=true`. | | `warm_truncated` | `true` when a source has more custom fields than are listed. | The standard properties are always listed, but a filter is not limited to them: any other property of a contact or a company can be named in a filter too. Its value is then compared as you sent it, without conversion to a type. ## Usage ``` GET /v1/usage ``` **Required scope:** none. Any valid key can call it. Returns how many [active profiles](/docs/api/overview#usage-and-metering) your workspace has used in the current billing cycle and how many remain, and when each connected source last synced. ```bash curl https://api.pathbound.ai/v1/usage \ -H "Authorization: Bearer $PATHBOUND_API_KEY" ``` ### Response — `200 OK` ```json { "status": "success", "workspace": { "id": "tenant_xyz", "name": "Acme Corp", "ws": "acmecorp" }, "activations_used": 1284, "remaining": 8716, "plan_cap": 10000, "cycle_start": "2026-10-02T00:00:00.000Z", "cycle_end": "2026-11-02T00:00:00.000Z", "reset_at": "2026-11-02T00:00:00.000Z", "subscription_status": "active", "plan_tier": "growth", "plan_label": "Growth", "entitlement_state": "subscribed", "is_trial": false, "trial_ends_at": null, "can_activate": true, "is_billing_exempt": false, "sources": [ { "source": "apollo", "last_synced_at": "2026-10-12T09:41:07.000Z" }, { "source": "hubspot", "last_synced_at": "2026-10-12T09:44:52.000Z" } ] } ``` | Field | Description | | ----- | ----------- | | `workspace` | The workspace the key reaches: its `id`, its `name` (`null` when the name holds characters the MCP server does not show to a model) and its `ws`, the value an [MCP connector URL](/docs/mcp/overview#connecting-several-workspaces) names it by. | | `activations_used` | Customers read so far in this cycle. | | `plan_cap` | The plan's allowance per cycle. `null` means unlimited. `0` means no new customer can be read: the trial is over, or the subscription is paused or canceled. | | `remaining` | `plan_cap` less `activations_used`, never below 0. `null` when the plan is unlimited. | | `can_activate` | Whether the plan allows new customers to be read at all: `false` when the trial is over, or the subscription is paused or canceled. It does not look at usage, so it stays `true` when the allowance is used up. To know whether a read will go through, check `remaining` or use a `dry_run`. | | `cycle_start`, `cycle_end`, `reset_at` | The current cycle. The count starts again at `reset_at`. | | `plan_tier`, `plan_label` | The plan, as a key and as its name. | | `entitlement_state` | `subscribed`, `trialing`, `trial_expired`, `paused`, `inactive`, `custom` or `exempt`. | | `is_trial`, `trial_ends_at` | Whether the workspace is in its trial, and when the trial ends. | | `subscription_status` | The subscription's status, or `null` when there is none. | | `is_billing_exempt` | `true` for a workspace on an arrangement outside the standard plans. | | `sources[]` | Each connected source and when it last finished a sync. `postgres` stands for a connected database. `last_synced_at` is `null` for a source that has not synced yet. | Records you have already read this cycle stay readable at any `remaining`, and counts, aggregates and id-only lists are always free. To learn what one particular read would add, use its [`dry_run`](/docs/api/overview#usage-and-metering). ## Capabilities ``` GET /v1/tenant/capabilities ``` **Required scope:** `actions:write` Returns which of the integrations that have action endpoints are connected (Gmail, Resend, Instantly, Apollo and Hunter) and, for each group of actions, whether the caller can use it now and why not. A connected CRM or another source that is only read is not listed here: [`GET /v1/usage`](#usage) lists those under `sources`. Call it when an action endpoint answers `412`, or before you offer an action in your own interface. Capabilities are answered for the user who created the key. Sending from Gmail is the clearest case: `gmail.accounts` lists every mailbox connected in the workspace, and `send_enabled` is `true` only for a mailbox that user connected themselves and may send from. ### Response — `200 OK` ```json { "status": "success", "capabilities": { "integrations": ["gmail", "resend", "apollo"], "gmail": { "accounts": [ { "account_id": "ada@example.com", "is_primary": true, "has_send_scope": true, "needs_reconsent": false, "is_valid": true, "send_enabled": true } ] }, "tool_settings": { "gmail_send_email": { "enabled": true, "sender_email": "ada@example.com", "senders": ["ada@example.com"] }, "resend_send_email": { "enabled": false, "reason": "tool_disabled" }, "prospecting_enrich": { "enabled": true, "providers": ["apollo"] }, "apollo_sequences": { "enabled": false, "reason": "scope_missing" }, "streams_manage": { "enabled": true } }, "privacy": { "mcp_redact_pii": false }, "cache_ttl_seconds": 60 } } ``` `tool_settings` holds one entry per group of actions; the example shows five of them. Each entry has `enabled` and, when it is `false`, a `reason`. For Gmail, `sender_email` is the mailbox a send without `from` would use, and `senders` every mailbox the caller may name. When `enabled` is `true` and there is no `sender_email`, several mailboxes can send and none is the default, so every send has to name one. | `reason` | Meaning | What to do | | -------- | ------- | ---------- | | `integration_missing` | The integration is not connected. | Connect it under **Integrations**. | | `tool_disabled` | A workspace admin has not switched this action on. | Switch it on under **MCP Tools**. | | `scope_missing` | The integration is connected without a permission the action needs. | Connect it again and grant the permission. | | `invalid_credentials` | The integration's credentials stopped working. | Connect it again. | | `no_primary` | The user who created the key has connected no Gmail mailbox of their own. | Connect one under **Integrations → Gmail**. | Each name under `tool_settings` stands for a switch on the **MCP Tools** page. Those switches are set for AI agents. Whether one also applies to an API key differs by action, and each action's page says so: [Gmail](/docs/api/gmail), [Resend](/docs/api/resend), [Apollo sequences](/docs/api/apollo), [Instantly](/docs/api/instantly), [Enrichment and prospecting](/docs/api/enrichment), [Streaming](/docs/api/webhooks) and [Knowledge](/docs/api/knowledge). `privacy.mcp_redact_pii` says whether the workspace hides contacts' personal details from AI agents. It does not change what an API key receives. --- # Knowledge > List, search, read and write your workspace's knowledge pages, the markdown pages about your own business that your team and your AI agents read. Source: https://pathbound.ai/docs/api/knowledge Knowledge pages are markdown pages a workspace keeps about its own business: product, pricing, ideal customers, terminology, playbooks. Your team reads and edits them on the **Knowledge** page of the dashboard. AI agents connected to the workspace read them before they write for you or reason about your customers. Use these endpoints to publish pages from your own systems, to keep one section of a page current from a script, or to search what the workspace knows. The MCP tools `list_knowledge`, `search_knowledge`, `get_knowledge_page`, `save_knowledge_page` and `update_knowledge_section` call the same endpoints. See the [MCP tools reference](/docs/mcp/tools#knowledge). | Endpoint | Scope | What it does | | -------- | ----- | ------------ | | `GET /v1/knowledge` | `knowledge:read` | List the pages, with the pinned pages in full. | | `GET /v1/knowledge/search` | `knowledge:read` | Find the sections that match a few keywords. | | `GET /v1/knowledge/pages?slug=` | `knowledge:read` | Look a page up by its slug to get its id. | | `GET /v1/knowledge/pages/:id` | `knowledge:read` | Read a page, or one section of it. | | `POST /v1/knowledge/pages` | `knowledge:write` | Create a page. | | `PATCH /v1/knowledge/pages/:id` | `knowledge:write` | Change a page's title, summary, tags or whole body. | | `POST /v1/knowledge/pages/:id/section` | `knowledge:write` | Replace one section, or add one. | `knowledge:write` is an opt-in scope: a key has it only if it was selected when the key was created. See [Scopes](/docs/getting-started/authentication#scopes). Knowledge calls read no customer record, so they never count against your plan. ## What an API Key Can and Cannot Do A page that one caller writes is read by everyone in the workspace, and by their AI agents, as the workspace's own position. So what a key may do is narrower than what a person may do in the dashboard. A key with `knowledge:write` can: - create pages; - change pages that are neither locked nor pinned. A key can never: - **delete a page.** There is no delete endpoint. - **change a locked or a pinned page.** The request is refused with `403`. - **lock, unlock, pin or unpin a page, or mark it verified.** `locked` and `pinned` in a request body are ignored, like any other field the endpoint does not list. - **name the author of a change.** The author is taken from the key. Everything a key writes is `unverified` until a person saves the page in the dashboard. Locking, pinning and deleting are done by a workspace admin in the dashboard. ### The two workspace switches A workspace admin has two switches for AI agents, both off by default: **Read knowledge** and **Add to knowledge**, under **MCP Tools** and on the **Knowledge** page of the dashboard. They apply to calls that reach the API through the MCP server, whether the agent signed in or uses an API key there. A direct call to these endpoints with an API key is governed by the key's scopes alone, whichever way the switches are set. ## Pages, Slugs and Ids A page has two names. - Its **`id`** addresses it in a URL: `/v1/knowledge/pages/:id`. - Its **`slug`** is the readable name you choose, such as `pricing` or `playbooks/renewals`: lowercase letters, digits and hyphens, in up to three parts joined by `/`, each part starting with a letter or a digit, 120 characters at most. A slug is unique among the workspace's pages. A slug can contain `/`, so it is never part of a URL path. To go from a slug to an id, call [`GET /v1/knowledge/pages?slug=`](#look-up-a-page-by-slug). The list endpoint returns slugs without ids. Search results carry both. ### Trust Every page and every search hit carries `trust`, which says how much weight the text deserves. | `trust` | Meaning | | ------------ | ------- | | `pinned` | An admin pinned the page: it holds the workspace's standing rules for AI agents. | | `verified` | A person saved the current revision in the dashboard. | | `unverified` | The current revision was written by an API key or an AI agent, and no person has saved the page since. | A change through the API to a `verified` page makes it `unverified` again. ### Revisions `revision` starts at 1 and goes up by one each time a page's title, summary, tags, body or date changes. A request that changes nothing makes no revision. Send the revision you read as `expected_revision` when you write, so that you never overwrite a version you have not seen. A page's most recent revisions are kept, not all of them. ### Sections A page is read and edited by section. A section starts at a heading of level 1 to 3 (`#`, `##`, `###`) and holds that heading's text and every deeper section under it, up to the next heading of the same or a higher level. Headings of level 4 and deeper do not start a section, and neither does a `#` inside a fenced code block. Each section has an **anchor**: its heading in lowercase with punctuation removed and spaces turned into hyphens, so `## 90 days out` is `90-days-out`. A second section with the same heading gets `-2`, a third `-3`. Text before the first heading is the section `intro`. One exception: when a page opens with its only `#` heading, that heading is the page's title. Its section is just the text between the title and the next heading, never the whole page, and it is left out of the `heading_path` of the other sections. Wherever an endpoint takes a `section`, you can pass the anchor, the heading text (with or without its `#` marks, in any case), or the heading path (`Discounts > Discount approval rules`). ## List Knowledge Pages ``` GET /v1/knowledge ``` **Required scope:** `knowledge:read` Returns the pinned pages in full, then one row per page, sorted by slug. ### Query Parameters | Parameter | Type | Default | Description | | --------- | ------ | ------- | ----------- | | `offset` | number | 0 | Row to start from. Pass the previous response's `next_offset`. | | `limit` | number | 40 | Rows per call (1 to 100). Fewer come back when they would not fit one response. | | `tag` | string | none | Only pages with this tag. | ### Example ```bash curl https://api.pathbound.ai/v1/knowledge \ -H "Authorization: Bearer $PATHBOUND_API_KEY" ``` ### Response — `200 OK` ```json { "status": "success", "pinned": [ { "slug": "agent-rules", "title": "Rules for agents", "revision": 4, "body": "# Rules for agents\n\nNever quote a price that is not on the pricing page.\n" } ], "pages": [ { "slug": "agent-rules", "title": "Rules for agents", "summary": "Never quote a price that is not on the pricing page.", "trust": "pinned", "locked": false, "updated_at": "2026-10-06T08:41:17.000Z", "updated_by": { "type": "user", "email": "dana@example.com" }, "chars": 73 }, { "slug": "pricing", "title": "Pricing and commercial terms", "summary": "What we charge, and what a rep may agree to.", "tags": ["pricing", "sales"], "trust": "verified", "locked": true, "as_of": "2026-10-01", "updated_at": "2026-10-08T11:27:09.000Z", "updated_by": { "type": "user", "email": "dana@example.com" }, "chars": 14260 } ], "total": 2, "offset": 0, "timestamp": "2026-10-13T09:30:00.000Z" } ``` | Field | Description | | ----- | ----------- | | `pinned` | The pinned pages, each with its whole `body`. Returned with `offset=0` only, whatever `tag` says. A workspace pins only a few short pages, so this part stays small. | | `pages` | One row per page. A pinned page is also a row here. | | `pages[].summary` | The page's summary, cut to 240 characters. | | `pages[].tags` | Present when the page has tags. | | `pages[].trust` | See [Trust](#trust). | | `pages[].locked` | `true` when only an admin may change the page. | | `pages[].as_of` | The date the page's facts are correct for (`YYYY-MM-DD`), when the page states one. | | `pages[].updated_by` | Who last changed the page: `type` is `user` (a person in the dashboard), `mcp` (an AI agent), `api_key` or `import` (a bulk import), with the `email` of the account behind it when known. | | `pages[].chars` | The length of the body in characters. | | `total` | Pages that match, across all calls. | | `next_offset` | Present when more rows follow. Pass it as `offset`. A `hint` beside it says the same in words. | A row has no `id`. Get it with [Look Up a Page by Slug](#look-up-a-page-by-slug). ## Search Knowledge ``` GET /v1/knowledge/search ``` **Required scope:** `knowledge:read` Finds the sections that match a query, best first. This is a keyword search with no sense of meaning: `discount approval rules` finds the right section more reliably than `what discount can a rep give without asking?`. If a query misses, try the words a heading would use. ### Query Parameters | Parameter | Type | Default | Description | | --------- | ------ | ------- | ----------- | | `q` | string | none | Required. A few keywords, up to 200 characters. | | `tag` | string | none | Only search pages with this tag. | | `limit` | number | 8 | Most sections to return (1 to 20). | ### Example ```bash curl "https://api.pathbound.ai/v1/knowledge/search?q=discount+approval" \ -H "Authorization: Bearer $PATHBOUND_API_KEY" ``` ### Response — `200 OK` ```json { "status": "success", "query": "discount approval", "matched_by": "text", "results": [ { "page_id": "6701c9e25a3b4c7d8e9f0a1b", "page": "pricing", "title": "Pricing and commercial terms", "trust": "verified", "updated_at": "2026-10-08T11:27:09.000Z", "updated_by": { "type": "user", "email": "dana@example.com" }, "as_of": "2026-10-01", "section": "discount-approval-rules", "heading_path": "Discounts > Discount approval rules", "chars": 99, "text": "### Discount approval rules\n\nA rep may give up to 10% without asking. Above that, ask your manager." } ], "timestamp": "2026-10-13T09:30:00.000Z" } ``` | Field | Description | | ----- | ----------- | | `matched_by` | `text` when the words were found in the pages. `heading` when they were not, and the results come from a looser match on page titles and headings. | | `results[].page_id`, `results[].page` | The page's id and its slug. | | `results[].section` | The anchor of the matching section. | | `results[].heading_path` | The section's heading with the headings above it. | | `results[].text` | The section's own text, without the sections under it. The best hits carry it. Once one response is full, the remaining hits come without `text`, and a `hint` says how many. | | `results[].part`, `results[].parts` | Present when the section's own text is too long for one response and was cut into parts. `part` is the one that matched. | | `results[].chars` | The length of the matching text. | To read a hit that came without its text, call [Get a Page](#get-a-page) with `page_id`, `section` and, when the hit has one, `part`. When nothing matches, `results` is empty and `hint` suggests what to try. ## Look Up a Page by Slug ``` GET /v1/knowledge/pages?slug= ``` **Required scope:** `knowledge:read` Resolves a slug to its page. The slug is matched exactly, and slugs are lowercase. ### Query Parameters | Parameter | Type | Default | Description | | --------- | ------ | ------- | ----------- | | `slug` | string | none | Required. The page's slug, up to 120 characters. URL-encode a `/`. | ### Example ```bash curl "https://api.pathbound.ai/v1/knowledge/pages?slug=playbooks%2Frenewals" \ -H "Authorization: Bearer $PATHBOUND_API_KEY" ``` ### Response — `200 OK` ```json { "status": "success", "pages": [ { "id": "670b9c3f4c1a9d3e5f6071c8", "slug": "playbooks/renewals", "title": "Renewal playbook", "trust": "unverified", "locked": false, "revision": 1, "as_of": "2026-10-01", "updated_at": "2026-10-13T09:41:27.000Z", "updated_by": { "type": "api_key", "email": "sam@example.com" }, "chars": 120 } ], "timestamp": "2026-10-13T09:45:10.000Z" } ``` `pages` holds one page, or is empty when no page has that slug. An unknown slug is not an error. `as_of` is present only when the page states a date. ## Get a Page ``` GET /v1/knowledge/pages/:id ``` **Required scope:** `knowledge:read` Returns a page, or one section of it. Every response is sized to fit one AI agent tool result, about 10,000 characters of JSON, and a direct API call gets the same responses. So a short page comes back whole, and a long one comes back as its opening and an outline that you then read section by section. No call returns the whole body of a long page at once. ### Path Parameters | Parameter | Type | Description | | --------- | ------ | ----------- | | `id` | string | The page's id. | ### Query Parameters | Parameter | Type | Default | Description | | --------- | ------ | ------- | ----------- | | `section` | string | none | Read this section only: an anchor, a heading or a heading path. See [Sections](#sections). | | `part` | number | 1 | For a section that comes in parts, the part to read. | ### Example ```bash curl https://api.pathbound.ai/v1/knowledge/pages/670b9c3f4c1a9d3e5f6071c8 \ -H "Authorization: Bearer $PATHBOUND_API_KEY" ``` ### Response — `200 OK` A page that fits: ```json { "status": "success", "page": { "id": "670b9c3f4c1a9d3e5f6071c8", "slug": "playbooks/renewals", "title": "Renewal playbook", "trust": "unverified", "locked": false, "revision": 1, "as_of": "2026-10-01", "updated_at": "2026-10-13T09:41:27.000Z", "updated_by": { "type": "api_key", "email": "sam@example.com" }, "chars": 120 }, "body": "# Renewal playbook\n\nHow we run a renewal, from 90 days out to signature.\n\n## 90 days out\n\nCheck usage against the plan.\n", "timestamp": "2026-10-13T09:45:10.000Z" } ``` `page` always has these fields, with `as_of` only when the page states a date. The next two examples cut it short. A page that does not fit has no `body`. It has `truncated: true`, the opening of the page in `lead`, and an `outline`: ```json { "status": "success", "page": { "id": "6701c9e25a3b4c7d8e9f0a1b", "slug": "pricing", "revision": 7, "chars": 14260 }, "truncated": true, "lead": "# Pricing and commercial terms\n\nWhat we charge, and what a rep may agree to.\n", "outline": [ { "section": "pricing-and-commercial-terms", "heading_path": "Pricing and commercial terms", "level": 1, "chars": 77 }, { "section": "plans", "heading_path": "Plans", "level": 2, "chars": 6120 }, { "section": "discounts", "heading_path": "Discounts", "level": 2, "chars": 56 }, { "section": "discount-approval-rules", "heading_path": "Discounts > Discount approval rules", "level": 3, "chars": 100 }, { "section": "contract-terms", "heading_path": "Contract terms", "level": 2, "chars": 7907 } ], "hint": "This page is too long for one response. Read one section at a time: pass section=.", "timestamp": "2026-10-13T09:30:00.000Z" } ``` Each `outline` entry is a section: its anchor, its heading path, its heading level (0 for `intro`), and the length of its own text. On a page with so many headings that the outline itself would not fit, the outline lists the sections down to level 2 only, and `hint` says how many are left out. With `section`, the response has a `section` object in place of `body`: ```json { "status": "success", "page": { "id": "6701c9e25a3b4c7d8e9f0a1b", "slug": "pricing", "revision": 7, "chars": 14260 }, "section": { "section": "discounts", "heading_path": "Discounts", "text": "## Discounts\n\nDiscounts are given on annual plans only.\n\n### Discount approval rules\n\nA rep may give up to 10% without asking. Above that, ask your manager.\n", "chars": 157 }, "timestamp": "2026-10-13T09:30:00.000Z" } ``` | Field | Description | | ----- | ----------- | | `section.section` | The section's anchor. | | `section.text` | The section: its own text and the sections under it. | | `section.chars` | The size of the section in characters. When the response is truncated, it is more than the length of `text`. | A section that does not fit is returned in one of two ways, both with `truncated: true` and a `hint` that says which: - **It has sections under it.** `text` holds its own text and as many of the sections under it as fit, in order. `section.children` lists the ones that are not in `text`, for you to read one at a time. `section.children_omitted` counts any that did not fit in that list. - **Its own text is too long.** `text` holds one part, `section.part` and `section.parts` say which of how many, and you read the rest with `part=2`, `part=3` and so on. A `part` beyond the last returns the last. The sections under it, if any, are all in `section.children`. An entry of `section.children` has the shape of an `outline` entry, or only `section` and `chars` when the full form would not fit. A `section` that matches no section is answered with `404 KNOWLEDGE_SECTION_NOT_FOUND`, and `details.sections` lists the page's anchors. A heading that several sections share is answered with `400 KNOWLEDGE_SECTION_AMBIGUOUS`, and `details.sections` lists the anchors of those sections. `page.revision` is the number to send as `expected_revision` when you change the page. ## Create a Page ``` POST /v1/knowledge/pages ``` **Required scope:** `knowledge:write` ### Request Body | Field | Type | Required | Description | | --------- | -------- | -------- | ----------- | | `slug` | string | yes | The page's slug. See [Pages, Slugs and Ids](#pages-slugs-and-ids). It cannot be changed later. | | `body` | string | no | The page as markdown, up to 256 KB. Use `##` and `###` headings: pages are read and edited one section at a time. Stored exactly as sent. | | `title` | string | no | Up to 200 characters. Defaults to the body's first `#` heading, or else to the last part of the slug. | | `summary` | string | no | What the page holds, up to 600 characters. Shown in the page list. Defaults to the page's section headed `In short`, `Summary`, `Overview` or `TL;DR`, or else to its first paragraph. | | `tags` | string[] | no | Up to 50 tags. Each is trimmed, lowercased and cut to 64 characters. | | `as_of` | string | no | The date the page's facts are correct for, such as `2026-10-01`. | ### Example ```bash curl -X POST https://api.pathbound.ai/v1/knowledge/pages \ -H "Authorization: Bearer $PATHBOUND_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "slug": "playbooks/renewals", "tags": ["playbook", "sales"], "as_of": "2026-10-01", "body": "# Renewal playbook\n\nHow we run a renewal, from 90 days out to signature.\n\n## 90 days out\n\nCheck usage against the plan.\n" }' ``` ### Response — `201 Created` ```json { "status": "success", "page": { "id": "670b9c3f4c1a9d3e5f6071c8", "slug": "playbooks/renewals", "title": "Renewal playbook", "summary": "How we run a renewal, from 90 days out to signature.", "tags": ["playbook", "sales"], "trust": "unverified", "locked": false, "pinned": false, "revision": 1, "as_of": "2026-10-01", "chars": 120, "created_at": "2026-10-13T09:41:27.000Z", "updated_at": "2026-10-13T09:41:27.000Z", "updated_by": { "type": "api_key", "email": "sam@example.com" } }, "timestamp": "2026-10-13T09:41:27.412Z" } ``` The response describes the page and does not repeat its body. `as_of` is `null` when the page states no date. A slug that is taken is answered with `409 KNOWLEDGE_SLUG_TAKEN`: update that page, or choose another slug. ## Update a Page ``` PATCH /v1/knowledge/pages/:id ``` **Required scope:** `knowledge:write` Changes a page's title, summary, tags, date or whole body. Send only the fields you want to change. To change one part of a page, use [Update a Section](#update-a-section): it leaves the rest of the page as it is. ### Path Parameters | Parameter | Type | Description | | --------- | ------ | ----------- | | `id` | string | The page's id. | ### Request Body | Field | Type | Required | Description | | ------------------- | -------- | -------- | ----------- | | `body` | string | no | The new body, which replaces the whole page. Needs `expected_revision`. | | `title` | string | no | A new title. A blank one is ignored. The title does not follow the body's `#` heading: send `title` to change it. | | `summary` | string | no | A new summary. | | `tags` | string[] | no | Replaces the page's tags. | | `as_of` | string | no | A new date. `null` removes it. | | `expected_revision` | number | with `body` | The revision you read. If the page has moved on, nothing is written. | A summary that was derived from the body, and never written by hand, follows the new body unless you send `summary`. ### Response — `200 OK` The page, in the shape of [Create a Page](#create-a-page), with the new `revision`. A request that changes nothing returns the page as it is. ### Errors specific to this endpoint - `400 KNOWLEDGE_EXPECTED_REVISION_REQUIRED`: `body` differs from the stored body and no `expected_revision` was sent. `details.current_revision` holds the page's revision. - `409 KNOWLEDGE_REVISION_CONFLICT`: `expected_revision` is not the page's current revision (`details.current_revision`), or someone else saved the page while this request was being written. Read the page again and apply your change to it. ## Update a Section ``` POST /v1/knowledge/pages/:id/section ``` **Required scope:** `knowledge:write` Replaces one section and leaves the rest of the page untouched, or adds a new section at the end. The edit is applied to the page as it is at that moment, so two callers who edit different sections both succeed. ### Path Parameters | Parameter | Type | Description | | --------- | ------ | ----------- | | `id` | string | The page's id. | ### Request Body | Field | Type | Required | Description | | ------------------- | ------- | -------- | ----------- | | `section` | string | yes | The section to replace: an anchor, a heading or a heading path, up to 300 characters. With `create_if_missing`, the heading of the new section. | | `content` | string | yes | The new markdown for the section. | | `create_if_missing` | boolean | no | `true` adds the section at the end of the page when no section matches. Default `false`: an unknown section is a `404` that lists the ones that exist. | | `expected_revision` | number | no | The revision you read. The write is refused only if this section changed since then. | ### What is replaced - **The section and everything under it** is replaced by `content`: its own text and all the deeper sections it holds. - **The section keeps its heading.** Send only what goes under it: text, or its sub-sections as `### …`. - **To rename a section**, start `content` with a heading of the section's own level (`## New name` for a `##` section). A heading of a higher level works the same way. - **Naming the page's `#` title** replaces only the text between the title and the first section, never the rest of the page. - **`intro`** is the text before the first heading. - **A new section** (`create_if_missing`) is added at the end under `## ` and the text of `section`. If `content` itself opens with a `#` or `##` heading, it is added as it is. ### `expected_revision` Without it, the edit goes through whatever happened to the page since you read it. With it, the page may have moved on, and the write still goes through as long as this section reads the same as it did at that revision. If the section changed, or that revision is no longer among the ones that are kept, the request is refused with `409 KNOWLEDGE_SECTION_CONFLICT` and `details.current_revision`. It is not checked when a new section is added. ### Example ```bash curl -X POST https://api.pathbound.ai/v1/knowledge/pages/670b9c3f4c1a9d3e5f6071c8/section \ -H "Authorization: Bearer $PATHBOUND_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "section": "90-days-out", "content": "Check usage against the plan, and book the review call.", "expected_revision": 1 }' ``` ### Response — `200 OK` ```json { "status": "success", "page": { "id": "670b9c3f4c1a9d3e5f6071c8", "slug": "playbooks/renewals", "title": "Renewal playbook", "summary": "How we run a renewal, from 90 days out to signature.", "tags": ["playbook", "sales"], "trust": "unverified", "locked": false, "pinned": false, "revision": 2, "as_of": "2026-10-01", "chars": 145, "created_at": "2026-10-13T09:41:27.000Z", "updated_at": "2026-10-13T09:52:03.000Z", "updated_by": { "type": "api_key", "email": "sam@example.com" } }, "section": "90-days-out", "created": false, "changed": true, "timestamp": "2026-10-13T09:52:03.288Z" } ``` | Field | Description | | ----- | ----------- | | `section` | The section's anchor after the write. It differs from the one you sent when the heading was renamed. | | `created` | `true` when the section was added. | | `changed` | `false` when `content` is what the section already held. No revision is made then. | ## Limits | Limit | Value | | ----- | ----- | | Pages per workspace | Limited. A create past the limit is answered `403 QUOTA_EXCEEDED`, and the message states the limit. | | Page body | 256 KB | | Title | 200 characters | | Summary | 600 characters | | Slug | 120 characters | | Tags per page | 50, each up to 64 characters | | Revisions kept per page | The most recent ones. An `expected_revision` older than those is answered `409 KNOWLEDGE_SECTION_CONFLICT`. | | Writes | Rate limited per workspace, across all keys and agents. A write over the limit is answered `429` with a `Retry-After` header. | ## Errors Errors use the [common error format](/docs/api/overview#errors). The message of each knowledge error says what to do next. | Status | `code` | Meaning | | ------ | ------ | ------- | | `400` | none | A parameter or a field failed validation: a missing `q`, `slug`, `section` or `content`, a value over its length, or an `id` that is not a page id. `details` lists each one. | | `400` | `KNOWLEDGE_INVALID_SLUG` | The slug does not fit the [slug rule](#pages-slugs-and-ids). | | `400` | `KNOWLEDGE_INVALID_AS_OF` | `as_of` is not a date. | | `400` | `KNOWLEDGE_EXPECTED_REVISION_REQUIRED` | A whole body was sent without `expected_revision`. | | `400` | `KNOWLEDGE_SECTION_AMBIGUOUS` | Several sections have that heading. `details.sections` lists their anchors: pass one. | | `403` | none | The key lacks `knowledge:read` or `knowledge:write`. `required_scopes` names it. | | `403` | `KNOWLEDGE_PAGE_LOCKED` | The page is locked. Only an admin changes it, in the dashboard. | | `403` | `KNOWLEDGE_PAGE_PINNED` | The page is pinned. Only an admin changes it, in the dashboard. | | `403` | `QUOTA_EXCEEDED` | The workspace has reached its limit on pages. | | `404` | `KNOWLEDGE_PAGE_NOT_FOUND` | No page has that id. | | `404` | `KNOWLEDGE_SECTION_NOT_FOUND` | No section matches. `details.sections` lists the page's anchors, and `details.more` counts any that did not fit in the list. | | `409` | `KNOWLEDGE_SLUG_TAKEN` | A page with that slug exists. | | `409` | `KNOWLEDGE_REVISION_CONFLICT` | The page changed since the revision you sent, or while the request was being written. | | `409` | `KNOWLEDGE_SECTION_CONFLICT` | The section changed since the revision you sent. | | `412` | `KNOWLEDGE_TOOLS_DISABLED`, `KNOWLEDGE_WRITE_DISABLED` | Only for calls made through the MCP server: the workspace's switch for agents reading, or adding to, knowledge is off. | | `413` | `KNOWLEDGE_PAGE_TOO_LARGE` | The body would be over 256 KB. Split it into several pages. | | `429` | none | Too many knowledge writes in a minute in this workspace. Wait for the number of seconds in the `Retry-After` header. | | `500` | none | The request could not be completed. | --- # Streaming webhooks > Push events, contacts, and companies to your own endpoint as they change. POST webhooks with retries, HMAC-signed once you set a secret. Source: https://pathbound.ai/docs/api/webhooks **Streaming** is Pathbound's third way to get your data, alongside the [REST API](/docs/api/contacts) and the [MCP server](/docs/mcp/overview): - **REST API** — pull a snapshot when you ask for it. - **MCP** — let an AI agent read your data as tools. - **Streaming** — Pathbound pushes changes to your endpoint *as they happen*. Use it to keep an external system in sync: notify your service when a high-intent visitor identifies, push form submissions to your warehouse, or mirror contacts and companies into your own database as integrations enrich them. :::note Set a signing secret on a subscription (it is optional, and none is generated for you), then verify the signature on every delivery to prove the request came from Pathbound. ::: Streams are managed in the [dashboard](https://app.pathbound.ai/streaming). Their **trigger** — conditions, filters, change type, on/off — can also be managed over the [REST API](#managing-streams-over-the-api) and by an AI agent through the MCP server. Their **destination** cannot: see below. A stream can also act instead of posting: add a tag, change Resend segment membership, or send a Resend template to the person who submitted a form, or one of your Resend events for them. Those are [automations](#automations), [email automations](#email-automations) and [Resend event automations](#resend-event-automations). ## What you can stream Each subscription targets one **data type**: | Data type | Change types | Fires when | | --------- | ------------ | ---------- | | `event` | a tracked event name, or `*` | the events snippet, an integration or the [public form endpoint](/docs/api/forms) records an event | | `contact` | `created`, `updated`, `deleted`, or `*` | a unified contact is created, changed, or removed | | `company` | `created`, `updated`, `deleted`, or `*` | a unified company is created, changed, or removed | Contact and company changes capture **every** source — manual edits, the REST API, form submissions, and (the common case) integration syncs that create or enrich records pulled from HubSpot, Apollo, and the rest. ## Subscribing In the dashboard, open **Events → Streaming → New stream**, then: 1. Pick the **data type** (events, contacts, or companies). 2. Pick the **change type** — for events, the event name or `*`; for contacts/companies, `created` / `updated` / `deleted` / `*`. 3. Set the **destination URL**. Must be `https://` and publicly reachable — internal IP ranges are rejected. 4. Optionally add **filters** (see below). 5. Optionally add **custom headers** (encrypted at rest) and a **signing secret**. 6. Save. ## Delivery format Pathbound sends a `POST` with a JSON body and these headers: ``` Content-Type: application/json User-Agent: Pathbound-Webhooks/1.0 X-Pathbound-Signature: sha256= (only if a signing secret is set) ``` Plus any custom headers you configured. ### Event payload ```json { "id": "evt_xyz", "event": "page_view", "timestamp": "2026-06-08T12:00:00.000Z", "data": { "title": "Pricing", "path": "/pricing" }, "url": "https://example.com/pricing", "domain": "example.com", "visitor_id": "vis_123", "contact_id": "665f1c2ab3d4e5f601234567", "platform": "web", "os": "Mac OS X", "device_type": "Desktop" } ``` - `platform` is `web`, `ios` or `android`: a browser, or a native app's webview. Together with `os` and `device_type` (`Desktop` / `Mobile` / `Tablet`) it lets a receiver tell a tap in your mobile app from a click in a desktop browser without parsing `data.userAgent`. See [Server-added fields](/docs/events/types#server-added-fields) for how each is derived. - `contact_id` is absent for a visitor who isn't linked to a contact. `platform`, `os` and `device_type` are absent on an event that did not come from the snippet. ### Contact / company payload ```json { "id": "ec_abc123", "type": "contact.updated", "resource": "contact", "action": "updated", "timestamp": "2026-06-08T12:00:00.000Z", "source": "hubspot", "object": { "contact_id": "665f1c2ab3d4e5f601234567", "properties": { "email": "ada@example.com", "jobtitle": "VP Engineering", "lifecyclestage": "mql" } }, "changed_fields": ["jobtitle", "lifecyclestage"] } ``` - `object` is the full record, identical in shape to [`GET /v1/contacts/:id`](/docs/api/contacts) / [`GET /v1/companies/:id`](/docs/api/companies). For `deleted` it is the last-known state, or `null`. - `source` is the origin of the change: an integration name (`hubspot`, `apollo`, …), or `manual` / `form` / `api` / `system`. - `changed_fields` lists the fields that changed when available — it can be empty (creates, deletes, and updates where a per-field diff isn't computed). Treat `object` as the source of truth and reconcile by `updated_at`. ## Filters **Events** support **URL filters** — AND/OR substring patterns matched against the event URL (e.g. only deliver `page_view` events whose URL contains `/pricing`). **Contacts and companies** support **entity filters**: - **Changed-field filter** — when a per-field diff is available, only fire on *updates* that touch one of the named fields (e.g. `lifecyclestage`). When no diff is available the filter fails open (the update is still delivered). Creates and deletes always fire. - **Source filter** — only fire when the change came from one of the named origins (e.g. `hubspot`). ## Verifying the signature Compute an HMAC-SHA256 of the **raw request body** (not the parsed JSON) with your signing secret, and compare it to `X-Pathbound-Signature` (after stripping `sha256=`). Use a constant-time comparison. ```js title="Node" import crypto from 'node:crypto'; function verify(rawBody, header, secret) { if (!header?.startsWith('sha256=')) return false; const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex'); const provided = header.slice('sha256='.length); if (provided.length !== expected.length) return false; return crypto.timingSafeEqual(Buffer.from(provided), Buffer.from(expected)); } ``` ```python title="Python" import hmac, hashlib def verify(raw_body: bytes, header: str, secret: str) -> bool: if not header.startswith('sha256='): return False expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, header[len('sha256='):]) ``` If your framework already parsed the body, capture the raw bytes before parsing — re-stringifying won't match byte-for-byte. ## Delivery semantics - **Timeout.** Respond `2xx` within **30 seconds**. - **Retries.** A non-`2xx`, timeout, or network error is retried up to **3 times** with backoff (1s, 10s, 60s). A `4xx` is treated as a permanent client error and not retried. - **Circuit breaker.** After a long run of consecutive failures a subscription auto-pauses; re-enable it from the dashboard once your endpoint is healthy. - **At-least-once.** Deliveries can repeat — **dedupe on `id`**. - **Latest-wins, not strictly ordered.** Retries mean two changes to the same record can arrive out of order. Treat `object` as the latest state and reconcile by `updated_at` rather than relying on delivery order. - **Coalescing.** Many rapid changes to the same record within a short window (e.g. an integration sync touching it repeatedly) collapse into a single delivery carrying the net change. ## Managing streams over the API `/v1/streams` exposes stream configuration to scripts and AI agents. The same tools are available through the MCP server (`list_streams`, `get_stream`, `create_stream`, `update_stream`, `delete_stream`, `preview_stream_matches`, `get_stream_options`, `send_stream_test_email`; see the [tools reference](/docs/mcp/tools#streaming-and-email-automations)). | Method | Path | Scope | | ------ | ---- | ----- | | `GET` | `/v1/streams` | `streams:read` | | `GET` | `/v1/streams/options` | `streams:read` | | `GET` | `/v1/streams/:id` | `streams:read` | | `GET` | `/v1/streams/:id/deliveries` | `streams:read` | | `POST` | `/v1/streams/preview` | `streams:write` | | `POST` | `/v1/streams` | `streams:write` | | `PATCH` | `/v1/streams/:id` | `streams:write` | | `DELETE` | `/v1/streams/:id` | `streams:write` | | `POST` | `/v1/streams/:id/test-email` | `streams:write` (email automations only) | `streams:write` is opt-in: tick it when you create the API key, and grant it explicitly when you connect an MCP client. Both require a workspace admin. The MCP tools additionally need **Manage streams** enabled under Pathbound → Streaming in your [MCP Tools settings](https://app.pathbound.ai/mcp-tools). That is a workspace-level switch, independent of any integration; whether a Resend segment destination is *creatable* is reported separately by `GET /v1/streams/options`. An [email automation](#email-automations) asks for more than any other stream, because it sends email: the key or connection needs `actions:write` as well as `streams:write`, and Resend's **Send email** toggle must be on. :::caution[Headless MCP clients] A headless client authenticating with an API key also needs **`actions:write`** on that key for the stream tools to appear in `tools/list`. The MCP server reads your workspace's tool settings through an endpoint gated on that scope, and treats "can't read settings" as "every optional tool is off" — so a key scoped only `streams:read`/`streams:write` connects fine but shows no stream tools and no error. This applies to every optional tool group, not just streaming. ::: ### What the API cannot do **It cannot set a destination URL.** A stream that POSTs to an endpoint is a standing export of your customer data, so choosing that endpoint stays a human action in the dashboard. Over the API you can create **automations** — streams whose destination is an *action* on the matching record: add or remove tags, or add to or remove from a Resend segment in your own connected account. You can also create an **email automation**, which sends a template of your Resend account to the person who submitted a form. `destination_url`, custom headers and signing secrets are never settable and never returned. **What it can change depends on where the stream delivers:** | | Automations | Email automations | HTTPS streams | | --- | --- | --- | --- | | Create | yes | yes | no — dashboard only | | `name`, `active` | yes | yes | yes | | `conditions`, `event_type`, filters, `resource_type` | yes | **no**: it has none | **no** | | `destination_config` | immutable (the tags, the segment) — recreate | `template_id`, `daily_cap`, `min_interval_hours`; never `form_id` | — | | `destination_url`, `headers`, `secret` | never | never | never | An HTTPS stream's destination is invisible to the API, so the API also refuses to change *what gets sent there*. Widening one — clearing its conditions, or switching it from `event` to `contact` — would export customer records to an address the caller cannot read. Those edits return `403 STREAM_FIELD_FORBIDDEN`; make them in the dashboard. Three more deliberate omissions: `POST /v1/streams/preview` returns counts only (no contact records) and requires `streams:write` because a `contains` predicate over live data is a substring oracle; `GET /v1/streams/:id/deliveries` omits request and response bodies; and for HTTPS streams the delivery `error` is reduced to a category (`timeout`, `destination_client_error`, …) rather than the raw message, which can name the host. Use the dashboard to inspect an actual delivered payload. ### Automations An automation is a stream whose destination is an action. Four act on a contact or company that matches conditions, and are described here. A fifth, `resend_send_email`, sends a Resend template to the person who submitted a form; it follows different rules and has [its own section](#email-automations). A sixth, `resend_send_event`, sends one of your Resend events for that person instead, and has [its own section](#resend-event-automations) too. `GET /v1/streams/options` reports which are creatable for your workspace right now and lists the valid values for each one's config fields (`option_sources`). | `destination_type` | Acts on | Config | Meters? | | --- | --- | --- | --- | | `pathbound_tag_add` | contacts, companies | `{ "tags": ["vip", "customer"] }` (1–10) | no | | `pathbound_tag_remove` | contacts, companies | `{ "tags": ["lead"] }` (1–10) | no | | `resend_segment_add` | contacts | `{ "segment_id": "seg_…" }` | yes | | `resend_segment_remove` | contacts | `{ "segment_id": "seg_…" }` | yes | | `resend_send_email` | form submissions | a form and a Resend template: see [Email automations](#email-automations) | no | | `resend_send_event` | form submissions | a form and a Resend event: see [Resend event automations](#resend-event-automations) | no | (`resend_segment` is still accepted as an alias of `resend_segment_add`.) ```bash curl -X POST https://api.pathbound.ai/v1/streams \ -H "Authorization: Bearer $PATHBOUND_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Customers → onboarding segment", "destination_type": "resend_segment_add", "destination_config": { "segment_id": "seg_abc123" }, "event_type": "*", "conditions": [ { "field": "properties.lifecyclestage", "op": "eq", "value": "customer" }, { "field": "properties.company_size", "op": "in", "value": ["201-500", "501-1000"] } ] }' ``` ```bash # Tag every enterprise company, and drop the "lead" tag from contacts that became customers. curl -X POST https://api.pathbound.ai/v1/streams \ -H "Authorization: Bearer $PATHBOUND_API_KEY" -H "Content-Type: application/json" \ -d '{ "resource_type": "company", "destination_type": "pathbound_tag_add", "destination_config": { "tags": ["enterprise"] }, "event_type": "*", "conditions": [{ "field": "properties.numberofemployees", "op": "exists" }] }' curl -X POST https://api.pathbound.ai/v1/streams \ -H "Authorization: Bearer $PATHBOUND_API_KEY" -H "Content-Type: application/json" \ -d '{ "destination_type": "pathbound_tag_remove", "destination_config": { "tags": ["lead"] }, "event_type": "updated", "conditions": [{ "field": "properties.lifecyclestage", "op": "eq", "value": "customer" }] }' ``` The response carries `estimated_matches` — how many records match right now — so you can see the blast radius. Pass `"validate_only": true` to get that estimate without creating anything. `enrolled_count` on a stream is the number of records the action has been applied to. ### Rules worth knowing before you script this - **Conditions are level-triggered.** They describe current state ("lifecyclestage *is* customer"), never a transition ("*became* a customer"). A record matches for as long as the state holds. - **Conditions are AND-ed.** For "A or B" on a single field, use the `in` operator. Across fields, create two automations — tag automations may share the same tags. - **Each tag or segment automation acts on each record at most once, and never undoes it.** A contact is added to (or removed from) a segment once; a tag is added (or removed) once. When the record stops matching nothing is reversed, and a human who undoes the action afterwards is not fought: a tag you remove is not re-added, a contact you re-add is not re-removed. A no-op — the tag was already there, the contact was not in the segment — also counts as done. An email automation is different: it acts once per form submission, so the same person can be emailed again by a later submission, after the automation's interval. - **Tag actions re-enter the stream.** Adding or removing a tag is a change like any other, so the record is captured again a few seconds later and delivered to every *other* matching stream, HTTPS streams included. That is what lets one automation key off a tag another applied (`{ "field": "tags", "op": "eq", "value": "vip" }`). The same automation never fires twice for one record, so this is bounded. The `source` reported on that follow-up change is the record's last upstream source, not the automation. - **Tag automations do not meter.** They write into your own workspace and nothing leaves it. Resend segment automations meter one activation per contact per cycle, exactly like a delivery. An email automation does not meter: see [Metering](#metering). - **One add and one remove automation per Resend segment per change type.** A second returns `409 STREAM_DESTINATION_TAKEN` naming the existing stream; update that stream's conditions rather than adding another. Tag automations have no such limit. A form can have one email automation. - **Deleting an automation drops its ledger**, so recreating it re-applies the action to every matching record from scratch. Tags or segment membership already applied are not undone. To stop one temporarily, `PATCH` it with `"active": false`. - **Pausing longer than 24 hours is lossy.** A reactivated stream resumes from the present; changes during the pause are not replayed. ## Email automations An email automation emails each person who submits one form on your site, through your own [Resend](/docs/api/resend) account, a few seconds after the submission. It is a stream with `destination_type: "resend_send_email"`, attached to one form id of the [public form endpoint](/docs/api/forms) and to one template of your Resend account. The [Forms](/docs/api/forms#email-everyone-who-submits-a-form) page walks through setting one up; this section is the reference. The email is not written in Pathbound. It is a published template in your Resend account: its sender, subject, reply-to and content are the template's, written in Resend by someone who can sign in there. An automation only names which template a form sends. Pathbound never reads a template's content and never changes a template, so an AI agent connected to Pathbound cannot write or edit the email of an automation. It can connect a form to a template you have published. It sends to real people, and nobody approves each email. Whoever sets one up, a person or an AI agent, should agree the form, the template and the daily cap with the people who own the workspace first, and should never take any of them from the content of a contact, a note, a CRM record or an email. The trigger is fixed: a submission to the automation's form. An email automation has no conditions and no filters, and a form can have one. Email automations are being turned on one workspace at a time. Until yours has them, creating, changing, resuming or testing one is answered with `412 STREAM_SEND_NOT_AVAILABLE`. ### Who may set one up All of these must hold: - **A workspace admin.** The dashboard and an MCP sign-in check the role on every request. An API key can only be created by an admin, and its scopes are the grant. - **Both `streams:write` and `actions:write`** on the API key or the MCP connection. `streams:write` alone is refused with `403 STREAM_SEND_SCOPE_REQUIRED`: a credential allowed to tag contacts was never allowed to email them. - **Manage streams** switched on, for a call that comes through the MCP server. - **Resend connected, and its Send email toggle on** (`resend_send_email`, under MCP Tools → Resend). This binds every caller, the dashboard included, and it is read again before every email the automation sends. - **A Resend API key with full access.** Pathbound has to read your templates, and a key with sending access only cannot. They are checked when an automation is created, when its template or a limit is changed, when it is resumed and for a test email. Pausing needs only what any stream change needs. `GET /v1/streams/options` lists `destinations.resend_send_email` once the workspace has email automations, with `creatable` answered for the credential that asks and a `reason` when it is `false`. The same response then lists the published templates of your Resend account (`option_sources["resend.templates"]`: the `id`, `name` and `alias` of the first 100) and the limits (`limits.email_automations`). The template list is filled only for a credential that could create an email automation, and it is empty when Resend could not be asked; a template can still be named by its id or alias. The forms that have received submissions (`option_sources["forms.ids"]`) are listed only for a credential that also holds `events:read`. An admin can also create, change, pause and test an email automation on the Streaming page in the dashboard. ### The template The Forms page says how to [prepare the template in Resend](/docs/api/forms#prepare-the-template-in-resend). A template can be used only if, in Resend, all of these hold: - it is **published**; - it has a **subject**; - it has a **from** address on a domain verified for sending in that Resend account; - **every variable it declares has a fallback value**. Pathbound sends the template with no sender and no subject of its own, which is why the template has to carry both. A template that fails one of these is refused with `422 STREAM_EMAIL_TEMPLATE_NOT_USABLE` when an automation is created, changed, resumed or tested. `details.problem` is one of `not_published`, `no_subject`, `no_sender`, `sender_not_verified` and `variables_without_fallback` (`details.variables` then lists them), and the message says what to change in Resend. A template your Resend account does not have is a `422 STREAM_EMAIL_TEMPLATE_NOT_FOUND`. - **What Pathbound reads of a template:** whether it exists and is published, its name, its sender, its subject, its reply-to addresses and the names of its variables. Never its HTML or its text. Pathbound cannot show you the email; the [test email](#test-email) can. - **The one variable Pathbound fills is `first_name`:** this exact key, declared as a string in the template. In Resend's editor it is written `{{{first_name}}}`. It is filled with the contact's first name when the contact's own email address is the one being emailed, and otherwise with the `firstname` sent in that submission. It can be text a stranger typed into your form, and it goes into an email sent from your domain, so it is used only when it is a single word of letters, hyphens and apostrophes, at most 25 characters. Otherwise Pathbound gives the variable no value, and the fallback value you set in Resend is sent. No other variable is ever filled, which is why each one needs a fallback. - **No part of the email can be set through Pathbound.** `from`, `reply_to`, `subject`, `body`, `text`, `html`, `cc`, `bcc` or `variables` in `destination_config` is refused with `400 STREAM_CONFIG_FIELD_UNKNOWN` and a message that says it is set on the template in Resend. It is never silently ignored. ### When the template changes in Resend Pathbound reads the template again before every email, and remembers the answer for one minute. - **Its content, subject or sender is changed in Resend.** Emails sent from then on are the template as Resend holds it. Pathbound notifies nobody about a change made in Resend. - **It is unpublished or deleted, a variable is added without a fallback, or its sender's domain is no longer verified.** Emails wait: their [runs](#runs) stay `pending` with the reason `template_not_usable`, for up to a day after each submission. The workspace's admins are told, and the emails are sent once the template is fixed in Resend. Nothing has to be changed in Pathbound. Meanwhile `GET /v1/streams/:id` returns `template.usable: false` with a `problem` that says what to change. - **A form should send a different template.** Change the automation's `template_id`: see [Change](#change). ### Create `POST /v1/streams` with `destination_type: "resend_send_email"`. Send no `event_type`, no `resource_type` and no `conditions`. ```json { "destination_type": "resend_send_email", "name": "Playbook email", "active": true, "validate_only": false, "destination_config": { "form_id": "playbook", "template_id": "playbook-email", "daily_cap": 50, "min_interval_hours": 24 } } ``` | Field | Required | Description | | --- | --- | --- | | `destination_config.form_id` | yes | The form's id: the last part of the URL the form posts to. It cannot be changed afterwards. The form does not have to have received a submission yet. | | `destination_config.template_id` | yes | The id or the alias of a published template in your Resend account: letters, digits, `_` and `-`, up to 128 characters. See [The template](#the-template). | | `destination_config.daily_cap` | no | The most emails this automation sends per UTC day. 1 to 200, default 50. | | `destination_config.min_interval_hours` | no | Hours before the same address is emailed again from this form. 1 to 720, default 24. | | `name` | no | A label, up to 100 characters. | | `active` | no | Whether it starts running. Default `true`. | | `validate_only` | no | Run every check and create nothing. | `conditions`, `entity_filters` or `url_filters` are refused with `400 STREAM_FIELD_UNSUPPORTED`. They are never silently dropped: an automation saved without the condition you asked for would email everyone. A `resource_type` other than `event`, or an `event_type` other than `form_submission`, is a `400 STREAM_DESTINATION_UNSUPPORTED`. **Response — `201 Created`** ```json { "status": "success", "stream": { "_id": "6703f1c2a4b5c6d7e8f90123", "destination_type": "resend_send_email", "…": "…" }, "form": { "form_id": "playbook", "submissions_last_30_days": 12 } } ``` `stream` is [the stream object](#the-stream-object), with `template`: the sender and the subject the automation will send, as Resend holds them. `form.submissions_last_30_days` tells you whether the form id is one that receives submissions: `0` usually means the id is misspelled or the form is not wired yet. A second automation for the same form is refused with `409 STREAM_DESTINATION_TAKEN`; `details.existing_stream_id` names the first. Change that one instead. **With `validate_only: true` — `200 OK`** ```json { "status": "success", "validate_only": true, "would_create": true, "stream": { "name": "Playbook email", "resource_type": "event", "event_type": "form_submission", "destination_type": "resend_send_email", "destination_label": "Form playbook", "destination_config": { "form_id": "playbook", "template_id": "34a080c9-b17d-4187-ad80-5af20266e535", "template_name": "Playbook email", "daily_cap": 50, "min_interval_hours": 24 }, "template": { "id": "34a080c9-b17d-4187-ad80-5af20266e535", "name": "Playbook email", "from": "Acme Team ", "subject": "Your playbook", "reply_to": [], "variables": ["first_name"], "fills": ["first_name"], "usable": true }, "active": true }, "form": { "form_id": "playbook", "submissions_last_30_days": 12 } } ``` Every check of a real create has run, the read of the template included. `stream` has no `_id`. Nothing is written and nobody is notified. Use it to see which sender and subject an automation would send before you create it. ### The stream object ```json { "_id": "6703f1c2a4b5c6d7e8f90123", "tenant_id": "tenant_xyz", "name": "Playbook email", "resource_type": "event", "event_type": "form_submission", "destination_type": "resend_send_email", "destination_label": "Form playbook", "destination_config": { "form_id": "playbook", "template_id": "34a080c9-b17d-4187-ad80-5af20266e535", "template_name": "Playbook email", "daily_cap": 50, "min_interval_hours": 24 }, "template": { "id": "34a080c9-b17d-4187-ad80-5af20266e535", "name": "Playbook email", "from": "Acme Team ", "subject": "Your playbook", "reply_to": [], "variables": ["first_name"], "fills": ["first_name"], "usable": true }, "active": true, "active_since": "2026-10-07T09:00:00.000Z", "created_by": "abc123", "createdAt": "2026-10-07T09:00:00.000Z", "updatedAt": "2026-10-07T09:00:00.000Z", "has_headers": false, "has_secret": false, "sent_count": 3, "sent_today": 1, "failed_count": 0, "skipped_count": 2, "unconfirmed_count": 0, "waiting_count": 0, "last_sent_at": "2026-10-07T09:14:05.000Z" } ``` - `destination_config.template_id` is always Resend's id of the template, also when you named the template by its alias. `template_name` is the template's name in Resend when the automation was last saved. The server sets it; sending it is refused. - `template` is the template as Resend holds it when you ask. It is not stored. `GET /v1/streams/:id`, a create, a `validate_only` and a change of the config or a resume return it. `GET /v1/streams` (the list) does not. - `variables` are the variables the template declares. `fills` are those Pathbound gives a value to: `first_name`, or none. - When the template can no longer be sent, `usable` is `false` and `problem` is a sentence that says what to change in Resend. For a template that was deleted, only `id`, `name`, `usable` and `problem` are returned. - When Resend could not be asked, it is `{ "id": "…", "name": "…", "unavailable": true }`. - `active_since` is set when the automation is created and again at every resume. Nothing submitted before it is emailed. - `blocked_since` is present only while Resend refuses the key or the sender. See [What is emailed, and what is not](#what-is-emailed-and-what-is-not). - The counters count [runs](#runs) by status, and `sent_today` counts from 00:00 UTC. `GET /v1/streams` and `GET /v1/streams/:id` return them. There is no `enrolled_count` for this type. ### Change `PATCH /v1/streams/:id`. For this type the body may hold only `name`, `active` and `destination_config`. ```json { "destination_config": { "template_id": "playbook-email-v2", "daily_cap": 100 } } ``` - `destination_config` may hold any of `template_id`, `daily_cap` and `min_interval_hours`. What you send is merged over the saved config, and the template is read from Resend again and checked as it is at that moment, whichever field changes. - **The form cannot change.** Create another automation for another form. Over this API, `destination_config.form_id`, any key inside `destination_config` that is not in the list above (`template_name` and every part of an email included), and any other top-level field (`conditions`, `event_type`, `resource_type`, filters) are refused with `403 STREAM_FIELD_FORBIDDEN`. `validate_only` is refused with `400 STREAM_FIELD_UNSUPPORTED`. - `name` is one line of up to 100 characters (`400 STREAM_NAME_INVALID` otherwise). - A credential without `actions:write` that sends `destination_config` is refused with `403 STREAM_SEND_SCOPE_REQUIRED` before the template it named is looked up. - **Pause** with `{ "active": false }`. Emails that have not started are cancelled. Pausing is never limited, and it needs only what any stream change needs. - **Resume** with `{ "active": true }`. It goes through the same checks as a create, the check of the template included. Nothing submitted while the automation was paused is emailed, however short the pause. - A new `template_id` applies to every email that has not been attempted yet, including one that is waiting. An email that was already attempted and is being retried is sent with the template it was first prepared with. The response is `{ "status": "success", "stream": { … } }`. `stream.template` is present when the call changed the config or resumed the automation. `DELETE /v1/streams/:id` removes the automation and the emails it had queued. It does not reset who was emailed: an automation created again for the same form does not email an address before its interval has passed. ### Test email `POST /v1/streams/:id/test-email`, with no body. It sends the automation's template once, to the address of the Pathbound account behind the credential: the account that created the API key, or the person signed in through MCP. There is no recipient parameter, so a test can never be pointed at someone else. Any field in the body is refused with `422 STREAM_TEST_TAKES_NO_INPUT`. ```json { "status": "success", "sent_to": "you@example.com", "sent_at": "2026-10-07T09:05:00.000Z", "email_id": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c", "template_id": "34a080c9-b17d-4187-ad80-5af20266e535", "template_name": "Playbook email", "subject": "Your playbook" } ``` `subject` is the template's subject as Resend holds it, with its variables not filled in. `first_name` is filled from that account's first name when the template declares it. The checks are the same as for a create, the check of the template included. A workspace can send 5 test emails a minute. A test counts against the workspace's 500 emails a day, and not against the automation's `daily_cap`. The test email is the only way to see the email from Pathbound, which never reads a template's content. If Resend does not answer, the call ends with `502 SEND_UNCONFIRMED`: the email may have been sent, so look in the inbox before you send another. ### What is emailed, and what is not - **One email per submission**, a few seconds after it arrives. - **At most one email per address per `min_interval_hours` per form** (24 hours unless you change it), even if the person submits again, and even if the automation is deleted and created again. `ada+news@example.com` counts as `ada@example.com`, and for Gmail addresses the dots are ignored. - **Only the submitted address is emailed**, and only when it is one plain email address. Pathbound adds no other recipient. - **An address that Resend lists as unsubscribed is not emailed.** - **Nothing submitted before the automation was created, or while it was paused, is emailed.** Resuming does not send what arrived during the pause. - **A submission that arrives after a limit is reached is not emailed later**, not the next day either. See [Limits](#limits). - **A submission older than 24 hours is never emailed.** An email that cannot be sent is tried again after 30 seconds, 2 minutes, 10 minutes, 30 minutes, 1 hour, 2 hours, 4 hours and 8 hours, for up to 24 hours after the submission, and then given up on. - **At most once.** When Resend does not answer, nobody knows whether the email went out. It is tried again in a way that Resend recognizes as the same email, and if no attempt is confirmed, the run ends `unconfirmed` and is not sent again. - **If Resend refuses your API key or the sender** (a 401 or a 403), the automation is *blocked*. Its emails wait, for up to a day, instead of failing; the admins are told; Resend is asked again at least every 30 minutes; and the automation carries on by itself once the key or the domain is fixed. - **Emails also wait**, for up to a day, while Resend is disconnected or connected again with **Send email** not yet set, and while the template cannot be sent as it is: see [When the template changes in Resend](#when-the-template-changes-in-resend). Both are checked again before every email. - **If an admin switches Send email off**, nothing is sent while it is off, and emails that were waiting are skipped for good. - **A workspace without an active plan sends nothing.** Each email that is sent is recorded on the contact's timeline and appears among the emails sent to that contact. ### Limits | Limit | Value | | --- | --- | | Emails per automation per UTC day (`daily_cap`) | 50 by default, 200 at most | | Emails per workspace per UTC day, all email automations together, test emails included | 500 | | Emails per UTC day to one recipient domain that is not a free-mail provider such as gmail.com | 10 | | Hours before one address is emailed again from one form (`min_interval_hours`) | 24 by default, 1 to 720 | | Email automations per workspace | 20 | | Creates, changes of the template or a limit and resumes per workspace per day, over the API and through MCP together | 20 | | Test emails per workspace | 5 a minute, 20 per UTC day | The per-domain limit is there because a company's mail server may accept any invented address at its domain. The limit of 20 changes a day does not count a refused request or a `validate_only` call. Pausing is never counted, and changes made in the dashboard are outside it, so an admin can always fix and resume an automation. Past the limit, the call is refused with `429 STREAM_AGENT_CHANGE_LIMIT`. A submission that is over a daily limit gets a run with status `skipped`. It is not queued for the next day. ### Runs `GET /v1/streams/:id/deliveries?page=1&limit=20` returns an email automation's runs, newest first. A run is one submission and what became of its email. ```json { "status": "success", "deliveries": [ { "id": "6703f2a1a4b5c6d7e8f90aaa", "status": "sent", "contact_id": "6703f1c2a4b5c6d7e8f90456", "submission_id": "6703f29fa4b5c6d7e8f90999", "submitted_at": "2026-10-07T09:14:02.000Z", "attempts": 1, "sent_at": "2026-10-07T09:14:05.000Z", "finished_at": "2026-10-07T09:14:05.000Z" }, { "id": "6703f2b0a4b5c6d7e8f90bbb", "status": "skipped", "reason": "cooldown", "contact_id": "6703f1c2a4b5c6d7e8f90456", "submission_id": "6703f2aea4b5c6d7e8f909aa", "submitted_at": "2026-10-07T09:20:41.000Z", "attempts": 0, "finished_at": "2026-10-07T09:20:44.000Z" } ], "pagination": { "page": 1, "limit": 20, "total_items": 2, "total_pages": 1 } } ``` `next_attempt_at` is present while a run is `pending` or `sending`. A run never returns the address or anything Resend said, and over the API it does not return Resend's id of the email either: the dashboard shows that. Skipped runs are kept for 35 days. | Status | Meaning | | --- | --- | | `pending` | Waiting to be sent, to be retried, or for something to be fixed. `reason` says which. | | `sending` | Being sent now. | | `sent` | Resend accepted the email. | | `skipped` | It was decided not to send. Nothing was sent. | | `failed` | Resend refused the email, or 24 hours passed. Nothing was sent. | | `unconfirmed` | It may have been sent. It is never sent again. | | Reason | Status | Meaning | | --- | --- | --- | | `paused` | `skipped` | The automation was paused, or the submission arrived before it was created or last resumed. | | `deleted` | `skipped` | The automation was deleted. | | `disabled` | `skipped` | An admin switched Resend's **Send email** off. | | `no_plan` | `skipped` | The workspace has no active plan. | | `stale` | `skipped` | The submission was more than 24 hours old, or is gone. | | `invalid_recipient` | `skipped` | The submitted address is not one plain email address, or the contact no longer exists. | | `unsubscribed` | `skipped` | Resend lists the address as unsubscribed. | | `cooldown` | `skipped` | The address was emailed from this form within `min_interval_hours`. | | `daily_cap` | `skipped` | The automation's `daily_cap` or the workspace's daily total was reached. | | `source_limit` | `skipped` | The recipient's domain reached its daily limit. | | `provider_rejected` | `failed` | Resend refused this email. | | `window_elapsed` | `failed` | It could not be sent within 24 hours. | | `outcome_unknown` | `unconfirmed` | Resend did not answer, and no later attempt confirmed the send. | | `idempotency_conflict` | `unconfirmed` | Resend holds this send for a different email. | | `key_changed` | `unconfirmed` | The workspace's Resend API key changed after an attempt that got no answer. | | `retrying` | `pending` | Resend gave an answer that allows another attempt. | | `resend_not_connected` | `pending` | Resend is not connected, or its key no longer works. | | `resend_refused` | `pending` | Resend refuses the key or the sender. The automation is blocked until that is fixed. | | `template_not_usable` | `pending` | The template was unpublished or deleted, or can no longer be sent as it is. See [When the template changes in Resend](#when-the-template-changes-in-resend). | | `send_toggle_unset` | `pending` | Resend was connected again and **Send email** has not been set since. | | `not_available` | `pending` | Email automations are not turned on for the workspace at the moment. | ### What admins are told Every create, every change of the template or a limit and every resume is shown to **all verified admins** of the workspace in the app's notifications. Pathbound sends them no email about it. The notice says who made the change and has a link that opens the automation, where the form and the template are shown and it can be paused. Nobody approves an automation before it runs, so this notice is the review. If the notice cannot be recorded, nothing is changed and the call is answered with `503 STREAM_NOTICE_FAILED`. Admins are also told when an email could not be sent, when the automation is blocked, when Resend is not connected, when the template cannot be sent as it is, when a daily limit is reached (the automation's, the workspace's, or one recipient domain's) and when the workspace has no plan. Each of these is shown at most once per automation per day, in the app's notifications and not by email, so a problem is seen when an admin opens the app. They are not told when the content of a template is changed in Resend. ### Metering The emails of an email automation do not count against your monthly meter. Anyone on the internet can post to a public form, so metering them would let a stranger spend your monthly cap. In return, a workspace without an active plan sends none. ### Error codes Every error below has the shape `{ "status": "error", "error": "…", "code": "…", "details": { … } }`. `details` is present only for the codes that name it. A request that fails basic shape checks first (a malformed stream id, an `active` that is not a boolean) is a `400` with `"error": "Validation failed"` and no `code`. | HTTP | `code` | Meaning | | --- | --- | --- | | 403 | `STREAM_ADMIN_REQUIRED` | The caller is not a workspace admin. | | 403 | `STREAM_SCOPE_REQUIRED` | The API key has no `streams:write`. | | 403 | `STREAM_SEND_SCOPE_REQUIRED` | The credential has no `actions:write` (`required_scopes`, also under `details`). | | 412 | `STREAM_TOOLS_DISABLED` | The call came through MCP and **Manage streams** is off. | | 412 | `STREAM_SEND_NOT_AVAILABLE` | The workspace does not have email automations yet. | | 412 | `STREAM_SEND_RESEND_NOT_CONNECTED` | Resend is not connected, or its key no longer works. | | 412 | `STREAM_SEND_DISABLED` | Resend's **Send email** toggle is off or was never set. | | 400 | `STREAM_FIELD_UNSUPPORTED` | `conditions`, `entity_filters` or `url_filters` on create, or `validate_only` on PATCH (`details.fields`). | | 400 | `STREAM_DESTINATION_UNSUPPORTED` | A `resource_type` other than `event`, or an `event_type` other than `form_submission`. | | 400 | `STREAM_CONFIG_FIELD_UNKNOWN` | An unknown key in `destination_config` on create (`details.fields`). `from`, `reply_to`, `subject`, `body`, `text`, `html`, `cc`, `bcc` and `variables` get a message that says they are set on the template in Resend. | | 400 | `STREAM_FORM_ID_INVALID` | `form_id` is missing or does not fit the form id rule. | | 400 | `STREAM_EMAIL_TEMPLATE_REQUIRED` | `template_id` is missing, or is not one token of letters, digits, `_` and `-` of up to 128 characters. | | 422 | `STREAM_EMAIL_TEMPLATE_NOT_FOUND` | Your Resend account has no template with this id or alias (`details.field`). | | 422 | `STREAM_EMAIL_TEMPLATE_NOT_USABLE` | The template cannot be sent as it is. `details.problem` is `not_published`, `no_subject`, `no_sender`, `sender_not_verified` or `variables_without_fallback` (then `details.variables` lists them). The message says what to change in Resend. | | 503 | `STREAM_EMAIL_TEMPLATE_UNREADABLE` | Resend could not be asked about the template just now. Nothing was changed; try again. | | 412 | `STREAM_EMAIL_TEMPLATE_UNREADABLE` | Resend refuses to show templates to the connected API key. The key must have full access. | | 422 | `STREAM_EMAIL_LIMIT_INVALID` | `daily_cap` or `min_interval_hours` is outside its range (`details.field`). | | 400 | `STREAM_NAME_INVALID` | `name` is not one line of at most 100 characters, or holds a control character. | | 400 | `STREAM_ACTIVE_INVALID` | `active` is not true or false. | | 403 | `STREAM_FIELD_FORBIDDEN` | A PATCH with a field, or a key in `destination_config`, that may not be changed over the API. This includes `form_id` and every part of an email. | | 400 | `STREAM_DESTINATION_IMMUTABLE` | A change of `form_id` or `destination_type` made in the dashboard. Over the API the same attempt is a `403 STREAM_FIELD_FORBIDDEN`. | | 409 | `STREAM_DESTINATION_TAKEN` | The form already has an email automation or a [Resend event automation](#resend-event-automations) (`details.existing_stream_id`). | | 429 | `QUOTA_EXCEEDED` | The workspace has 20 email and Resend event automations together, or has reached its stream limit. | | 429 | `STREAM_AGENT_CHANGE_LIMIT` | The 20 changes a day over the API and MCP are used. | | 503 | `STREAM_NOTICE_FAILED` | The admins could not be notified. Nothing was changed; try again. | | 422 | `STREAM_TEST_TAKES_NO_INPUT` | Test email: the request had a body. | | 422 | `STREAM_TEST_RECIPIENT_INVALID` | Test email: the credential's account has no usable address. | | 412 | `STREAM_SEND_RESEND_REFUSED` | Test email: Resend refused the key or the template's sender. Nothing was sent. | | 422 | `STREAM_SEND_REJECTED` | Test email: Resend refused this email (`details.provider_message`). Nothing was sent. | | 429 | `STREAM_SEND_DAILY_LIMIT` | Test email: the workspace's 500 emails for the day are used. | | 429 | `STREAM_TEST_DAILY_LIMIT` | Test email: the workspace's 20 test emails for the day are used. | | 429 | `STREAM_SEND_THROTTLED` | Test email: Resend is limiting the account. Nothing was sent; try again in a minute. | | 502 | `SEND_UNCONFIRMED` | Test email: no answer from Resend. It may have been sent. | ## Resend event automations A Resend event automation sends one of your own Resend events for each person who submits one form on your site, a few seconds after the submission. The event starts the automations you built on it in [Resend](/docs/api/resend#list-events): a welcome series, a segment change, whatever you set up there. It is a stream with `destination_type: "resend_send_event"`, attached to one form id and one event name. It is an [email automation](#email-automations) with an event in the template's place. The trigger, the daily limits, the runs, the notice to admins, pausing and metering are the same, and so is who may set one up, with one difference: the toggle is Resend's **Send events** (`resend_send_event`), not **Send email**. This section covers only what differs. **Every submission sends the event.** There is no wait per address: when the same person submits the form again, the event is sent again, and what a repeat should do is decided by your automation in Resend. The daily limits still apply. **Pathbound cannot see what an event sets off.** One event starts every active automation built on it, and an automation may send several emails over days. A run that reads `sent` means Resend accepted the event, not that an email was sent. `daily_cap` limits events, not the emails your automations then send. **A form has one automation that sends**: an email automation or a Resend event automation, not both. A second one of either kind is a `409 STREAM_DESTINATION_TAKEN`. An admin sees a Resend event automation on the Streaming page in the dashboard, with its runs, and can pause, resume or delete it there. It is created and changed over the API or by an agent. ### Create an event automation `POST /v1/streams` with `destination_type: "resend_send_event"`. Send no `event_type`, no `resource_type` and no `conditions`. ```json { "destination_type": "resend_send_event", "name": "Demo requests", "destination_config": { "form_id": "demo", "event": "demo.requested", "daily_cap": 50 } } ``` | Field | Required | Meaning | | --- | --- | --- | | `destination_config.form_id` | yes | The form whose submissions trigger it. It cannot be changed afterwards. A form needs no setup: see [Forms](/docs/api/forms). | | `destination_config.event` | yes | The exact name of an event your Resend account defines, capitals included. `GET /v1/streams/options` lists them under `option_sources["resend.events"]`. Events are created in Resend, and Resend's own events (`resend:…`) cannot be named. | | `destination_config.daily_cap` | no | The most events this automation sends per UTC day, 1 to 200. Default 50. A submission that arrives after it is reached gets no event later. | `destination_config` takes nothing else. There is no recipient, no payload, no template and no `min_interval_hours` to set: a key such as `email`, `payload` or `template_id` is a `400 STREAM_CONFIG_FIELD_UNKNOWN`. The workspace's total of 500 a day and the limit of 10 a day per recipient domain (free mail excepted) apply as for an email automation. `"validate_only": true` checks the request, including the event's name against your Resend account, without creating anything or notifying anyone. ### What is sent - **To whom.** The address the person submitted, as one plain address. Resend creates a contact for an address it does not know. A person Resend lists as unsubscribed is sent no event. - **The payload.** One field, `first_name`, and only when the event declares `first_name` as a string in Resend and the person gave a first name of one word. Nothing else typed into the form travels with the event. - **Once per submission.** One event for each submission, and another for each further submission by the same person. ### An event is never sent twice for one submission Resend does not deduplicate events: a second request starts every automation again. So an event Resend did not clearly answer (a timeout, a server error) is **not** retried, unlike an email. Its run ends `unconfirmed` with reason `outcome_unknown`, and the admins are told. Check the automation's runs in Resend to see whether it started. An event Resend itself refused was not accepted. A rate limit is retried for up to a day, a refusal of your API key makes runs wait until it is fixed, and any other refusal ends that one run as `failed`. ### When the event changes in Resend The event is looked up in your Resend account before every send, by exact name. If it was deleted or renamed, runs wait with reason `event_not_found` for up to a day after each submission, the admins are told, and they go out once an event of that name exists again or the automation is changed to another event. ### Change an event automation `PATCH /v1/streams/:id`. `destination_config` may hold `event` and `daily_cap`; what you send is merged over the saved config, and the event is looked up in Resend again. `name` and `active` work as for an email automation. `form_id` cannot change. There is no test send: `POST /v1/streams/:id/test-email` answers `400 STREAM_DESTINATION_UNSUPPORTED`, because a test event would start your real automations. Submit the form once to try it. ### Error codes of an event automation The [error codes of an email automation](#error-codes) apply, with `STREAM_SEND_DISABLED` meaning Resend's **Send events** toggle. A Resend event automation adds these: | HTTP | `code` | Meaning | | --- | --- | --- | | 400 | `STREAM_EVENT_REQUIRED` | `event` is missing, is not a usable name, or is one of Resend's own events. | | 422 | `STREAM_EVENT_NOT_FOUND` | Your Resend account defines no event of this exact name (`details.field`). | | 503 | `STREAM_EVENT_LIST_UNREADABLE` | Resend could not be asked for its events just now. Nothing was changed; try again. | | 412 | `STREAM_EVENT_LIST_UNREADABLE` | Resend refuses to list events for the connected API key. The key must have full access. | ## Best practices - **Treat the destination URL as public.** The signature is your only authenticator — verify it on every request. - **Acknowledge fast, process async.** Return `200 OK`, then do the work in a background job. - **Be idempotent.** Use `id` as a dedup key. - **Inspect deliveries** in the dashboard — response code, latency, body, and attempt count per subscription. --- # Forms > Post a form on your own site to Pathbound's public form endpoint, list the forms that have received submissions, and send a Resend template to everyone who submits one. Source: https://pathbound.ai/docs/api/forms A form on your own site can post straight to Pathbound. Each submission creates or updates a contact (and a company, if you send one), is stored, and appears on the contact's timeline. You can then have Pathbound email everyone who submits a given form, with a template from your own Resend account. A form needs no registration. You choose an id, put it in the URL the form posts to, and the form exists from its first submission. This page covers: - the public endpoint a form posts to, which takes no API key; - `GET /v1/forms`, which returns where to post and which forms have received submissions; - how to [email everyone who submits a form](#email-everyone-who-submits-a-form). ## Submit a Form ``` POST /v1/public/form-submission/:tenant_id/:form_id ``` This endpoint takes no API key, because a browser calls it. Instead, the request must come from a page on one of your workspace's verified domains. See [Where the request may come from](#where-the-request-may-come-from). Your form's own code calls this endpoint. The [events snippet](/docs/events/overview) does not: its `form_submit` event only records that a form was submitted on a page, without the field values. See [`form_submit` and `form_submission`](/docs/events/overview#form_submit-and-form_submission). ### Path Parameters | Parameter | Type | Description | | ----------- | ------ | ----------- | | `tenant_id` | string | Your workspace's tenant ID. [`GET /v1/forms`](#list-forms) returns the full URL with it filled in. It is also `user.tenant_id` in [`GET /v1/auth/status`](/docs/getting-started/authentication). | | `form_id` | string | An id you choose for the form: 1 to 64 letters, digits, `_`, `.`, `:` or `-`, starting with a letter or a digit. For example `playbook` or `demo-request`. | Use an id that fits that rule. The endpoint still accepts any other non-empty id and stores the contact, but such a submission writes no `form_submission` event, the form is not listed by `GET /v1/forms`, and it cannot have an email automation. ### Request Body Send JSON (`Content-Type: application/json`). | Field | Type | Required | Description | | -------------- | ------ | -------- | ----------- | | `contact_data` | object | one of the two | The person. `email` is required when this object is sent. Every other key (`firstname`, `lastname`, `jobtitle`, `phone`, and so on) is stored as a contact property. | | `company_data` | object | one of the two | The company. `domain` is required when this object is sent. Every other key is stored as a company property. | | `visitor_id` | string | no | The value of the `pathbound_visitor_id` cookie that the [events snippet](/docs/events/identity) sets. It ties the person's earlier page views to the contact. | At least one of `contact_data` and `company_data` must be present. `lifecyclestage` (in either object, if set) must be one of: `lead`, `prospect`, `mql`, `sql`, `opportunity`, `customer`, `evangelist`, `subscriber`, `other`. An [email automation](#email-everyone-who-submits-a-form) emails the address in `contact_data.email`, so a submission without `contact_data` emails nobody. It reads `contact_data.firstname` for the template's `first_name` variable. ### Where the request may come from The request's `Origin` header must name a host on one of your workspace's verified domains: the domain itself or any subdomain of it. When there is no `Origin` header, `Referer` is read instead. A browser sets these headers itself, so a form on your site needs nothing extra. You verify domains under **Settings → Domains** in the dashboard. A request is refused with `403` when it comes from any other site, from a host listed under your [excluded hostnames](/docs/events/install#excluded-hostnames), or with neither header. A call from your own server usually has neither. :::note This check keeps pages on other sites from posting into your workspace. It is not a secret: a script that runs outside a browser can set the header to any value. That is why an email automation has its own limits on how many emails it sends and to whom. ::: The endpoint answers CORS preflight requests, so a `fetch()` with a JSON body works from the browser. ### Example: `fetch()` ```js const cookie = document.cookie.match(/(?:^|; )pathbound_visitor_id=([^;]*)/); await fetch('https://api.pathbound.ai/v1/public/form-submission/YOUR_TENANT_ID/playbook', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ contact_data: { email: 'ada@example.com', firstname: 'Ada' }, company_data: { domain: 'example.com' }, visitor_id: cookie ? decodeURIComponent(cookie[1]) : undefined, }), }); ``` ### Example: an HTML form ```html
``` After wiring a form, submit it once from the live page and open **Forms** in the dashboard, or call [`GET /v1/forms`](#list-forms): the form id appears with `submissions_last_30_days` of at least 1. If it does not, the page's domain is probably not verified. ### Response — `200 OK` ```json { "status": "success", "message": "Thank you for your submission" } ``` The endpoint never returns the contact or any id, so an anonymous caller learns nothing about your workspace. ### Error Codes | Status | Meaning | | ------ | ------- | | `400` | The body is not usable: neither `contact_data` nor `company_data`, a `contact_data` without a valid `email`, a `company_data` without `domain`, or an unknown `lifecyclestage`. | | `403` | The `Origin` or `Referer` is missing, is not on a verified domain of this workspace, or is an excluded hostname. | | `429` | A rate limit was reached. | | `500` | The submission could not be processed. | An error body carries `"status": "error"` and a short, general text (`message`, or `error` for a `429`). It does not say which field was wrong. ### Rate Limits 30 submissions a minute per address a request arrives from, and 100 a minute per workspace. A request over either limit is answered with `429`. ## What a Submission Creates One accepted submission does four things: 1. **A company**, when `company_data` is sent. It is found by `domain` or created, and the other keys are merged into its properties. 2. **A contact**, when `contact_data` is sent. It is found by `email` or created, and the other keys are merged into its properties. The contact is linked to the company from `company_data`. Without one, a contact that has no company yet is linked to an existing company whose domain matches its email domain (never for a free-mail address). When `visitor_id` is sent, that visitor is tied to the contact, so the page views recorded before the submission belong to the contact's timeline. 3. **A stored submission**: what was submitted, with the host of the page it came from, the `Referer`, the user agent and the address the request arrived from. This is what `GET /v1/forms` counts, and it is what triggers an email automation. The dashboard's **Forms** page lists each form's submissions with what was submitted and the host of the page; it never shows the `Referer`, the user agent or the address. 4. **A [`form_submission` event](/docs/events/types#form_submission)** on the contact's timeline. It carries the form id and the ids of the records above, never the submitted values. It is delivered to [HTTPS event streams](/docs/api/webhooks) like any other event. The contact and company changes also reach your contact and company [streams](/docs/api/webhooks), with `source: "form"`. ## List Forms ``` GET /v1/forms ``` Returns where a form on your site has to post, and the forms that have received submissions. Requires `events:read`. ### Response — `200 OK` ```json { "status": "success", "endpoint": { "method": "POST", "url": "https://api.pathbound.ai/v1/public/form-submission/tenant_xyz/{form_id}", "form_id": "Any id you choose for the form, in the URL: 1 to 64 letters, digits, …", "content_type": "application/json", "body": { "contact_data": "Object. `email` is required; any other key …", "company_data": "Optional object. `domain` is required when it is sent; …", "visitor_id": "Optional. The Pathbound events snippet's visitor id, …" }, "allowed_from": "A browser page on one of the workspace's verified domains …", "response": "200 {\"status\":\"success\"} when stored. It never returns the contact or any id.", "rate_limits": "30 submissions a minute per address a request arrives from and 100 a minute per workspace." }, "verified_domains": ["example.com"], "example": "fetch(\"https://api.pathbound.ai/v1/public/form-submission/tenant_xyz/playbook\", {\n method: \"POST\", …\n});", "check": "After wiring the form, submit it once from the live page, then call this again: …", "docs": "https://pathbound.ai/docs/api/forms", "forms": [ { "form_id": "playbook", "submissions_last_30_days": 12, "last_submitted_at": "2026-10-07T09:14:02.000Z", "email_automation_id": "6703f1c2a4b5c6d7e8f90123" } ], "timestamp": "2026-10-07T12:00:00.000Z" } ``` | Field | Description | | ----- | ----------- | | `endpoint.url` | The URL to post to, with your tenant ID filled in. Replace `{form_id}` with the form's id. | | `endpoint.*` | The same rules as [Submit a Form](#submit-a-form), written as text for an AI agent to read. | | `verified_domains` | The domains a form may post from. | | `example` | A `fetch()` call for a form with the id `playbook`. | | `forms` | Forms that have received submissions. Only ids that fit the [form id rule](#path-parameters) are listed: every form that has an email automation first, then up to 50 others, busiest first. | | `forms[].submissions_last_30_days` | Submissions stored in the last 30 days. A form that has an email automation and no recent submissions is listed with `0`. On a workspace with a very large number of submissions the counts can be up to a minute old. | | `forms[].last_submitted_at` | When the most recent of those arrived. | | `forms[].email_automation_id` | The stream id of the form's email automation, when it has one. | | `forms[].event_automation_id` | The stream id of the form's [Resend event automation](/docs/api/webhooks#resend-event-automations), when it has one. A form has one or the other. | The MCP tool [`get_form_setup`](/docs/mcp/tools#streaming-and-email-automations) returns the same thing to an AI agent. ## Email everyone who submits a form An **email automation** sends one email to each person who submits one form, through your own [Resend](/docs/api/resend) account, a few seconds after the submission. The email is a template you write and publish in Resend. Pathbound does not hold the email and has no editor for it: an automation only says which template a form sends. You set it up once, over the API or by asking an AI agent connected through the MCP server. Nobody approves each email, so read [what is emailed, and what is not](/docs/api/webhooks#what-is-emailed-and-what-is-not) before you turn one on. End to end: 1. **Wire the form.** Point it at the public endpoint as described above, with at least `contact_data.email`. Submit it once and check that `GET /v1/forms` lists it. 2. **Prepare Resend.** Connect Resend with an API key that has full access, verify a sending domain there, and have a workspace admin turn on **Send email** for Resend under MCP Tools. To set the automation up through an AI agent, also turn on **Manage streams**. 3. **Prepare the template in Resend.** See [below](#prepare-the-template-in-resend). 4. **Create the automation.** It is a stream with `destination_type: "resend_send_email"` attached to the form id and the template. The API key needs both `streams:write` and `actions:write`. ```bash curl -X POST https://api.pathbound.ai/v1/streams \ -H "Authorization: Bearer $PATHBOUND_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "destination_type": "resend_send_email", "name": "Playbook email", "destination_config": { "form_id": "playbook", "template_id": "playbook-email" } }' ``` `template_id` is the template's id in Resend, or its alias. The response shows the template's sender and subject as Resend holds them (`stream.template`), so you can check that it is the email you mean. Add `"validate_only": true` to see that without creating anything. 5. **Send yourself the test email.** `POST /v1/streams/:id/test-email` sends the template once to the address of your own Pathbound account. It is the only way to see the email from Pathbound. ### Prepare the template in Resend 1. **Create a template** in Resend, under **Templates**, and write the email there. 2. **Give it a subject and a from address.** The from address must be on a domain you have verified for sending in that Resend account. Pathbound sends the template with no sender and no subject of its own, so the template has to carry both. 3. **Give every variable a fallback value.** Pathbound fills at most one variable (the next step), so every other one is sent as its fallback. 4. **To greet people by name, add a variable with the key `first_name`,** of type string, with a fallback such as `there`. In Resend's editor it is written `{{{first_name}}}`. Pathbound fills it with the person's first name, and only when that name is a single word of letters, hyphens and apostrophes, at most 25 characters. Anything else is sent as the fallback. 5. **Publish it.** A template that is not published cannot be used. If one of these is missing, creating the automation is refused with `422 STREAM_EMAIL_TEMPLATE_NOT_USABLE` and a message that says what to change in Resend. To change the email later, edit the template in Resend. Emails sent from then on are the template as Resend holds it, and Pathbound notifies nobody about that change. If the template is unpublished or deleted, or a variable is added without a fallback, the automation's emails wait for up to a day, the workspace's admins are told, and the emails are sent once the template is fixed. See [When the template changes in Resend](/docs/api/webhooks#when-the-template-changes-in-resend). ### What happens from then on - Each submission to that form is emailed once. The same address is emailed at most once per 24 hours per form, even if the person submits again. You can change the interval. - The sender, subject, reply-to and content are the template's. None of them can be set through Pathbound, and Pathbound adds no cc and no bcc. - An automation sends at most 50 emails a day unless you set another cap, up to 200. A submission that arrives after a limit is reached is not emailed later. - Nothing submitted before the automation was created, or while it was paused, is emailed. - Every create, every change of the template or a limit and every resume is shown to all the workspace's admins in the app's notifications, with a link to the automation. No email is sent to them. - Sending does not count against your monthly meter. A workspace without an active plan sends nothing. Email automations are being turned on one workspace at a time. Until yours has them, creating one is answered with `412 STREAM_SEND_NOT_AVAILABLE`. If you get that answer and want them, email [support@pathbound.ai](mailto:support@pathbound.ai). The full reference, with every field, limit and error code, is [Email automations](/docs/api/webhooks#email-automations) on the Streaming page. --- # Health & Public Endpoints > Unauthenticated endpoints for health checks, public form submissions, and status monitoring in Pathbound. Source: https://pathbound.ai/docs/api/health These endpoints do **not** require an API key. ## Health Check ``` GET /v1/health ``` Returns a simple status indicating the public API is reachable. This is the endpoint to point uptime monitors at. Operator-facing diagnostics — `/healthz`, `/readyz`, `/health`, `/health/detailed` — live on the backend host root (not under `/v1`) and are intended for deployment monitoring; the gated `/health` and `/health/detailed` require `Authorization: Bearer $API_HEALTH_TOKEN` and disclose runtime details that are not part of the public API contract. ### Response — `200 OK` ```json { "status": "success", "message": "Public API is healthy", "timestamp": "2026-04-29T12:00:00.000Z" } ``` --- ## Public Form Submission ``` POST /v1/public/form-submission/:tenant_id/:form_id ``` Accepts form submissions from your website. Authentication is handled through **domain verification** rather than API keys — your tenant's verified domains are checked against the request's `Origin`/`Referer`. Your form's own code calls this endpoint. The Pathbound [events snippet](/docs/events/install) does not: its `form_submit` event only records that a form was submitted on a page, with none of the field values. The full reference is on the [Forms](/docs/api/forms) page: the form id rule, the request body, a `fetch()` example and an HTML form, what a submission creates, and how to email everyone who submits a form. ### Request Body | Field | Type | Required | Description | | -------------- | ------ | -------- | -------------------------------------------------- | | `visitor_id` | string | no | Visitor tracking ID from the [events snippet](/docs/events/identity). When provided, the submission is linked to the visitor's prior pageviews. | | `contact_data` | object | no | Must include `email` if provided. Other fields optional. | | `company_data` | object | no | Must include `domain` if provided. | At least one of `contact_data` and `company_data` must be present. ### Response — `200 OK` ```json { "status": "success", "message": "Thank you for your submission" } ``` The endpoint deliberately does **not** return internal IDs to avoid leaking tenant state to anonymous callers. ### Error Codes | Status | Meaning | | ------ | ----------------------------------------------- | | `400` | Invalid request body. | | `403` | The `Origin`/`Referer` is missing or is not on a domain verified for this tenant. | | `429` | Rate limit exceeded: 30 submissions a minute per address a request arrives from, or 100 a minute per tenant. | --- # Gmail > Send an email to a contact from your own Gmail mailbox, now or at a set time, and hold drafts in Pathbound until you send them. Source: https://pathbound.ai/docs/api/gmail These endpoints send a one-to-one email to a contact through Gmail, from a mailbox you connected to Pathbound. You can send now, schedule a send up to 30 days ahead, or save a draft and send it later. Every email that goes out is recorded on the contact's timeline. A draft or a scheduled email is held by Pathbound, not by Gmail. It does not appear in Gmail until it is sent, and you review it through this API. Three things have to be in place first: an API key with the `actions:write` scope, a Gmail mailbox connected by the user who created that key, and sending switched on for that mailbox by a workspace admin. [Before you send](#before-you-send) explains each. The MCP tools `gmail_send_email`, `gmail_create_draft`, `gmail_send_draft`, `gmail_list_outbox` and `gmail_cancel_outbox_email` call the same endpoints: see [MCP tools](/docs/mcp/tools#email-actions-gmail). :::caution These endpoints send real email to real people. An email that has been sent cannot be recalled. ::: | Endpoint | Scope | What it does | | -------- | ----- | ------------ | | [`GET /v1/integrations/gmail/senders`](#list-senders) | `actions:write` | Lists your mailboxes and whether each one can send. | | [`POST /v1/contacts/:contact_id/send_email`](#send-an-email) | `actions:write` | Sends an email to a contact now. | | [`POST /v1/integrations/gmail/outbox`](#create-a-draft-or-a-scheduled-email) | `actions:write` | Saves a draft, or schedules an email. | | [`GET /v1/integrations/gmail/outbox`](#list-the-outbox) | `actions:write` | Lists your drafts and your scheduled, sent, failed and canceled emails. | | [`POST /v1/integrations/gmail/outbox/:id/send`](#send-a-draft) | `actions:write` | Sends a draft now, or schedules it. | | [`DELETE /v1/integrations/gmail/outbox/:id`](#cancel-an-outbox-email) | `actions:write` | Discards a draft or cancels a scheduled email. | ## Before you send **The scope.** Every endpoint on this page needs `actions:write`. It is opt-in: a key holds it only when it was selected for that key. See [Scopes](/docs/getting-started/authentication#scopes). **The mailbox is your own.** An API key acts as the user who created it, and sends only from Gmail mailboxes that user connected. A teammate's mailbox is never listed and never usable. Connecting a mailbox takes a workspace admin, under **Integrations → Gmail** in the dashboard, so create the key as an admin who has connected one. An admin can connect a default mailbox and up to five more. **The mailbox can send.** A mailbox sends when all three of these hold: 1. Its connection is valid. 2. Its Google permission includes sending (`gmail.send`). 3. A workspace admin has turned on **Allow agents to send from this mailbox** for it on the Gmail page. The switch is per mailbox and is off until an admin turns it on. The API checks all three on every request that sends, schedules or saves an email, and again when a scheduled email goes out. The switch applies to every caller: an API key is held to it exactly as an AI agent is. Listing your senders, listing the outbox and canceling an email do not need it. ## Which mailbox sends `from` names the mailbox, by its address. It has to be one of your own connected mailboxes. Without `from`, Pathbound chooses in this order: 1. Your default mailbox, if it can send. 2. Otherwise, the only one of your mailboxes that can send. 3. If several can send and none of them is the default, the request is refused with `SENDER_REQUIRED` and you have to pass `from`. If none of your mailboxes can send, the request is refused with the reason your default mailbox cannot. A mailbox you name is used or refused. It is never swapped for another one. The same holds for a draft or a scheduled email: the mailbox is fixed when the email is created, and the email goes out from that mailbox or not at all. ### When a mailbox is refused These answers come from every endpoint that sends, schedules or saves an email. Each carries `available_senders`, the addresses you can send from right now, and a `hint`. | Status | `code` | Meaning | What to do | | ------ | ------ | ------- | ---------- | | `422` | `SENDER_REQUIRED` | No `from` was given, several of your mailboxes can send, and none of them is the default. | Pass `from` as one of `available_senders`. | | `422` | `SENDER_NOT_CONNECTED` | `from` is not a mailbox you connected. A teammate's mailbox gets this answer too. | Use one of `available_senders`. | | `412` | `integration_missing` | No Gmail mailbox is connected in the workspace. | A workspace admin connects one. | | `412` | `no_primary` | You have not connected a Gmail mailbox of your own. | Connect one, or use a key that belongs to a user who has. | | `412` | `invalid_credentials` | The mailbox's connection is no longer valid. | Reconnect the mailbox. | | `412` | `scope_missing` | The mailbox's Google permission does not include sending. The body carries `needs_reconsent: true`. | Reconnect the mailbox and grant sending. | | `412` | `tool_disabled` | Sending is switched off for the mailbox. The body carries `tool_disabled: true`. | A workspace admin turns on **Allow agents to send from this mailbox**. | ```json { "status": "error", "error": "Several of your Gmail mailboxes can send and none is the default: pass from", "code": "SENDER_REQUIRED", "available_senders": ["dana@example.com", "dana@example.org"], "hint": "Pass from as one of available_senders (GET /v1/integrations/gmail/senders lists your mailboxes)." } ``` ## List Senders ``` GET /v1/integrations/gmail/senders ``` **Required scope:** `actions:write` Returns the Gmail mailboxes you connected and whether each one can send. Call it to choose a value for `from`. ```bash curl https://api.pathbound.ai/v1/integrations/gmail/senders \ -H "Authorization: Bearer $PATHBOUND_API_KEY" ``` ### Response — `200 OK` ```json { "status": "success", "senders": [ { "email": "dana@example.com", "is_default": true, "send_enabled": true }, { "email": "dana@example.org", "is_default": false, "send_enabled": false, "reason": "tool_disabled" } ], "default_sender": "dana@example.com" } ``` | Field | Description | | ----- | ----------- | | `senders` | Your own mailboxes, the default first. Empty when you have connected none. | | `senders[].send_enabled` | Whether the mailbox can send right now. | | `senders[].reason` | Present when `send_enabled` is `false`: `invalid_credentials`, `scope_missing` or `tool_disabled`, with the meanings given [above](#when-a-mailbox-is-refused). | | `default_sender` | The mailbox a request without `from` sends from. `null` when you have to pass `from`, or when no mailbox can send. | ## Send an Email ``` POST /v1/contacts/:contact_id/send_email ``` **Required scope:** `actions:write` Sends one email to a contact, now. The recipient is the address on the contact record: no field of the request names one. ### Path Parameters | Parameter | Type | Description | | --------- | ---- | ----------- | | `contact_id` | string | The contact to email. | ### Headers | Header | Required | Description | | ------ | -------- | ----------- | | `Idempotency-Key` | yes | A key of your choosing for this one email. See [Sending the same email twice](#sending-the-same-email-twice). | ### Request Body | Field | Type | Required | Description | | ----- | ---- | -------- | ----------- | | `subject` | string | yes | The subject line. At most 998 characters, with no line breaks. | | `body` | string | yes | The email's content. | | `body_format` | string | no | `html` (the default) or `text`. | | `from` | string | no | The address of the mailbox to send from. See [Which mailbox sends](#which-mailbox-sends). | | `cc` | string[] | no | Addresses to copy. | | `bcc` | string[] | no | Addresses to copy without showing them. | | `thread_id` | string | no | The Gmail thread to reply in. See [Replying in a thread](#replying-in-a-thread). | | `include_signature` | boolean | no | Append your saved email signature. Default `false`. | This endpoint only sends now. A request that carries `send_at` or `draft` is refused with `400 USE_OUTBOX` and nothing is sent: use [the outbox](#create-a-draft-or-a-scheduled-email) for those. ```bash curl -X POST https://api.pathbound.ai/v1/contacts/665f1c2ab3d4e5f601234567/send_email \ -H "Authorization: Bearer $PATHBOUND_API_KEY" \ -H "Idempotency-Key: 7c1f0d5e-3b0a-4a3e-9a51-2f6f6e0f8f11" \ -H "Content-Type: application/json" \ -d '{ "from": "dana@example.com", "subject": "Next steps after the pilot", "body": "

Hi Ada,

Here is the plan we talked about.

" }' ``` ### Response — `200 OK` ```json { "status": "success", "action_id": "670bd0e4a1b2c3d4e5f60718", "message_id": "19286f3a7c2d4e10", "thread_id": "19286f3a7c2d4e10", "sent_at": "2026-10-13T14:02:11.482Z", "sender": "dana@example.com" } ``` | Field | Description | | ----- | ----------- | | `action_id` | Pathbound's record of this send. | | `message_id`, `thread_id` | Gmail's ids for the sent message and the thread it is in. | | `sender` | The mailbox the email went out from. | | `replayed` | `true` when this answer repeats an earlier send under the same `Idempotency-Key`. Nothing was sent again. | The send appears on the contact's timeline as a `pathbound_action` event with `data.action_type` set to `gmail_send_email`. ### Replying in a thread To reply inside an existing conversation, pass its `thread_id`. [`GET /v1/contacts/:contact_id/email-threads`](/docs/api/contacts#list-contact-email-threads) lists a contact's Gmail threads with their ids. A Gmail thread belongs to one mailbox, so the reply has to go out from the mailbox that holds the thread. A `thread_id` from another mailbox is refused with `422 THREAD_NOT_IN_MAILBOX` and nothing is sent. ### Sending the same email twice `Idempotency-Key` is what keeps a retry from becoming a second email. For 24 hours, a request with a key you have already used is answered like this: | Earlier request with this key | Answer | | ----------------------------- | ------ | | Sent | `200` with the same `message_id` and `replayed: true`. Nothing is sent. | | Still being processed | `409 ACTION_IN_PROGRESS`. Ask again with the same key. | | Refused by Gmail, so certainly not sent | The request is carried out as a new send. | | Got no answer from Gmail | `502 SEND_UNCONFIRMED` again, with `replayed: true`. Nothing is sent. | The general rules are under [Idempotency](/docs/api/overview#idempotency). ### Error Codes | Status | `code` | Meaning | What to do | | ------ | ------ | ------- | ---------- | | `400` | `USE_OUTBOX` | The body carries `send_at` or `draft`. | Use the outbox endpoint. | | `400` | `IDEMPOTENCY_KEY_REQUIRED` | The `Idempotency-Key` header is missing. | | | `400` | none | A field is not valid. `details` names it. | | | `404` | none | No such contact in your workspace. | | | `409` | `ACTION_IN_PROGRESS` | A request with this key is still being processed. | Ask again with the same key. | | `412`, `422` | see [above](#when-a-mailbox-is-refused) | The mailbox cannot be used. | | | `422` | `CONTACT_EMAIL_MISSING` | The contact has no email address. | | | `422` | `THREAD_NOT_IN_MAILBOX` | `thread_id` is not a thread of the sending mailbox. | Send from the mailbox that holds the thread, or leave `thread_id` out. | | `502` | `GMAIL_SEND_FAILED` | Gmail refused the request or could not be reached. Nothing was sent. | Retry with the same key after `retry_after_seconds`. | | `502` | `SEND_UNCONFIRMED` | Gmail did not answer. The email may have been sent. | Do not retry. See [When Gmail does not answer](#when-gmail-does-not-answer). | ## When Gmail does not answer Delivery is at most once. Pathbound hands an email to Gmail one time, and never resends one whose outcome it does not know. When Gmail takes the request and then gives no clear answer (a timeout, a dropped connection, a server error after the email was handed over), the email may or may not have gone out. The answer is `502` with the code `SEND_UNCONFIRMED`: ```json { "status": "error", "error": "Gmail did not answer; the email may have been sent", "code": "SEND_UNCONFIRMED", "action_id": "670bd0e4a1b2c3d4e5f60718", "sender": "dana@example.com", "hint": "Check the Sent folder of dana@example.com before sending again. Do not retry this request; to send deliberately, use a new Idempotency-Key." } ``` What to do with it: 1. **Do not retry automatically.** The same request with the same `Idempotency-Key` gets `SEND_UNCONFIRMED` again for 24 hours and sends nothing. 2. **Look in the Sent folder** of the `sender` mailbox. 3. **If the email is not there** and you still want it sent, send it again under a new `Idempotency-Key`. `GMAIL_SEND_FAILED` is the other `502` and means the opposite: nothing was sent, and a retry with the same key is safe. It carries `retry_after_seconds`. `SEND_UNCONFIRMED` never does. A draft or a scheduled email whose send got no answer ends with `status: "failed"` and `failure.code: "SEND_UNCONFIRMED"`. It is not sent again. ## Create a Draft or a Scheduled Email ``` POST /v1/integrations/gmail/outbox ``` **Required scope:** `actions:write` Saves an email that Pathbound holds: a draft that waits until you send it, or an email that goes out at `send_at`. The mailbox and the contact are checked now, with the same errors as [Send an Email](#send-an-email), so an email that could never be sent is refused when you create it. The mailbox and the recipient's address are fixed at this point. See [What is checked when a scheduled email goes out](#what-is-checked-when-a-scheduled-email-goes-out). ### Headers | Header | Required | Description | | ------ | -------- | ----------- | | `Idempotency-Key` | yes | A key of your choosing for this one email. The same key within 24 hours returns the email as it is now, with `replayed: true`, and creates nothing. One exception: a scheduled request repeated after its `send_at` is more than 5 minutes in the past is answered `400 SEND_AT_IN_PAST`, because the time is checked first. [List the outbox](#list-the-outbox) to see what became of the email. | ### Request Body | Field | Type | Required | Description | | ----- | ---- | -------- | ----------- | | `contact_id` | string | yes | The contact the email is for. | | `draft` | boolean | one of the two | `true` to save a draft. Nothing is sent until you [send the draft](#send-a-draft). | | `send_at` | string | one of the two | When to send. See below. | | `subject`, `body`, `body_format`, `from`, `cc`, `bcc`, `thread_id`, `include_signature` | | | The same fields as [Send an Email](#send-an-email). `subject` and `body` are required. | Pass exactly one of `draft: true` and `send_at`. `send_at` is an ISO 8601 date and time with an explicit offset or `Z`, for example `2026-10-20T09:00:00+02:00` or `2026-10-20T07:00:00Z`. A time without an offset is refused, because the server cannot know your time zone. It can be at most 30 days ahead. A time up to 5 minutes in the past is accepted and treated as now. Anything earlier is refused. ```bash curl -X POST https://api.pathbound.ai/v1/integrations/gmail/outbox \ -H "Authorization: Bearer $PATHBOUND_API_KEY" \ -H "Idempotency-Key: 2b6a9c4e-11d7-4f0b-8a3c-5e9d7f1c2a40" \ -H "Content-Type: application/json" \ -d '{ "contact_id": "665f1c2ab3d4e5f601234567", "from": "dana@example.com", "subject": "Next steps after the pilot", "body": "

Hi Ada,

Here is the plan we talked about.

", "send_at": "2026-10-20T09:00:00+02:00" }' ``` ### Response — `202 Accepted` A scheduled email is answered with `202`, a draft with `201 Created`, and a repeated request with `200 OK` and `replayed: true`. The body has the same shape each time. ```json { "status": "success", "action_id": "670bd1a2a1b2c3d4e5f60730", "email": { "outbox_id": "670bd1a2a1b2c3d4e5f60731", "status": "scheduled", "contact_id": "665f1c2ab3d4e5f601234567", "to": "ada@example.com", "sender": "dana@example.com", "subject": "Next steps after the pilot", "body_format": "html", "cc_count": 0, "bcc_count": 0, "include_signature": false, "send_at": "2026-10-20T07:00:00.000Z", "attempts": 0, "created_by": { "agent_id": "api_key:9f3c21ab" }, "send_requested_by": { "agent_id": "api_key:9f3c21ab" }, "action_id": "670bd1a2a1b2c3d4e5f60730", "created_at": "2026-10-13T14:05:38.120Z", "updated_at": "2026-10-13T14:05:38.120Z" } } ``` A scheduled email goes out within about 30 seconds after `send_at`. You can hold at most 200 open emails (drafts plus scheduled) at a time. There is no endpoint that edits a held email: cancel it and create a new one. ### Error Codes | Status | `code` | Meaning | | ------ | ------ | ------- | | `400` | `IDEMPOTENCY_KEY_REQUIRED` | The `Idempotency-Key` header is missing. | | `400` | `DRAFT_OR_SEND_AT` | The body carries both `draft: true` and `send_at`, or neither. | | `400` | `SEND_AT_INVALID` | `send_at` is not an ISO 8601 date and time with an offset. | | `400` | `SEND_AT_TOO_FAR` | `send_at` is more than 30 days ahead. | | `400` | `SEND_AT_IN_PAST` | `send_at` is more than 5 minutes in the past. | | `400` | none | Another field is not valid. `details` names it. | | `404` | none | No such contact in your workspace. | | `409` | `ACTION_IN_PROGRESS` | A request with this key is still being processed. | | `412`, `422` | see [above](#when-a-mailbox-is-refused) | The mailbox cannot be used. | | `422` | `CONTACT_EMAIL_MISSING` | The contact has no email address. | | `422` | `CONTACT_EMAIL_INVALID` | The contact's email is not a usable address. | | `422` | `OUTBOX_LIMIT` | You already hold 200 open emails. Send or cancel some first. | ## The outbox email object Every outbox endpoint returns the email in this shape. A field with no value is left out. | Field | Description | | ----- | ----------- | | `outbox_id` | The email's id, for the send and cancel endpoints. | | `status` | `draft`, `scheduled`, `sending`, `sent`, `failed` or `canceled`. See below. | | `contact_id`, `to` | The contact, and the address the email was written for. | | `sender` | The mailbox it goes out from. | | `subject`, `body_format` | As given when the email was created. | | `cc_count`, `bcc_count` | How many addresses are copied. The addresses themselves are returned only by the list, with `include_body=true`. | | `reply_thread_id` | The Gmail thread the email replies in, when one was given. | | `include_signature` | Whether your signature is appended. | | `send_at` | When a scheduled email goes out. | | `expires_at` | Drafts only: when the draft expires. | | `attempts`, `next_attempt_at` | How many times sending was tried, and when it is tried next after a failure that can be retried. | | `message_id`, `thread_id`, `sent_at` | Set once the email is sent: Gmail's ids for the message and its thread, and the time. | | `failure` | `{ code, message }` when a send did not go through. See [Failure codes](#failure-codes). | | `created_by` | Which credential created the email. For an API key, `agent_id` is `api_key:` followed by the key's id. | | `send_requested_by` | Which credential asked for it to be sent: set at creation for a scheduled email, and when a draft is sent. | | `action_id` | Pathbound's record of the latest decision about this email. | | `created_at`, `updated_at` | Timestamps. | **Statuses** | `status` | Meaning | | -------- | ------- | | `draft` | Held. It is never sent until you [send it](#send-a-draft). A draft is kept for 30 days. At `expires_at` it becomes `canceled`, with `failure.code` set to `DRAFT_EXPIRED`. | | `scheduled` | Goes out at `send_at`, or at `next_attempt_at` after a failure that can be retried. | | `sending` | Being sent right now. It can no longer be canceled. | | `sent` | Sent. | | `failed` | Not sent, or sent with an unknown outcome. `failure` says which. | | `canceled` | Discarded, canceled or expired. | Once an email is `sent`, `failed` or `canceled`, Pathbound removes its body and its cc and bcc addresses. The sent copy is in Gmail. Deleting a contact deletes the emails held for that contact. ## List the Outbox ``` GET /v1/integrations/gmail/outbox ``` **Required scope:** `actions:write` Returns your own outbox emails, newest first. You never see a teammate's. ### Query Parameters | Parameter | Type | Default | Description | | --------- | ---- | ------- | ----------- | | `status` | string | none | Only emails in this status: `draft`, `scheduled`, `sending`, `sent`, `failed` or `canceled`. | | `contact_id` | string | none | Only emails for this contact. | | `limit` | number | 50 | How many to return (1 to 200). | | `include_body` | boolean | `false` | Add `body`, `cc` and `bcc` to each email that still holds them: drafts and scheduled emails. | | `after` | string | none | The `outbox_id` of the last email you received. The list starts right after it: pass the last email of one page to read the next. | ### Response — `200 OK` ```json { "status": "success", "counts": { "total": 1, "draft": 1, "scheduled": 0, "sending": 0, "sent": 0, "failed": 0, "canceled": 0 }, "has_more": false, "emails": [ { "outbox_id": "670bd0e4a1b2c3d4e5f60719", "status": "draft", "contact_id": "665f1c2ab3d4e5f601234567", "to": "ada@example.com", "sender": "dana@example.com", "subject": "Next steps after the pilot", "body_format": "html", "cc_count": 1, "bcc_count": 0, "include_signature": false, "expires_at": "2026-11-12T14:02:11.482Z", "attempts": 0, "created_by": { "agent_id": "api_key:9f3c21ab" }, "action_id": "670bd0e4a1b2c3d4e5f60718", "created_at": "2026-10-13T14:02:11.482Z", "updated_at": "2026-10-13T14:02:11.482Z", "body": "

Hi Ada,

Here is the plan we talked about.

", "cc": ["sam@example.com"], "bcc": [] } ] } ``` `subject` and `body` are returned exactly as they will be sent. `counts` gives how many emails the list holds in each status and in total. It counts every email that matches `contact_id`, whatever `status`, `limit` and `after` say. `has_more` is `true` when more emails follow the last one returned: pass that email's `outbox_id` as `after` to read them. ## Send a Draft ``` POST /v1/integrations/gmail/outbox/:id/send ``` **Required scope:** `actions:write` Sends a draft now, or schedules it when the body carries `send_at`. The email goes out from the mailbox the draft was written for, to the address it was written for. If that mailbox can no longer send, or the contact's email has changed since, the request is refused and nothing is sent. ### Path Parameters | Parameter | Type | Description | | --------- | ---- | ----------- | | `id` | string | The draft's `outbox_id`. | ### Headers | Header | Required | Description | | ------ | -------- | ----------- | | `Idempotency-Key` | yes | A key of your choosing for this one send. | ### Request Body | Field | Type | Required | Description | | ----- | ---- | -------- | ----------- | | `send_at` | string | no | When to send, in the format given for [creating a scheduled email](#create-a-draft-or-a-scheduled-email). Leave it out to send now. | ### Response — `200 OK` Sent now: ```json { "status": "success", "action_id": "670bd3c7a1b2c3d4e5f60742", "message_id": "19286f9b1e5a7c33", "thread_id": "19286f9b1e5a7c33", "sent_at": "2026-10-13T14:20:03.915Z", "sender": "dana@example.com", "email": { "outbox_id": "670bd0e4a1b2c3d4e5f60719", "status": "sent" } } ``` `email` is the full [outbox email object](#the-outbox-email-object), shortened here. With `send_at`, the answer is `202 Accepted` with `status`, `action_id` and `email`, and `email.status` is `scheduled`. You also get `202` for a send without `send_at` when the email was picked up for sending at the same moment: read `email.status`, and [list the outbox](#list-the-outbox) to see how it ended. A repeated request with the same `Idempotency-Key` returns `200` with `replayed: true` and the email as it is now, and sends nothing. Read `email.status` in that answer: an email whose send got no answer is replayed with `status: "failed"`. ### When a draft is not sent - **Gmail refused it, or could not be reached.** The draft is kept as a draft, with the reason in `failure`, and you can send it again. The answer is `502 GMAIL_SEND_FAILED` with `retry_after_seconds`, or `422 THREAD_NOT_IN_MAILBOX`. - **Gmail did not answer.** The email becomes `failed` and the answer is `502 SEND_UNCONFIRMED`. It is not sent again. See [When Gmail does not answer](#when-gmail-does-not-answer). These three answers carry `email`, the email as it is after the attempt. ### Error Codes | Status | `code` | Meaning | What to do | | ------ | ------ | ------- | ---------- | | `400` | `IDEMPOTENCY_KEY_REQUIRED` | The `Idempotency-Key` header is missing. | | | `400` | `SEND_AT_INVALID`, `SEND_AT_TOO_FAR`, `SEND_AT_IN_PAST` | `send_at` is not usable. | | | `404` | none | No such email in your outbox. Another user's email gets this answer too. | | | `409` | `OUTBOX_NOT_DRAFT` | The email is no longer a draft. `current_status` says what it is. | | | `412`, `422` | see [above](#when-a-mailbox-is-refused) | The draft's mailbox can no longer be used. | Fix the mailbox, or cancel the draft and create one for another mailbox. | | `422` | `RECIPIENT_CHANGED` | The contact's email is no longer the address the draft was written for. | Cancel the draft and create a new one. | | `422` | `CONTACT_EMAIL_MISSING`, `CONTACT_EMAIL_INVALID` | The contact has no usable email address now. | | | `422` | `THREAD_NOT_IN_MAILBOX` | The draft's `thread_id` is not a thread of its mailbox. | Cancel the draft and create one without `thread_id`. | | `502` | `GMAIL_SEND_FAILED` | Nothing was sent. The draft is kept. | Send it again after `retry_after_seconds`. | | `502` | `SEND_UNCONFIRMED` | Gmail did not answer. The email may have been sent. | Do not resend before checking the Sent folder. | ## Cancel an Outbox Email ``` DELETE /v1/integrations/gmail/outbox/:id ``` **Required scope:** `actions:write` Discards a draft or cancels a scheduled email. Only an email in status `draft` or `scheduled` can be canceled. ### Path Parameters | Parameter | Type | Description | | --------- | ---- | ----------- | | `id` | string | The email's `outbox_id`. | ### Response — `200 OK` ```json { "status": "success", "email": { "outbox_id": "670bd1a2a1b2c3d4e5f60731", "status": "canceled" } } ``` `email` is the full [outbox email object](#the-outbox-email-object), shortened here. ### Error Codes | Status | `code` | Meaning | | ------ | ------ | ------- | | `404` | none | No such email in your outbox. | | `409` | `OUTBOX_NOT_PENDING` | The email is already sending, sent, failed or canceled. `current_status` says which. An email that is sending or sent cannot be recalled. | ## What is checked when a scheduled email goes out A scheduled email is sent long after the request that asked for it, so Pathbound checks again at that moment, in this order: 1. **Whoever asked for the send may still send.** The user is still a verified member of the workspace. For an API key: the key is still active, has not expired, and still holds `actions:write`. For an AI agent: its connection has not been disconnected. 2. **The mailbox chosen when the email was created can still send.** It is used or refused, never swapped for another mailbox. 3. **The contact's email is still the address the email was written for.** A check that fails ends the email as `failed`, with the reason in `failure`. Nothing is sent. ### Failure codes `failure.code` on an outbox email is one of these: | `failure.code` | Meaning | | -------------- | ------- | | `PRINCIPAL_REVOKED` | Whoever asked for the send may no longer send: the user left the workspace, the API key was revoked, expired or lost `actions:write`, or the AI agent's connection was disconnected. | | `SENDER_UNAVAILABLE` | The mailbox can no longer send. `failure.message` names the reason. | | `RECIPIENT_CHANGED` | The contact's email changed after the email was written. | | `CONTACT_EMAIL_MISSING`, `CONTACT_EMAIL_INVALID` | The contact has no usable email address now. | | `THREAD_NOT_IN_MAILBOX` | The thread to reply in is not in the sending mailbox. | | `GMAIL_SEND_FAILED` | Gmail refused the email or could not be reached, and nothing was sent. A scheduled email is tried up to 3 times, 5 minutes apart, before it ends with this code. | | `SEND_UNCONFIRMED` | Gmail did not answer. The email may have been sent, and it is not sent again. Check the Sent folder. | | `DRAFT_EXPIRED` | A draft was not sent within 30 days. Its status is `canceled`. | --- # Resend > Manage segments, send an email to a contact, send an event that starts an automation, and create and send broadcasts in your workspace's own Resend account. Source: https://pathbound.ai/docs/api/resend These endpoints act in the Resend account your workspace has connected. You name a Pathbound contact, and Pathbound reads the address from the contact record and makes the call to Resend with your workspace's Resend API key. Use them to keep Resend segments in step with what you know about your customers, to send a branded or templated email from your sending domain, and to start automations you built in Resend. Pathbound also reads your Resend contacts, segments and domains on a schedule, so a contact's segment membership and subscription status are on its profile. That sync only reads. For a one-to-one email from a person's own mailbox, use [Gmail](/docs/api/gmail). Before the first call, connect Resend, create an API key with the `actions:write` scope, and have a workspace admin switch on the actions you need: see [Before you call](#before-you-call). The MCP tools whose names start with `resend_` call the same endpoints: see [MCP tools](/docs/mcp/tools#email-actions-resend). :::caution These endpoints change your Resend account and send to real people. A sent email, a sent broadcast and a sent event cannot be recalled. ::: | Endpoint | Scope | What it does | | -------- | ----- | ------------ | | [`GET /v1/integrations/resend/segments`](#list-segments) | `actions:write` | Lists the segments in your Resend account. | | [`POST /v1/integrations/resend/segments`](#create-a-segment) | `actions:write` | Creates a segment. | | [`POST /v1/integrations/resend/segments/:segment_id/contacts`](#add-contacts-to-a-segment) | `actions:write` | Adds up to 25 contacts to a segment. | | [`DELETE /v1/integrations/resend/segments/:segment_id/contacts/:contact_id`](#remove-a-contact-from-a-segment) | `actions:write` | Removes one contact from a segment. | | [`DELETE /v1/integrations/resend/segments/:segment_id`](#delete-a-segment) | `actions:write` | Deletes a segment. | | [`GET /v1/integrations/resend/contacts/:contact_id/segments`](#list-a-contacts-segments) | `contacts:read` | Lists the segments a contact is in. | | [`GET /v1/integrations/resend/contacts/:contact_id/emails`](#list-emails-sent-to-a-contact) | `contacts:read` | Lists the emails Pathbound sent to a contact through Resend. | | [`POST /v1/integrations/resend/contacts/:contact_id/emails`](#send-an-email) | `actions:write` | Sends an email to a contact. | | [`GET /v1/integrations/resend/events`](#list-events) | `actions:write` | Lists the events your Resend account defines. | | [`POST /v1/integrations/resend/contacts/:contact_id/events`](#send-an-event) | `actions:write` | Sends one event for a contact. | | [`POST /v1/integrations/resend/broadcasts`](#create-a-broadcast) | `actions:write` | Creates a broadcast to a segment. Does not send it. | | [`POST /v1/integrations/resend/broadcasts/:broadcast_id/send`](#send-a-broadcast) | `actions:write` | Sends or schedules a broadcast. | ## Before you call **Resend is connected.** Connect it under **Integrations → Resend** in the dashboard, with a Resend API key that has **Full access**. A key with sending access only cannot read the account's domains, segments and templates, which these endpoints depend on. **The scope.** `actions:write` is opt-in: a key holds it only when it was selected for that key. See [Scopes](/docs/getting-started/authentication#scopes). **The admin switches.** A workspace admin switches each group of actions on under **MCP Tools → Resend** in the dashboard. Every switch is off until an admin turns it on. The page is named for AI agents, but the API checks a switch on every request to the endpoints it governs: an API key is held to it exactly as an agent is. With a switch off, its endpoints answer `412` with the code `tool_disabled`. | Switch | Name in error messages | Endpoints it governs | | ------ | ---------------------- | -------------------- | | Manage segments | `resend_manage_segments` | Create a segment, add contacts, remove a contact, delete a segment. | | Send email | `resend_send_email` | Send an email. | | Send events | `resend_send_event` | List events, send an event. | | Create broadcasts | `resend_manage_broadcasts` | Create a broadcast. | | Send broadcasts | `resend_send_broadcasts` | Send a broadcast. | Three reads are governed by the key's scope alone and work with every switch off: [List Segments](#list-segments), [List a Contact's Segments](#list-a-contacts-segments) and [List Emails Sent to a Contact](#list-emails-sent-to-a-contact). For an AI agent, the matching tools are still hidden while their switch is off. [`GET /v1/tenant/capabilities`](/docs/api/workspace#capabilities) reports which switches are on, under `tool_settings`. ## Rules shared by these endpoints **Contacts are named by their Pathbound id.** The address is read from the contact record, so a request never carries the contact's address. **Resend's own ids.** `segment_id` and `broadcast_id` are the ids Resend gives its objects. An id has to be one plain token of letters, digits, `_` and `-`. Anything else is refused with `400`. **The sender.** `from` on an email or a broadcast has to be on a domain that is verified for sending in your Resend account, written as `hello@example.com` or `Acme `. Otherwise the request is refused with `422 FROM_NOT_VERIFIED`, and `available_domains` lists the domains you can use. Pathbound learns about a newly verified domain at its next Resend sync. **Idempotency.** The endpoints that create or send take an `Idempotency-Key` header, and each endpoint below says whether it requires one. A request with a key that succeeded in the last 24 hours returns the first answer with `replayed: true` and does nothing again. See [Idempotency](/docs/api/overview#idempotency). ### Errors on every endpoint | Status | `code` | Meaning | What to do | | ------ | ------ | ------- | ---------- | | `400` | none | A parameter is not valid. `details` names it. | | | `400` | `IDEMPOTENCY_KEY_REQUIRED` | The endpoint requires the `Idempotency-Key` header. | | | `403` | none | The key lacks the scope. `required_scopes` names it. | | | `409` | `ACTION_IN_PROGRESS` | A request with this `Idempotency-Key` is still being processed. | Ask again with the same key. | | `412` | `integration_missing` | Resend is not connected. | Connect Resend. | | `412` | `invalid_credentials` | The stored Resend API key no longer works. | Connect Resend again with a working key. | | `412` | `tool_disabled` | A workspace admin has not switched this action on. | Switch it on under **MCP Tools → Resend**. | | `404` | `RESEND_NOT_FOUND` | Resend does not know the segment, broadcast or contact. | | | `422` | `RESEND_REJECTED` | Resend refused the request. `error` is Resend's own reason, for example a plan limit, and `resend_status` is the status Resend answered with. | | | `429` | `RATE_LIMITED` | Resend's rate limit was reached. | Retry after `retry_after_seconds`. | | `500` | none | The request failed. Resend may or may not have carried it out. | Where the endpoint takes an `Idempotency-Key`, retry with the same key, and read the endpoint's notes on retrying. | When Resend refuses the stored key, Pathbound treats the key as dead, and every later request is answered with `412 invalid_credentials` until Resend is connected again. The request that meets the refusal is answered by what Resend said: - a `401` or a `403` is answered with `412 invalid_credentials` at once (the two [event endpoints](#list-events) are the exception for a `403`); - Resend answers a revoked key with a `400`, and that request is answered with `422 RESEND_REJECTED` and `resend_status: 400`. On [Add Contacts to a Segment](#add-contacts-to-a-segment) it is answered `200` with every contact `failed`. ## List Segments ``` GET /v1/integrations/resend/segments ``` **Required scope:** `actions:write` Returns every segment in your Resend account, read from Resend at the time of the call. Use it to find a `segment_id`. ```bash curl https://api.pathbound.ai/v1/integrations/resend/segments \ -H "Authorization: Bearer $PATHBOUND_API_KEY" ``` ### Response — `200 OK` ```json { "status": "success", "segments": [ { "id": "78261eea-8f8b-4381-83c6-79fa7120f1cf", "name": "Trial users", "created_at": "2026-10-02T09:14:51.000Z" } ] } ``` Each segment is returned as Resend holds it. ## Create a Segment ``` POST /v1/integrations/resend/segments ``` **Required scope:** `actions:write` · **Switch:** Manage segments ### Headers | Header | Required | Description | | ------ | -------- | ----------- | | `Idempotency-Key` | yes | A key of your choosing for this one segment. | ### Request Body | Field | Type | Required | Description | | ----- | ---- | -------- | ----------- | | `name` | string | yes | The segment's name. At most 255 characters. | ### Response — `200 OK` ```json { "status": "success", "action_id": "670bd4a9a1b2c3d4e5f60750", "segment_id": "78261eea-8f8b-4381-83c6-79fa7120f1cf", "name": "Trial users" } ``` Resend limits the number of segments by plan. At the limit, the answer is `422 RESEND_REJECTED` with Resend's message. After a `500`, [list the segments](#list-segments) before you retry: Resend may have created the segment, and a retry would then create a second one with the same name. ## Add Contacts to a Segment ``` POST /v1/integrations/resend/segments/:segment_id/contacts ``` **Required scope:** `actions:write` · **Switch:** Manage segments Adds up to 25 Pathbound contacts to a Resend segment. For each one, Pathbound reads the email, first name and last name from the contact record, creates the contact in Resend if it is not there yet, and adds it to the segment. This endpoint takes no `Idempotency-Key`: adding a contact that is already in the segment changes nothing. To add everyone in a saved Pathbound segment, however many, run the `resend_add_to_segment` action on it as a background job: see [Run an Action on a Segment](/docs/api/segments#run-an-action-on-a-segment). To send to the whole segment afterwards, [create a broadcast](#create-a-broadcast) for the Resend segment and [send it](#send-a-broadcast). ### Path Parameters | Parameter | Type | Description | | --------- | ---- | ----------- | | `segment_id` | string | The Resend segment's id. | ### Request Body | Field | Type | Required | Description | | ----- | ---- | -------- | ----------- | | `contact_ids` | string[] | yes | 1 to 25 Pathbound contact ids. | ```bash curl -X POST https://api.pathbound.ai/v1/integrations/resend/segments/78261eea-8f8b-4381-83c6-79fa7120f1cf/contacts \ -H "Authorization: Bearer $PATHBOUND_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "contact_ids": ["665f1c2ab3d4e5f601234567", "665f1c2ab3d4e5f601234568"] }' ``` ### Response — `200 OK` ```json { "status": "success", "action_id": "670bd5f0a1b2c3d4e5f60755", "segment_id": "78261eea-8f8b-4381-83c6-79fa7120f1cf", "results": [ { "contact_id": "665f1c2ab3d4e5f601234568", "status": "skipped", "error": "contact_email_missing" }, { "contact_id": "665f1c2ab3d4e5f601234567", "status": "added", "resend_contact_id": "e169aa45-1ecf-4183-9955-b1499d5701d3" } ], "summary": { "added": 1, "skipped": 1, "failed": 0 } } ``` The request succeeds as a whole even when some contacts do not: read `results`. They are not in the order you sent, so match them by `contact_id`. | `status` | Meaning | | -------- | ------- | | `added` | The contact is in the segment. `resend_contact_id` is Resend's id for the contact, when Resend returned one. | | `skipped` | Nothing was sent to Resend. `error` is `contact_not_found` (no such contact in your workspace) or `contact_email_missing`. | | `failed` | Resend refused this contact. `error` is Resend's reason. | A `401` or `403` from Resend, or its rate limit, fails the whole request with `412` or `429`. Contacts that were added before that point stay in the segment, so the request can be sent again as it is. A key that Resend has revoked is different: every contact comes back `failed` in a `200`, as described under [Errors on every endpoint](#errors-on-every-endpoint). ## Remove a Contact from a Segment ``` DELETE /v1/integrations/resend/segments/:segment_id/contacts/:contact_id ``` **Required scope:** `actions:write` · **Switch:** Manage segments Removes one contact from a Resend segment. The contact itself stays in Resend. ### Path Parameters | Parameter | Type | Description | | --------- | ---- | ----------- | | `segment_id` | string | The Resend segment's id. | | `contact_id` | string | The Pathbound contact's id. | ### Response — `200 OK` ```json { "status": "success", "segment_id": "78261eea-8f8b-4381-83c6-79fa7120f1cf", "contact_id": "665f1c2ab3d4e5f601234567" } ``` ## Delete a Segment ``` DELETE /v1/integrations/resend/segments/:segment_id ``` **Required scope:** `actions:write` · **Switch:** Manage segments Deletes the segment in Resend. This cannot be undone. The contacts that were in it are not deleted. ### Response — `200 OK` ```json { "status": "success", "segment_id": "78261eea-8f8b-4381-83c6-79fa7120f1cf" } ``` ### Error Codes for Segments In addition to the [errors on every endpoint](#errors-on-every-endpoint): | Status | `code` | Meaning | | ------ | ------ | ------- | | `404` | none | Remove a contact: no such contact in your workspace. | | `422` | `CONTACT_EMAIL_MISSING` | Remove a contact: the contact has no email address. | ## List a Contact's Segments ``` GET /v1/integrations/resend/contacts/:contact_id/segments ``` **Required scope:** `contacts:read` Returns the Resend segments a contact is in, as of the last Resend sync. A contact you added to a segment a moment ago appears here after the next sync. ### Response — `200 OK` ```json { "status": "success", "segments": [ { "id": "78261eea-8f8b-4381-83c6-79fa7120f1cf", "name": "Trial users" } ] } ``` `segments` is empty when the contact is not in Resend. A contact that does not exist in your workspace is answered with `404`. ## List Emails Sent to a Contact ``` GET /v1/integrations/resend/contacts/:contact_id/emails ``` **Required scope:** `contacts:read` Returns the emails Pathbound sent to this contact through Resend, newest first, 50 at most. That covers emails sent with [Send an Email](#send-an-email) and emails sent by an [email automation](/docs/api/webhooks#email-automations). Email sent from Resend without Pathbound is not listed. ### Response — `200 OK` ```json { "status": "success", "emails": [ { "id": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c", "subject": "Your invoice for October", "status": "succeeded", "sent_at": "2026-10-13T14:31:07.204Z" } ] } ``` | Field | Description | | ----- | ----------- | | `id` | Resend's id for the email. | | `status` | Always `succeeded`: Resend accepted the email. It is not a delivery status. | | `sent_at` | When Resend accepted it. Absent on an email that was scheduled. | | `scheduled_at` | Present on a scheduled email: the time as it was given in the request. | ## Send an Email ``` POST /v1/integrations/resend/contacts/:contact_id/emails ``` **Required scope:** `actions:write` · **Switch:** Send email Sends one email to a contact through Resend, from your verified sending domain. The email is written in the request, or is a template of your Resend account. ### Path Parameters | Parameter | Type | Description | | --------- | ---- | ----------- | | `contact_id` | string | The contact to email. The recipient is the address on the contact record. | ### Headers | Header | Required | Description | | ------ | -------- | ----------- | | `Idempotency-Key` | yes | A key of your choosing for this one email. | ### Request Body | Field | Type | Required | Description | | ----- | ---- | -------- | ----------- | | `from` | string | yes | The sender, on a [verified domain](#rules-shared-by-these-endpoints). | | `subject` | string | yes | The subject line. At most 998 characters. | | `body` | string | one of the two | The email's content. | | `template_id` | string | one of the two | The id of a template in your Resend account. When both are sent, the template is used and `body` is ignored. | | `body_format` | string | no | `html` (the default) or `text`. Applies to `body`. | | `cc` | string[] | no | Addresses to copy. | | `bcc` | string[] | no | Addresses to copy without showing them. | | `reply_to` | string[] | no | Reply-to addresses. | | `scheduled_at` | string | no | When Resend should send the email, for example `2026-10-20T09:00:00Z`. The value is passed to Resend as written. Leave it out to send now. | ```bash curl -X POST https://api.pathbound.ai/v1/integrations/resend/contacts/665f1c2ab3d4e5f601234567/emails \ -H "Authorization: Bearer $PATHBOUND_API_KEY" \ -H "Idempotency-Key: 5d0b6f1a-7c3e-4b92-a1d4-0e8f3c6b9a27" \ -H "Content-Type: application/json" \ -d '{ "from": "Acme ", "subject": "Your invoice for October", "body": "

Hi Ada,

Your invoice is ready on your account page.

" }' ``` ### Response — `200 OK` ```json { "status": "success", "action_id": "670bd6b3a1b2c3d4e5f60761", "email_id": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c", "sent_at": "2026-10-13T14:31:07.204Z", "sender": "Acme " } ``` With `scheduled_at`, the response carries `scheduled_at` in place of `sent_at`. A scheduled email is held by Resend, not by Pathbound, and this API has no endpoint that cancels it. The send appears on the contact's timeline as a `pathbound_action` event with `data.action_type` set to `resend_send_email`. **Retrying.** If the request fails with a `500` or you get no answer, retry with the same `Idempotency-Key`. Pathbound passes a key derived from yours to Resend, and Resend does not send the same email twice under one key within 24 hours. ### Error Codes for Email In addition to the [errors on every endpoint](#errors-on-every-endpoint): | Status | `code` | Meaning | | ------ | ------ | ------- | | `400` | `BODY_OR_TEMPLATE_REQUIRED` | The request carries neither `body` nor `template_id`. | | `404` | none | No such contact in your workspace. | | `422` | `CONTACT_EMAIL_MISSING` | The contact has no email address. | | `422` | `FROM_NOT_VERIFIED` | `from` cannot be used. `reason` is `domain_not_verified`, or `no_verified_domain` when the account has no domain verified for sending. `available_domains` lists the usable ones. | ## List Events ``` GET /v1/integrations/resend/events ``` **Required scope:** `actions:write` · **Switch:** Send events Returns the events your Resend account defines, with the payload fields each one declares. An event is what starts an automation built in Resend: a welcome series when `user.created` arrives, a follow-up when `demo.booked` does. You build the automation in Resend, with its steps, its delays and its templates, and Pathbound only sends the event that starts it. Events and automations are created in Resend, not through Pathbound. To send an event for everyone who submits a form on your site, instead of one contact at a time, use a [Resend event automation](/docs/api/webhooks#resend-event-automations). ```bash curl https://api.pathbound.ai/v1/integrations/resend/events \ -H "Authorization: Bearer $PATHBOUND_API_KEY" ``` ### Response — `200 OK` ```json { "status": "success", "events": [ { "name": "demo.booked", "schema": { "plan": "string", "seats": "number" } }, { "name": "user.created", "schema": null } ] } ``` | Field | Description | | ----- | ----------- | | `events[].name` | The event's name. Pass it to [Send an Event](#send-an-event) exactly as returned. | | `events[].schema` | The payload fields the event declares, each with its type (`string`, `number`, `boolean` or `date`). `null` when it declares none. | | `truncated` | `true` when the account has more events than were read. Pathbound reads the first 500. | Resend's own system events, whose names start with `resend:`, are left out. The response does not say which automations an event starts or what they send: that is not visible from Pathbound. ## Send an Event ``` POST /v1/integrations/resend/contacts/:contact_id/events ``` **Required scope:** `actions:write` · **Switch:** Send events Sends one of your Resend events for one contact. Resend then runs every active automation that triggers on that event, for that contact. One contact per request. A `200` means that Resend accepted the event. It does not mean that an email was sent: Pathbound cannot see which automations the event started or what they do. ### Path Parameters | Parameter | Type | Description | | --------- | ---- | ----------- | | `contact_id` | string | The contact the event is for. It goes to the address on the contact record, and Resend creates the contact if it does not know the address. | ### Headers | Header | Required | Description | | ------ | -------- | ----------- | | `Idempotency-Key` | yes | A key of your choosing for this one event. | ### Request Body | Field | Type | Required | Description | | ----- | ---- | -------- | ----------- | | `event` | string | yes | The event's name, exactly as [List Events](#list-events) returns it. Capitals count. | | `payload` | object | no | Data for the automation. See below. | The body takes these two fields only. Any other field is refused with `400 UNKNOWN_FIELD`, so a request cannot name an address, a Resend contact or a segment. **The event has to exist.** Its name is checked against your account's events before anything is sent, and a name that matches none is refused with `422 RESEND_EVENT_NOT_FOUND`. A name that starts with `resend:` is never accepted. **The payload** is a flat object: each value is a string, a number or a boolean, and a date is written as an ISO 8601 string. It holds at most 50 fields. A field name is letters, digits and underscores, at most 64 characters, and does not start with a digit. A string is at most 2,000 characters, and the whole payload at most 10,000 bytes as JSON. A payload that breaks one of these rules is refused whole and nothing is sent. The automation can print these values in its emails, so keep to the fields the event's `schema` declares. **An unsubscribed contact is refused.** A contact that Resend lists as unsubscribed, as of the last sync, gets `422 CONTACT_UNSUBSCRIBED` and no event is sent. ```bash curl -X POST https://api.pathbound.ai/v1/integrations/resend/contacts/665f1c2ab3d4e5f601234567/events \ -H "Authorization: Bearer $PATHBOUND_API_KEY" \ -H "Idempotency-Key: 9a3e7b52-4c1d-4f68-b0a9-6d2e8f1c5b74" \ -H "Content-Type: application/json" \ -d '{ "event": "demo.booked", "payload": { "plan": "growth", "seats": 12 } }' ``` ### Response — `200 OK` ```json { "status": "success", "action_id": "670bd8e2a1b2c3d4e5f60774", "contact_id": "665f1c2ab3d4e5f601234567", "event": "demo.booked", "accepted_at": "2026-10-13T14:40:19.660Z", "note": "Resend accepted the event. Which automations it started, and what they send, is decided in Resend and is not visible here." } ``` The contact's timeline gets a `pathbound_action` event with `data.action_type` set to `resend_send_event` and the event's name. Pathbound keeps the names of the payload's fields and never their values. ### An event is sent at most once Resend does not deduplicate events: a second request is a second event, and it starts every automation again. So Pathbound sends an event one time and never repeats a send whose outcome it does not know. - **The same `Idempotency-Key` within 24 hours** returns the first answer with `replayed: true`. Nothing is sent again. - **When Resend does not answer** (a timeout, a dropped connection, a server error), the answer is `502` with the code `SEND_UNCONFIRMED`. Resend may have accepted the event. Do not retry: the same key gets `SEND_UNCONFIRMED` again for 24 hours, and a new key would send a second event. Check the automation's runs in Resend first, and send again under a new `Idempotency-Key` only if the event did not arrive. - **When Resend refuses the event** with a clear answer, nothing was accepted, and you can correct the request and send it again under the same key. ```json { "status": "error", "error": "Resend did not answer; the event may have been accepted", "code": "SEND_UNCONFIRMED", "action_id": "670bd8e2a1b2c3d4e5f60774", "hint": "Check the automation's runs in Resend before sending again. Do not retry this request: Resend does not deduplicate events, so a second one starts the automation again. To send deliberately, use a new Idempotency-Key." } ``` ### Error Codes for Events In addition to the [errors on every endpoint](#errors-on-every-endpoint). Except for `SEND_UNCONFIRMED`, every error here means that no event was sent. | Status | `code` | Meaning | What to do | | ------ | ------ | ------- | ---------- | | `400` | `UNKNOWN_FIELD` | The body carries a field other than `event` and `payload`. `details` names it. | Remove the field. | | `400` | none | `event` is not a usable name, or `payload` breaks a rule. `details` says which. | | | `404` | `CONTACT_NOT_FOUND` | No such contact in your workspace. | | | `422` | `CONTACT_EMAIL_MISSING` | The contact has no email address. | | | `422` | `CONTACT_EMAIL_INVALID` | The contact's email is not one plain address. | | | `422` | `CONTACT_UNSUBSCRIBED` | The contact is unsubscribed in Resend. | Nothing. Resubscribing is the contact's own choice. | | `422` | `RESEND_EVENT_NOT_FOUND` | Your Resend account defines no event of this name. | Use a name from [List Events](#list-events), or create the event in Resend. | | `422` | `RESEND_REJECTED` | Resend refused the request, for example because a payload value has the wrong type for a field the event declares. `error` is Resend's reason. | Correct the request. | | `502` | `RESEND_UNAVAILABLE` | Resend could not be reached while Pathbound read your account's events. | Retry with the same key. | | `502` | `RESEND_UNEXPECTED_RESPONSE` | Resend answered in a form Pathbound does not recognize. | Retry later with the same key. | | `502` | `SEND_UNCONFIRMED` | Resend did not answer. The event may have been accepted. | Do not retry. Check the automation's runs in Resend. | On these two endpoints a `403` from Resend is answered with `422 RESEND_REJECTED` and Resend's reason, and does not mark your key as dead. ## Create a Broadcast ``` POST /v1/integrations/resend/broadcasts ``` **Required scope:** `actions:write` · **Switch:** Create broadcasts Creates a broadcast in Resend: one email addressed to a whole segment. It is not sent until you call [Send a Broadcast](#send-a-broadcast). A broadcast always goes to a segment. ### Headers | Header | Required | Description | | ------ | -------- | ----------- | | `Idempotency-Key` | yes | A key of your choosing for this one broadcast. | ### Request Body | Field | Type | Required | Description | | ----- | ---- | -------- | ----------- | | `name` | string | yes | A name for the broadcast. | | `segment_id` | string | yes | The Resend segment to send to. | | `from` | string | yes | The sender, on a [verified domain](#rules-shared-by-these-endpoints). | | `subject` | string | yes | The subject line. At most 998 characters. | | `body` | string | yes | The email's content. A broadcast cannot use a template. | | `body_format` | string | no | `html` (the default) or `text`. | | `preview_text` | string | no | The preview text shown in the inbox. | | `reply_to` | string[] | no | Reply-to addresses. | A broadcast can use Resend's personalization placeholders in its subject and body: `{{{FIRST_NAME|fallback}}}`, `{{{LAST_NAME|fallback}}}`, `{{{EMAIL}}}` and `{{{RESEND_UNSUBSCRIBE_URL}}}`. ### Response — `200 OK` ```json { "status": "success", "action_id": "670bda15a1b2c3d4e5f60782", "broadcast_id": "559ac32e-9ef5-46fb-82a1-b76b840c0f7b" } ``` Keep `broadcast_id`: this API has no endpoint that lists broadcasts. After a `500`, a retry can create a second broadcast. Neither one is sent until you send it. ## Send a Broadcast ``` POST /v1/integrations/resend/broadcasts/:broadcast_id/send ``` **Required scope:** `actions:write` · **Switch:** Send broadcasts Sends a broadcast to every contact in its segment, now or at `scheduled_at`. Check the broadcast and the size of its segment before you call this. This endpoint takes no `Idempotency-Key`. Resend refuses to send a broadcast that has already been sent. ### Path Parameters | Parameter | Type | Description | | --------- | ---- | ----------- | | `broadcast_id` | string | The id returned by [Create a Broadcast](#create-a-broadcast). | ### Request Body | Field | Type | Required | Description | | ----- | ---- | -------- | ----------- | | `scheduled_at` | string | no | When Resend should send the broadcast, for example `2026-10-20T09:00:00Z`. The value is passed to Resend as written. Leave it out to send now. | ### Response — `200 OK` ```json { "status": "success", "action_id": "670bdb47a1b2c3d4e5f60790", "broadcast_id": "559ac32e-9ef5-46fb-82a1-b76b840c0f7b" } ``` With `scheduled_at`, the response carries it back as given. ### Error Codes for Broadcasts In addition to the [errors on every endpoint](#errors-on-every-endpoint): | Status | `code` | Meaning | | ------ | ------ | ------- | | `400` | `BODY_REQUIRED` | Create: the request carries no `body`. | | `422` | `FROM_NOT_VERIFIED` | Create: `from` cannot be used. `available_domains` lists the usable domains. | | `404` | `RESEND_NOT_FOUND` | Send: Resend does not know the broadcast. | --- # Apollo Sequences and Emails > Create and run Apollo sequences, enroll contacts in them, and send single emails now or at a set time through your workspace's Apollo mailboxes. Source: https://pathbound.ai/docs/api/apollo These endpoints run outreach through your workspace's Apollo account. You can create a sequence, enroll contacts in it, turn it on and off, and send one email to a contact now or at a set time. Apollo does the sending, from a mailbox of your Apollo team, and tracks opens and replies. Pathbound records each enrollment and each send on the contact's timeline. Use them when a script or an AI agent should start outreach from what Pathbound knows about a contact. Every enrollment and every send starts from a Pathbound contact ID. Before the first call, connect Apollo under **Integrations** with a connection that includes the sequence permissions, have a workspace admin turn on **Sequences** or **Single email** under **MCP Tools → Apollo**, and use an API key that has the `actions:write` [scope](/docs/getting-started/authentication#scopes). The MCP server's `apollo_*` tools call these endpoints ([tools reference](/docs/mcp/tools#engagement-actions-apollo-sequences--email)), except `apollo_add_segment_to_sequence`, which calls the [segment endpoint](/docs/api/segments#run-an-action-on-a-segment). :::caution These endpoints send real email to real people from your team's Apollo mailboxes. Nothing asks for confirmation first, and an email that has gone out cannot be recalled. ::: | Endpoint | Scope | What it does | | -------- | ----- | ------------ | | `GET /v1/integrations/apollo/senders` | `actions:write` | List the mailboxes you may send from. | | `GET /v1/integrations/apollo/sequences` | `actions:write` | List the team's sequences. | | `GET /v1/integrations/apollo/sequence-options` | `actions:write` | List the sending schedules and the rulesets in use. | | `POST /v1/integrations/apollo/sequences` | `actions:write` | Create an inactive sequence. | | `POST /v1/integrations/apollo/sequences/:sequence_id/activate` | `actions:write` | Turn a sequence on. | | `POST /v1/integrations/apollo/sequences/:sequence_id/pause` | `actions:write` | Turn a sequence off. | | `POST /v1/integrations/apollo/sequences/:sequence_id/schedule` | `actions:write` | Point a sequence at a sending schedule. | | `POST /v1/integrations/apollo/sequences/:sequence_id/contacts` | `actions:write` | Enroll up to 25 contacts in a sequence. | | `POST /v1/integrations/apollo/sequences/:sequence_id/contacts/stop` | `actions:write` | Stop or remove up to 25 contacts. | | `GET /v1/integrations/apollo/contacts/:contact_id/sequence-activity` | `actions:write` | Read what happened to one contact in sequences. | | `POST /v1/integrations/apollo/contacts/:contact_id/send_email` | `actions:write` | Send one email to a contact, now or at a set time. | | `GET /v1/integrations/apollo/scheduled-emails` | `actions:write` | List single emails and their state. | | `DELETE /v1/integrations/apollo/scheduled-emails/:id` | `actions:write` | Cancel a single email before it is sent. | The reads need `actions:write` too, because they show your team's mailboxes, sequences and outreach to each contact. `actions:write` is opt-in: a key has it only when it was selected at creation. ## What has to be on ### The Apollo connection The connection must include Apollo's sequence and email permissions. A connection made before September 2026 does not, and is answered `412 needs_reconsent` with the missing permissions in `details.missing_scopes`. A workspace admin reconnects Apollo under **Integrations → Apollo**. Apollo answers some of these calls only on a paid Apollo plan. When it refuses for that reason the answer is `412 plan_required`. ### The switches Two switches under **MCP Tools → Apollo** decide what may be done. Both are off until a workspace admin turns them on. | Switch | Endpoints it allows | | ------ | ------------------- | | **Sequences** | Every endpoint under `/v1/integrations/apollo/sequences`, `GET /v1/integrations/apollo/sequence-options`, and `GET /v1/integrations/apollo/contacts/:contact_id/sequence-activity`. | | **Single email** | `POST /v1/integrations/apollo/contacts/:contact_id/send_email`. A scheduled email reads the switch again when it is due. | | Either one | `GET /v1/integrations/apollo/senders`. | | Neither | `GET /v1/integrations/apollo/scheduled-emails` and `DELETE /v1/integrations/apollo/scheduled-emails/:id`. | Where a switch applies, the API reads it on every call and for every caller. A REST API key is held to it exactly as an MCP connection is, and a call with the switch off is answered `412 tool_disabled`. The MCP server also reads the switches to decide which tools it lists. Listing and canceling single emails are behind no switch in the API, so you can still review and cancel what is queued after **Single email** is turned off. Over MCP those two tools are listed only while the switch is on. ### The sending mailbox Every enrollment and every single email names the mailbox it goes out from, in `sender_account_id`. There is no default. The mailbox must be one that [`GET /v1/integrations/apollo/senders`](#list-senders) returns: active in your Apollo team and, when a workspace admin has set **Allowed sender mailboxes** under **Integrations → Apollo**, on that list. With no list set, every active mailbox of the Apollo team may be used. The mailbox is checked against Apollo again on every write, and once more when a scheduled email is due. A mailbox that is not allowed is refused with `403 sender_not_allowed`, and one that is inactive in Apollo with `400 sender_inactive`. Another mailbox is never used in its place. ### Idempotency Every `POST` on this page requires an `Idempotency-Key` header. Without it the call is refused with `400 IDEMPOTENCY_KEY_REQUIRED`, before the body is checked. | Header | Required | Description | | ------ | -------- | ----------- | | `Idempotency-Key` | yes, on every `POST` | Any string that is unique to the thing you intend to do. Use a new one for each new intention, and the same one when you retry. | Within 24 hours, the same key on the same endpoint does the action once: - While the first call is still running, a second is answered `409 ACTION_IN_PROGRESS`. - After the first call succeeded, a second is answered `200` with `replayed: true`, the `action_id` and a summary of the first result. On an enrollment or a stop the summary holds counts (`enrolled_count`, `skipped_count`, `updated_count`), not the per-contact lists. Nothing is done again. - After the first call failed, a second runs. The key alone identifies the action: a different body under a used key is not run. Activate and pause are separate endpoints, so a key used to activate does not block a pause. To activate the same sequence a second time, after a pause, use a new key. [Send an Email](#send-an-email) keeps its own record: the same key within 24 hours returns the email that the first call created, in whatever state it is now, with `replayed: true`. If your client times out, call again with the same key. See also [Idempotency](/docs/api/overview#idempotency). ### How long a call takes These endpoints call Apollo while you wait, and Pathbound paces its calls to Apollo. A call that Apollo rate-limits is retried for up to 45 seconds before it is answered `429 rate_limited`. ## List Senders ``` GET /v1/integrations/apollo/senders ``` **Required scope:** `actions:write` Returns the mailboxes an enrollment or a single email may name. Call it before you enroll or send, and take `sender_account_id` from it. ```bash curl https://api.pathbound.ai/v1/integrations/apollo/senders \ -H "Authorization: Bearer $PATHBOUND_API_KEY" ``` ### Response — `200 OK` ```json { "status": "success", "senders": [ { "sender_account_id": "66c8d9e0f1a2b3c4d5e6f701", "email": "sam@example.com", "user_id": "66b7c8d9e0f1a2b3c4d5e6f7", "is_default": true, "active": true, "provider": "Gmail", "daily_limit": 50 } ], "note": "Ask the user which mailbox to send from and confirm it by email address before enrolling or sending. There is no default." } ``` | Field | Description | | ----- | ----------- | | `senders[].sender_account_id` | Apollo's ID for the mailbox. This is the value to send as `sender_account_id`. | | `senders[].email` | The mailbox's address. | | `senders[].user_id` | The Apollo user the mailbox belongs to. | | `senders[].is_default` | Apollo's own default flag. Pathbound does not use it to choose a mailbox. | | `senders[].active` | Always `true` here: inactive mailboxes are not listed. | | `senders[].provider` | The mailbox provider, as Apollo names it. | | `senders[].daily_limit` | The mailbox's daily sending limit in Apollo, when it has one. | | `senders[].deliverability` | Apollo's figures for the mailbox, when it reports them: `open_rate`, `reply_rate`, `bounce_rate`, `spam_block_rate`, `domain_health_score`. | | `note` | A sentence for a person or an AI agent. With no mailbox available it says how to add one. | An empty `senders` means no mailbox may be used: link an active mailbox in Apollo, or have an admin widen **Allowed sender mailboxes**. The list is read from Apollo and kept for up to a minute. ## List Sequences ``` GET /v1/integrations/apollo/sequences ``` **Required scope:** `actions:write` Returns the sequences in your Apollo team, including inactive ones created through [Create a Sequence](#create-a-sequence). ### Query Parameters | Parameter | Type | Default | Description | | --------- | ---- | ------- | ----------- | | `q` | string | — | Part of a sequence name, up to 200 characters. | | `include_archived` | boolean | `false` | Include archived sequences. | | `page` | number | 1 | Page number, 1 to 500. | | `per_page` | number | 50 | Sequences per page, 1 to 100. | ### Response — `200 OK` ```json { "status": "success", "sequences": [ { "sequence_id": "66f1a2b3c4d5e6f701234567", "name": "Q4 renewals", "active": false, "archived": false, "num_steps": 3, "schedule_id": "66e0f1a2b3c4d5e6f7012345", "is_owner": true, "last_used_at": "2026-10-06T14:02:11.000Z", "stats": { "scheduled": 0, "delivered": 42, "opened": 30, "clicked": 4, "replied": 6, "bounced": 1 } } ], "page": 1, "per_page": 50, "total": 12, "total_pages": 1 } ``` | Field | Description | | ----- | ----------- | | `sequences[].sequence_id` | Apollo's ID for the sequence. Use it in the paths below. | | `sequences[].active` | Whether the sequence is on. | | `sequences[].num_steps` | The number of steps. | | `sequences[].schedule_id` | The sending schedule in force. Absent when the sequence uses Apollo's team default. Resolve it with [List Sequence Options](#list-sequence-options). | | `sequences[].ruleset_id` | The ruleset applied when the sequence was created. Absent when there is none. | | `sequences[].is_owner` | Whether the Apollo user behind the connection owns the sequence. | | `sequences[].stats` | Apollo's counts for the sequence: `scheduled`, `delivered`, `opened`, `clicked`, `replied`, `bounced`. Also `reply_rate`, `open_rate` and `bounce_rate`, as Apollo reports them. | | `total`, `total_pages` | Apollo's totals for the search. They count archived sequences too. | Archived sequences are taken out of each page after Apollo returns it, so a page can hold fewer than `per_page` sequences. ## List Sequence Options ``` GET /v1/integrations/apollo/sequence-options ``` **Required scope:** `actions:write` Returns the two things a sequence can be pointed at: the team's sending schedules, and the rulesets already applied to the team's sequences. It takes no parameters. A sending schedule decides the days and hours Apollo may send a sequence's emails. You can read the schedules and attach one to a sequence. The windows themselves are edited only in Apollo, under **Settings → Sequences → Schedules**. A ruleset is a set of shared settings applied to a sequence. Apollo has no way to list or name rulesets, so this endpoint reports the ruleset IDs found on the team's sequences, with the sequences that use each one. ### Response — `200 OK` ```json { "status": "success", "schedules": [ { "schedule_id": "66e0f1a2b3c4d5e6f7012345", "name": "Normal Business Hours", "is_default": true, "time_zone": "America/Chicago", "use_contacts_time_zone": false, "skip_holidays": true, "windows": { "monday": [[8, 17]], "tuesday": [[8, 17]], "wednesday": [[8, 17]], "thursday": [[8, 17]], "friday": [[8, 17]] }, "sending_hours": "Mon–Fri 8:00–17:00" } ], "rulesets_in_use": [ { "ruleset_id": "66d9e0f1a2b3c4d5e6f70123", "sequence_count": 4, "used_by": ["Q4 renewals", "Trial follow-up"] } ], "note": "Sending windows are set in Apollo (Settings → Sequences → Schedules); …" } ``` | Field | Description | | ----- | ----------- | | `schedules[].windows` | Day of the week to a list of `[start_hour, end_hour]` pairs in 24-hour time. A day without a window is left out. | | `schedules[].sending_hours` | The same windows as one line of text. Empty when the schedule sends on no day. | | `schedules[].time_zone` | The time zone the windows are in. | | `schedules[].use_contacts_time_zone` | When `true`, Apollo sends in each contact's own time zone instead. | | `rulesets_in_use[].used_by` | Up to five sequences that use the ruleset, so a person can recognize it. | `rulesets_in_use` is read from the team's first 100 sequences. ## Create a Sequence ``` POST /v1/integrations/apollo/sequences ``` **Required scope:** `actions:write` **Required header:** `Idempotency-Key` Creates a sequence of 1 to 10 email steps in your Apollo team. The sequence is created inactive and nobody is enrolled, so this call sends nothing. Its steps are ready to send: once contacts are enrolled, [activating](#activate-or-pause-a-sequence) the sequence is all that starts the emails. ### Request Body | Field | Type | Required | Description | | ----- | ---- | -------- | ----------- | | `name` | string | yes | The sequence's name, 1 to 200 characters. | | `steps` | object[] | yes | 1 to 10 email steps, in order. | | `steps[].body` | string | yes | The email body, up to 100,000 characters. Apollo's personalization variables, such as `{{first_name}}`, are passed on as written. | | `steps[].body_format` | string | no | `html` (default) or `text`. Text is converted to HTML. | | `steps[].subject` | string | for a new thread | The subject, up to 998 characters. Required on a step that starts a new thread. Leave it out on a reply. | | `steps[].wait_time` | number | yes | How long to wait after the previous step, 0 to 365. | | `steps[].wait_mode` | string | yes | The unit of `wait_time`: `minute`, `hour` or `day`. | | `steps[].thread_mode` | string | no | `new_thread` or `reply_to_thread`. The first step is `new_thread` and cannot be anything else. Later steps default to `reply_to_thread`. | | `steps[].include_signature` | boolean | no | Append the sender's Apollo signature. Default `true`. | | `labels` | string[] | no | Up to 10 Apollo labels, each 1 to 100 characters. | | `max_emails_per_day` | number | no | The sequence's daily cap in Apollo, 1 to 1000. | | `schedule_id` | string | no | A sending schedule from [List Sequence Options](#list-sequence-options). Without it the sequence uses Apollo's team default. You can change it later. | | `ruleset_id` | string | no | A ruleset from [List Sequence Options](#list-sequence-options). Without it the sequence has none. It cannot be set or changed after the sequence is created. | ```json { "name": "Q4 renewals", "steps": [ { "subject": "Your renewal in December", "body": "

Hi {{first_name}},

Your plan renews on December 1.

", "wait_time": 0, "wait_mode": "day" }, { "body": "

Hi {{first_name}}, a quick follow-up on my note below.

", "wait_time": 3, "wait_mode": "day" } ] } ``` Some settings are fixed. Every step is an automatic email with one version of its text. The sequence finishes for a contact who replies or is marked interested, pauses for one who answers with an out-of-office message, and keeps to Apollo's bounce list. ### Response — `201 Created` ```json { "status": "success", "action_id": "670f3a1b2c3d4e5f60718293", "sequence_id": "66f1a2b3c4d5e6f701234567", "sequence_name": "Q4 renewals", "active": false, "num_steps": 2, "note": "Created as an inactive draft. …" } ``` `schedule_id` and `ruleset_id` are returned when the sequence has them. `action_id` is the ID of Pathbound's record of the action. ## Activate or Pause a Sequence ``` POST /v1/integrations/apollo/sequences/:sequence_id/activate POST /v1/integrations/apollo/sequences/:sequence_id/pause ``` **Required scope:** `actions:write` **Required header:** `Idempotency-Key` `activate` turns the sequence on: Apollo starts sending its steps to every enrolled contact, from the mailbox each was enrolled with, on the sequence's schedule. `pause` turns it off: every enrolled contact is paused and nothing more is sent until the sequence is activated again. To stop particular contacts, use [Stop Contacts](#stop-contacts). Neither takes a body. ### Path Parameters | Parameter | Type | Description | | --------- | ---- | ----------- | | `sequence_id` | string | Apollo's sequence ID, from [List Sequences](#list-sequences) or [Create a Sequence](#create-a-sequence). | ### Response — `200 OK` ```json { "status": "success", "action_id": "670f3a1c2c3d4e5f60718294", "sequence_id": "66f1a2b3c4d5e6f701234567", "sequence_name": "Q4 renewals", "active": true, "num_steps": 2, "scheduled": 18, "note": "Sequence is active: Apollo now sends its steps to enrolled contacts on the sequence schedule. …" } ``` `active` is the state Apollo reports after the call, not the one you asked for. `scheduled` is the sequence's scheduled count, as Apollo reports it. ## Set a Sequence's Schedule ``` POST /v1/integrations/apollo/sequences/:sequence_id/schedule ``` **Required scope:** `actions:write` **Required header:** `Idempotency-Key` Points a sequence at one of the team's sending schedules. This changes the days and hours its emails may go out, for contacts already enrolled too. Only the schedule is changed: the steps, the owner and the on or off state are not touched. ### Request Body | Field | Type | Required | Description | | ----- | ---- | -------- | ----------- | | `schedule_id` | string | yes | A `schedule_id` from [List Sequence Options](#list-sequence-options). | A schedule that is not in your Apollo team is refused with `404 not_found`. A schedule with no sending windows is refused with `400 validation`, because Apollo does not accept an empty one. ### Response — `200 OK` ```json { "status": "success", "action_id": "670f3a1d2c3d4e5f60718295", "sequence_id": "66f1a2b3c4d5e6f701234567", "sequence_name": "Q4 renewals", "schedule_id": "66e0f1a2b3c4d5e6f7012345", "schedule_name": "Normal Business Hours", "sending_hours": "Mon–Fri 8:00–17:00", "time_zone": "America/Chicago", "use_contacts_time_zone": false, "active": false, "num_steps": 2, "note": "Emails for this sequence now go out Mon–Fri 8:00–17:00 (America/Chicago). …" } ``` ## Enroll Contacts ``` POST /v1/integrations/apollo/sequences/:sequence_id/contacts ``` **Required scope:** `actions:write` **Required header:** `Idempotency-Key` Enrolls up to 25 contacts in an existing sequence, from the mailbox you name. If the sequence is active, Apollo starts emailing them on the sequence's schedule. If it is inactive, nothing is sent until it is activated. ### Path Parameters | Parameter | Type | Description | | --------- | ---- | ----------- | | `sequence_id` | string | Apollo's sequence ID. | ### Request Body | Field | Type | Required | Description | | ----- | ---- | -------- | ----------- | | `contact_ids` | string[] | yes | 1 to 25 Pathbound contact IDs. Duplicates count once. | | `sender_account_id` | string | yes | The mailbox to send from: a `sender_account_id` from [List Senders](#list-senders). | | `start_at` | string | no | An ISO 8601 time in the future. The contacts are enrolled paused, and Apollo starts them at that time. Without it they start in the sequence's next sending window. | | `tags` | string[] | no | Up to 20 tags, each up to 64 characters, written on every contact that was enrolled. | ```bash curl -X POST https://api.pathbound.ai/v1/integrations/apollo/sequences/66f1a2b3c4d5e6f701234567/contacts \ -H "Authorization: Bearer $PATHBOUND_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: q4-renewals-batch-1" \ -d '{ "contact_ids": [ "665f1c2ab3d4e5f601234567", "665f1c2ab3d4e5f601234568", "665f1c2ab3d4e5f601234569" ], "sender_account_id": "66c8d9e0f1a2b3c4d5e6f701", "tags": ["campaign:q4-renewals"] }' ``` Each contact is matched to a contact in your Apollo team by email address, or created there. That costs no Apollo credits. Apollo decides whom it accepts. It refuses a contact who is already in another sequence, whose email is unverified, who is at a do-not-contact stage, and so on. Apollo has options that override these refusals, and Pathbound never sends them. A refused contact comes back in `skipped` with Apollo's reason. ### Response — `200 OK` ```json { "status": "success", "action_id": "670f3a1e2c3d4e5f60718296", "sequence": { "sequence_id": "66f1a2b3c4d5e6f701234567", "name": "Q4 renewals" }, "sender_email": "sam@example.com", "requested": 3, "enrolled": 2, "enrolled_contact_ids": ["665f1c2ab3d4e5f601234567", "665f1c2ab3d4e5f601234568"], "skipped": [ { "contact_id": "665f1c2ab3d4e5f601234569", "reason": "no_email" } ], "tags_applied": ["campaign:q4-renewals"] } ``` `start_at` is echoed when you sent one. A call in which every contact is skipped is still a `200`, with `enrolled: 0`. | `skipped[].reason` | Meaning | | ------------------ | ------- | | `not_found_in_pathbound` | No contact with this ID in your workspace. | | `no_email` | The contact has no email address. | | `already_enrolled` | The contact is already active or paused in this sequence. | | `bridge_failed` | The contact could not be matched or created in Apollo. | | anything else | Apollo's own reason for refusing the contact, as Apollo wrote it. | A workspace may enroll a set number of contacts a day through these endpoints and the MCP tools together, counted in UTC. Skipped contacts do not count. A call that would pass the cap is refused with `429 budget_exceeded`. To enroll every member of a saved segment as a background job, use [`POST /v1/segments/:ref/actions`](/docs/api/segments#run-an-action-on-a-segment). ## Stop Contacts ``` POST /v1/integrations/apollo/sequences/:sequence_id/contacts/stop ``` **Required scope:** `actions:write` **Required header:** `Idempotency-Key` Stops up to 25 contacts in a sequence so that no further step is sent to them, or removes them from it. ### Request Body | Field | Type | Required | Description | | ----- | ---- | -------- | ----------- | | `contact_ids` | string[] | yes | 1 to 25 Pathbound contact IDs. | | `mode` | string | no | `stop` (default) ends future steps and keeps the contact's history in the sequence. `remove` takes the contact out of the sequence. | | `reason` | string | no | A short reason, up to 300 characters. It is kept on Pathbound's record and on the contact's timeline. | ### Response — `200 OK` ```json { "status": "success", "action_id": "670f3a1f2c3d4e5f60718297", "sequence_id": "66f1a2b3c4d5e6f701234567", "mode": "stop", "requested": 2, "updated": 1, "updated_contact_ids": ["665f1c2ab3d4e5f601234567"], "skipped": [ { "contact_id": "665f1c2ab3d4e5f601234569", "reason": "not_enrolled" } ] } ``` `updated` counts the contacts Apollo was asked to stop or remove. A contact is skipped with `not_found_in_pathbound`, or with `not_enrolled` when Pathbound has never enrolled or emailed it through Apollo. Pathbound does not check with Apollo that each of the others was in this sequence. ## Get a Contact's Sequence Activity ``` GET /v1/integrations/apollo/contacts/:contact_id/sequence-activity ``` **Required scope:** `actions:write` Returns what happened to one contact in Apollo sequences: Pathbound's own enrollment records, and Apollo's activity feed for the contact. This read counts toward your usage the way reading the contact's timeline does. See [Usage and metering](/docs/api/overview#usage-and-metering). ### Path Parameters | Parameter | Type | Description | | --------- | ---- | ----------- | | `contact_id` | string | The Pathbound contact ID. | ### Query Parameters | Parameter | Type | Default | Description | | --------- | ---- | ------- | ----------- | | `sequence_id` | string | — | Only this sequence. | ### Response — `200 OK` ```json { "status": "success", "contact_id": "665f1c2ab3d4e5f601234567", "apollo_contact_id": "66a6b7c8d9e0f1a2b3c4d5e6", "enrollments": [ { "sequence_id": "66f1a2b3c4d5e6f701234567", "sequence_name": "Q4 renewals", "status": "replied", "sender_email": "sam@example.com", "enrolled_at": "2026-10-06T14:02:11.000Z", "last_event_at": "2026-10-08T09:41:27.000Z" } ], "events": [ { "type": "replied", "occurred_at": "2026-10-08T09:41:27.000Z", "sequence_id": "66f1a2b3c4d5e6f701234567", "sequence_name": "Q4 renewals", "step_position": 1 }, { "type": "enrolled", "occurred_at": "2026-10-06T14:02:11.000Z", "sequence_id": "66f1a2b3c4d5e6f701234567", "sequence_name": "Q4 renewals" } ] } ``` | Field | Description | | ----- | ----------- | | `enrollments[]` | Pathbound's records of the enrollments made through Pathbound, latest first. `start_at` is present for a delayed start, and `reason` when a stop gave one. | | `enrollments[].status` | `enrolled`, `paused`, `replied`, `bounced`, `finished`, `stopped`, `removed` or `failed`. | | `events[]` | Up to 50 entries of Apollo's activity feed for the contact. | | `events[].type` | `enrolled`, `paused`, `resumed`, `replied`, `completed`, `removed` or `failed`. | | `note` | Present when the contact was never enrolled or emailed through Apollo by Pathbound. `events` is then empty. | Opens, clicks and replies to each message are on the contact's timeline, as [events](#what-lands-on-the-contacts-timeline). ## Send an Email ``` POST /v1/integrations/apollo/contacts/:contact_id/send_email ``` **Required scope:** `actions:write` **Required header:** `Idempotency-Key` Sends one email to a contact through Apollo, from the mailbox you name, now or at `send_at`. The email goes to the contact's email address, read from the contact record. The request cannot set it. ### Path Parameters | Parameter | Type | Description | | --------- | ---- | ----------- | | `contact_id` | string | The Pathbound contact to email. | ### Request Body | Field | Type | Required | Description | | ----- | ---- | -------- | ----------- | | `sender_account_id` | string | yes | The mailbox to send from: a `sender_account_id` from [List Senders](#list-senders). | | `subject` | string | yes | The subject, up to 998 characters. | | `body` | string | yes | The email body. | | `body_format` | string | no | `html` (default) or `text`. Text is converted to HTML. | | `cc` | string[] | no | Up to 5 addresses. | | `bcc` | string[] | no | Up to 5 addresses. | | `track` | boolean | no | Apollo's open and click tracking. Default `true`. | | `send_at` | string | no | An ISO 8601 time within the next 30 days. Without it, or with a time less than a minute away, the email is sent now. | ```bash curl -X POST https://api.pathbound.ai/v1/integrations/apollo/contacts/665f1c2ab3d4e5f601234567/send_email \ -H "Authorization: Bearer $PATHBOUND_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: followup-665f1c2a-2026-10-13" \ -d '{ "sender_account_id": "66c8d9e0f1a2b3c4d5e6f701", "subject": "Following up on Thursday", "body": "Hi Ada,\n\nHere are the notes from our call.", "body_format": "text" }' ``` ### Response — `200 OK` or `202 Accepted` The answer is `202` when the email was scheduled and `200` otherwise. ```json { "status": "success", "email": { "scheduled_email_id": "670f3b2c3d4e5f6071829304", "contact_id": "665f1c2ab3d4e5f601234567", "sender_account_id": "66c8d9e0f1a2b3c4d5e6f701", "subject": "Following up on Thursday", "send_at": "2026-10-13T15:04:05.000Z", "status": "sent", "attempts": 1, "apollo_message_id": "670f3b2d9a8b7c6d5e4f3a2b", "sent_from": "sam@example.com", "sent_at": "2026-10-13T15:04:08.000Z", "action_id": "670f3b2c3d4e5f6071829303", "created_at": "2026-10-13T15:04:05.000Z" } } ``` **A `200` does not mean the email was sent. Read `email.status`.** Once the request has passed its checks, whatever happens to the email is reported on the email, not as an HTTP error. | `email.status` | Meaning | | -------------- | ------- | | `scheduled` | Waiting for `send_at`. | | `sent` | Apollo reports the email sent. `sent_at` is when, and `sent_from` is the address Apollo used. | | `sending` | Apollo has the email and has not confirmed it yet, because it paces each mailbox. Pathbound keeps checking. Look again with [List Scheduled Emails](#list-scheduled-emails). | | `delayed` | Not sent yet. It is tried again at `next_attempt_at`, and `failure` says why. | | `failed` | Not sent, and it will not be tried again. `failure.code` and `failure.message` say why. | | `canceled` | Canceled before it was sent. | The body of the email is not returned. ### What happens when the email is due Pathbound holds a scheduled email itself and hands it to Apollo at `send_at`. At that moment, and for an email sent now, it checks again that: - the API key that asked is still active and still has `actions:write`, and its owner is still a verified member of the workspace (`principal_revoked` otherwise); - **Single email** is still on and the Apollo connection still works; - the contact still exists, and its email address is the one it had when you asked (`recipient_changed` otherwise); - the mailbox may still be used. An email that fails one of these ends `failed`. It is never sent from another mailbox or to another address. An email is `delayed` and tried again when the mailbox has reached its Apollo sending limit (an hour later), when Apollo rate-limits the call, when Apollo fails (15 minutes later), or when the workspace has sent its daily allowance of single emails (at the start of the next day, UTC). After four attempts it ends `failed`. An email is handed to Apollo at most once. If Apollo's answer is lost, the email stays `sending` while Pathbound asks Apollo for its status, and it ends `failed` with `failure.code` `unconfirmed` if Apollo never confirms it. Such an email may have gone out: look at the message in Apollo before you send it again. `failure.code` is one of the [error codes](#errors) below, `principal_revoked`, `recipient_changed`, `unconfirmed`, or Apollo's own reason when Apollo reports that the send failed. ## List Scheduled Emails ``` GET /v1/integrations/apollo/scheduled-emails ``` **Required scope:** `actions:write` Returns the single emails created through [Send an Email](#send-an-email), whatever their state, latest `send_at` first. Emails that were sent at once are listed too. ### Query Parameters | Parameter | Type | Default | Description | | --------- | ---- | ------- | ----------- | | `contact_id` | string | — | Only emails to this contact. | | `status` | string | — | One of `scheduled`, `sending`, `delayed`, `sent`, `failed`, `canceled`. | | `limit` | number | 50 | The most emails to return, 1 to 200. | ### Response — `200 OK` ```json { "status": "success", "emails": [ { "scheduled_email_id": "670f3b2c3d4e5f6071829305", "contact_id": "665f1c2ab3d4e5f601234567", "sender_account_id": "66c8d9e0f1a2b3c4d5e6f701", "subject": "Your renewal in December", "send_at": "2026-10-20T14:00:00.000Z", "status": "scheduled", "attempts": 0, "sent_from": "sam@example.com", "action_id": "670f3b2c3d4e5f6071829306", "created_at": "2026-10-13T15:10:00.000Z" } ] } ``` Each entry has the fields of `email` in [Send an Email](#send-an-email). Before an email is sent, `sent_from` is the address of the mailbox you chose. ## Cancel a Scheduled Email ``` DELETE /v1/integrations/apollo/scheduled-emails/:id ``` **Required scope:** `actions:write` Cancels an email that is `scheduled` or `delayed`. An email in any other state cannot be canceled, and the call is refused with `409 validation` and the state in `details.status`. The `Idempotency-Key` header is optional here. ### Path Parameters | Parameter | Type | Description | | --------- | ---- | ----------- | | `id` | string | The `scheduled_email_id` of the email. | ### Response — `200 OK` ```json { "status": "success", "email": { "scheduled_email_id": "670f3b2c3d4e5f6071829305", "contact_id": "665f1c2ab3d4e5f601234567", "sender_account_id": "66c8d9e0f1a2b3c4d5e6f701", "subject": "Your renewal in December", "send_at": "2026-10-20T14:00:00.000Z", "status": "canceled", "attempts": 0, "sent_from": "sam@example.com", "action_id": "670f3b2c3d4e5f6071829306", "created_at": "2026-10-13T15:10:00.000Z" } } ``` An email that was still `scheduled` created nothing in Apollo, so there is nothing to remove there. A `delayed` email has been through a send attempt: the contact was matched or created in Apollo and a draft message was written there before the send was refused, and canceling does not remove them. Delete the draft in Apollo if you do not want it kept. ## What lands on the contact's timeline Each enrollment, each stop and each email that was sent is written to the contact's timeline as a `pathbound_action` [event](/docs/api/events). Its `data.action_type` is `apollo_add_to_sequence`, `apollo_stop_in_sequence` or `apollo_send_email`, and `data` carries the sequence, the subject and the sending address, as they apply. What recipients then do is read back from Apollo about every 30 minutes and written to the same timeline: `apollo_email_sent`, `apollo_email_opened`, `apollo_email_clicked`, `apollo_email_replied`, `apollo_email_bounced`, `apollo_email_unsubscribed`, `apollo_email_spam_blocked`, `apollo_sequence_finished`, `apollo_sequence_stopped` and `apollo_sequence_failed`. ## Errors An error has the shape `{ "status": "error", "code": "…", "error": "…" }`, with a `hint` and `details` where the tables name them. See [Errors](/docs/api/overview#errors) for the answers every endpoint shares. ### Every endpoint | Status | `code` | Meaning | | ------ | ------ | ------- | | `400` | none | A path, query or body value failed validation. `details` lists each field. | | `403` | none | The API key does not have `actions:write`. `required_scopes` lists it. | | `412` | `integration_missing` | Apollo is not connected. Connect it under **Integrations**. | | `412` | `invalid_credentials` | Apollo no longer accepts the connection. Reconnect Apollo. | | `412` | `needs_reconsent` | The connection predates the permissions this endpoint needs (`details.missing_scopes`). A workspace admin reconnects Apollo. | | `412` | `tool_disabled` | The switch for this endpoint is off (`details.tool`). A workspace admin turns it on under **MCP Tools → Apollo**. | | `412` | `plan_required` | Apollo refused the call for the Apollo plan or for the permissions of the connection. If the plan is a paid one, reconnect Apollo. | | `403` | `forbidden` | Apollo refused the call for another reason. The message carries Apollo's own. | | `404` | `not_found` | Apollo answered that the record does not exist. | | `409` | `dnc_blocked` | Apollo's Do Not Contact settings block the call. | | `422` | `validation` | Apollo rejected the request. The message carries Apollo's own. | | `429` | `mailbox_limit` | Apollo answered that the sending mailbox reached its limit. Try later, or choose another mailbox. | | `429` | `rate_limited` | Apollo's rate limit held after retries. `details.retry_after` is in seconds. | | `502` | `upstream_error` | Apollo failed or did not answer. | | `500` | `upstream_error` or none | The call failed unexpectedly. | The first four `412` answers are checked in the order of the table: connected, accepted, permissions, switch. `GET /v1/integrations/apollo/scheduled-emails` and `DELETE /v1/integrations/apollo/scheduled-emails/:id` do not call Apollo. Of the rows above, only the `400` and the scope `403` apply to them. ### Every `POST` | Status | `code` | Meaning | | ------ | ------ | ------- | | `400` | `IDEMPOTENCY_KEY_REQUIRED` | The `Idempotency-Key` header is missing. | | `409` | `ACTION_IN_PROGRESS` | A call with this key is still running. Wait and call again with the same key. | ### Sequences | Status | `code` | Meaning | | ------ | ------ | ------- | | `400` | `validation` | Create: the first step is not a new thread, or a step that starts a new thread has no subject. Schedule: the schedule has no sending windows. | | `404` | `not_found` | Schedule: the `schedule_id` is not one of your team's schedules. | ### Enrollment, stops and activity | Status | `code` | Meaning | | ------ | ------ | ------- | | `400` | `validation` | Enroll: `start_at` is not in the future. | | `400` | `sender_inactive` | Enroll: the mailbox is inactive or disconnected in Apollo. Choose another from [List Senders](#list-senders). | | `403` | `sender_not_allowed` | Enroll: the mailbox is not one you may send from. An admin can widen **Allowed sender mailboxes**. | | `404` | none | Activity: the contact does not exist in your workspace. | | `429` | `budget_exceeded` | Enroll: the workspace's enrollments for the day are used (`details.cap`, `details.used`). Try again the next day, UTC. | ### Single emails | Status | `code` | Meaning | | ------ | ------ | ------- | | `400` | `validation` | `send_at` is more than 30 days ahead. | | `400` | `sender_inactive` | Send: the mailbox is inactive or disconnected in Apollo. | | `403` | `sender_not_allowed` | Send: the mailbox is not one you may send from. | | `404` | `not_found` | The contact, or the scheduled email, does not exist in your workspace. | | `409` | `validation` | Cancel: the email is no longer `scheduled` or `delayed` (`details.status`). | | `422` | `validation` | The contact has no email address (`details.code` is `CONTACT_EMAIL_MISSING`). | Everything Apollo refuses after these checks, such as a contact under Do Not Contact or a mailbox at its limit, is reported in `email.status` and `email.failure`, not as an HTTP error. --- # Instantly > Add a person to an Instantly campaign and set a lead's interest status in your workspace's own Instantly account. Source: https://pathbound.ai/docs/api/instantly These two endpoints write to the Instantly account your workspace has connected. One adds a person to a campaign as a lead, so that they enter the campaign's sending sequence. The other sets a lead's interest status, for example after a reply tells you whether they are interested. Both take an email address, not a Pathbound contact id. The person does not have to be a contact in your workspace, and neither request creates or changes a Pathbound contact. Before the first call, connect Instantly under **Integrations → Instantly** in the dashboard, create an API key with the `actions:write` scope, and have a workspace admin switch the action on: see [Before you call](#before-you-call). The MCP tools `instantly_add_lead` and `instantly_update_lead_interest` call the same endpoints: see [MCP tools](/docs/mcp/tools#cold-email-actions-instantly). :::caution Adding a lead puts a real person into a campaign's sending sequence. Instantly then emails them as the campaign is set up to do. ::: | Endpoint | Scope | What it does | | -------- | ----- | ------------ | | [`POST /v1/integrations/instantly/leads`](#add-a-lead-to-a-campaign) | `actions:write` | Adds a lead to an Instantly campaign. | | [`POST /v1/integrations/instantly/leads/interest`](#update-a-leads-interest) | `actions:write` | Sets the interest status of a lead. | ## Before you call **The scope.** `actions:write` is opt-in: a key holds it only when it was selected for that key. See [Scopes](/docs/getting-started/authentication#scopes). **Instantly is connected.** The requests are made with your workspace's Instantly API key. **The admin switches.** Each endpoint has a switch of its own, under **MCP Tools → Instantly** in the dashboard. Both are off until a workspace admin turns them on. The page is named for AI agents, but the API checks the switch on every request: an API key is held to it exactly as an agent is. With the switch off, the endpoint answers `412` with the code `tool_disabled`. | Switch | Name in error messages | Endpoint it governs | | ------ | ---------------------- | ------------------- | | Add leads to campaigns | `instantly_add_leads` | Add a lead to a campaign. | | Update lead interest | `instantly_update_interest` | Update a lead's interest. | [`GET /v1/tenant/capabilities`](/docs/api/workspace#capabilities) reports which switches are on, under `tool_settings`. ## Add a Lead to a Campaign ``` POST /v1/integrations/instantly/leads ``` **Required scope:** `actions:write` · **Switch:** Add leads to campaigns Adds one person to an Instantly campaign. ### Headers | Header | Required | Description | | ------ | -------- | ----------- | | `Idempotency-Key` | yes | A key of your choosing for this one lead. See [Idempotency](/docs/api/overview#idempotency). | ### Request Body | Field | Type | Required | Description | | ----- | ---- | -------- | ----------- | | `campaign_id` | string | yes | Instantly's id for the campaign. No endpoint of this API lists campaigns. | | `email` | string | yes | The lead's email address. It is sent to Instantly in lowercase. | | `first_name` | string | no | At most 255 characters. | | `last_name` | string | no | At most 255 characters. | | `company_name` | string | no | At most 255 characters. | | `phone` | string | no | At most 64 characters. | | `personalization` | string | no | A personalization text for the campaign's emails. At most 5,000 characters. | | `custom_variables` | object | no | Custom variables for the lead, passed to Instantly as given. Use string, number or boolean values. | ```bash curl -X POST https://api.pathbound.ai/v1/integrations/instantly/leads \ -H "Authorization: Bearer $PATHBOUND_API_KEY" \ -H "Idempotency-Key: 3f8c2d17-6a4b-4e59-9c0d-1b7e5a2f8d63" \ -H "Content-Type: application/json" \ -d '{ "campaign_id": "0198f4d1-3c2a-7b6e-8d15-9e7a4c2b1f03", "email": "ada@example.com", "first_name": "Ada", "company_name": "Analytical Engines", "custom_variables": { "plan": "growth", "seats": 12 } }' ``` ### Response — `200 OK` ```json { "status": "success", "action_id": "670bdd31a1b2c3d4e5f607a2", "lead_id": "0199c7a2-5b1e-7d3f-9a44-2f6c8e1b7d90", "campaign_id": "0198f4d1-3c2a-7b6e-8d15-9e7a4c2b1f03" } ``` `lead_id` is Instantly's id for the new lead. A request with an `Idempotency-Key` that succeeded in the last 24 hours returns `status`, `action_id`, `lead_id` and `replayed: true`, and adds nothing. After a `500`, the lead may or may not have been added. Check the campaign in Instantly before you send the request again. ## Update a Lead's Interest ``` POST /v1/integrations/instantly/leads/interest ``` **Required scope:** `actions:write` · **Switch:** Update lead interest Sets the interest status of a lead in Instantly. The lead is named by its email address. This endpoint takes no `Idempotency-Key`: setting the same value twice changes nothing. ### Request Body | Field | Type | Required | Description | | ----- | ---- | -------- | ----------- | | `lead_email` | string | yes | The lead's email address. It is sent to Instantly in lowercase. | | `interest_value` | integer or `null` | yes | Instantly's code for the interest status. `null` puts the lead back to the neutral "Lead" status. | | `campaign_id` | string | no | Limits the change to one campaign. | `interest_value` is passed to Instantly unchanged, and Pathbound does not check it against a list. These are the codes Instantly uses: | `interest_value` | Status | | ---------------- | ------ | | `1` | Interested | | `2` | Meeting booked | | `3` | Meeting completed | | `4` | Closed | | `-1` | Out of office | | `-2` | Wrong person | | `-3` | Not interested | | `null` | Lead (neutral) | ```bash curl -X POST https://api.pathbound.ai/v1/integrations/instantly/leads/interest \ -H "Authorization: Bearer $PATHBOUND_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "lead_email": "ada@example.com", "interest_value": 1 }' ``` ### Response — `200 OK` ```json { "status": "success", "action_id": "670bde04a1b2c3d4e5f607b0", "lead_email": "ada@example.com", "interest_value": 1 } ``` ## Error Codes Both endpoints answer with these. | Status | `code` | Meaning | What to do | | ------ | ------ | ------- | ---------- | | `400` | `IDEMPOTENCY_KEY_REQUIRED` | Add a lead: the `Idempotency-Key` header is missing. | | | `400` | none | A field is missing or not valid. `details` names it. | | | `403` | none | The key lacks `actions:write`. `required_scopes` names it. | | | `409` | `ACTION_IN_PROGRESS` | Add a lead: a request with this `Idempotency-Key` is still being processed. | Ask again with the same key. | | `412` | `integration_missing` | Instantly is not connected. | Connect Instantly. | | `412` | `invalid_credentials` | The stored Instantly API key no longer works. | Connect Instantly again with a working key. | | `412` | `tool_disabled` | A workspace admin has not switched this action on. | Switch it on under **MCP Tools → Instantly**. | | `429` | `RATE_LIMITED` | Instantly's rate limit was reached. | Retry after `retry_after_seconds`. | | `500` | none | The request failed. A request that Instantly refuses for any reason other than the key or its rate limit is also answered this way, without Instantly's reason. | Check `campaign_id` and the lead in Instantly. | When Instantly answers a request with `401` or `403`, Pathbound treats the key as dead: that request and every later one is answered with `412 invalid_credentials` until Instantly is connected again. ## Reading what Instantly sent These endpoints write only. The emails a campaign exchanged with a person who is a Pathbound contact are read with [Get Contact Conversations](/docs/api/contacts#get-contact-conversations), in its `instantly` block. --- # Enrichment and Prospecting > Enrich the contacts and companies you already have, find people at companies you track, and search a data provider for new companies and people, saved as records in your workspace. Source: https://pathbound.ai/docs/api/enrichment These endpoints bring data from a data provider into your workspace. Enrichment fills in contacts and companies you already have. Prospecting finds people at companies you already track, or searches the provider's database for companies and people you do not have yet and saves the matches as records. Two providers can run a call: Apollo and Hunter. You never name one. Pathbound picks the workspace's connected provider, and every response says which one ran in `via`. Before the first call, connect Apollo or Hunter under **Integrations**, have a workspace admin turn on the switch for the kind of work under **MCP Tools → Apollo** or **MCP Tools → Hunter**, and use an API key that has the `enrichment:write` [scope](/docs/getting-started/authentication#scopes). The MCP tools `enrich_contacts`, `enrich_companies`, `find_people_at_companies`, `prospect_people` and `prospect_companies` call these endpoints ([tools reference](/docs/mcp/tools#enrichment-and-prospecting-actions)). :::caution These calls spend your workspace's own credits with the provider and write contacts and companies into your workspace. Nothing asks for confirmation first. Every response reports what was spent in `credits_consumed`. ::: | Endpoint | Scope | What it does | | -------- | ----- | ------------ | | `POST /v1/enrichment/contacts` | `enrichment:write` | Enrich up to 25 contacts you already have. | | `POST /v1/enrichment/companies` | `enrichment:write` | Enrich up to 25 companies you already have. | | `POST /v1/prospecting/people-at-companies` | `enrichment:write` | Find people at up to 25 companies you already have and save them as contacts. | | `POST /v1/prospecting/people` | `enrichment:write` | Search for new people by person and company filters and save them with their employers. | | `POST /v1/prospecting/companies` | `enrichment:write` | Search for new companies by filters and save them. | | `POST /v1/integrations/emailable/contacts/{contact_id}/verify` | `enrichment:write` | [Check one contact's email address](#check-a-contacts-email-address) with Emailable now. | `enrichment:write` is opt-in. A key has it only when it was selected at creation, and a key created without any scopes does not have it. ## Providers and switches A workspace admin decides what each provider may be used for. Each provider has three switches under **MCP Tools**: | Switch | Endpoints it allows | | ------ | ------------------- | | **Enrichment** | `POST /v1/enrichment/contacts`, `POST /v1/enrichment/companies` | | **People discovery** | `POST /v1/prospecting/people-at-companies` | | **Prospecting** | `POST /v1/prospecting/people`, `POST /v1/prospecting/companies` | The API reads these switches on every call and for every caller. A REST API key is held to them exactly as an MCP connection is. The MCP server also reads them to decide which tools it lists. When no connected provider has the switch on for the kind of work you ask for, the call is answered `412` and nothing is spent. When both providers are connected and both allow the kind of work, Apollo runs the call. To have Hunter run it, leave Apollo's switch for that kind of work off. One provider runs a call: if it finds nothing, the other is not tried. No field names a provider, and one sent in the body is not read. ## Enrich Contacts ``` POST /v1/enrichment/contacts ``` **Required scope:** `enrichment:write` Looks each contact up with the provider and merges what it returns into the contact: name, job title, LinkedIn profile URL, phone, location, company and, when the provider reveals or finds one, the email address. ### Request Body | Field | Type | Required | Description | | ----- | ---- | -------- | ----------- | | `contact_ids` | string[] | yes | 1 to 25 contact IDs. Duplicates count once. | | `force` | boolean | no | Enrich contacts that were already enriched. This spends credits again. Default `false`. | | `reveal_personal_emails` | boolean | no | Also reveal personal (non-work) email addresses, which may use more credits. Default `false`. Apollo only: Hunter returns work addresses. | ```bash curl -X POST https://api.pathbound.ai/v1/enrichment/contacts \ -H "Authorization: Bearer $PATHBOUND_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "contact_ids": [ "665f1c2ab3d4e5f601234567", "665f1c2ab3d4e5f601234568", "665f1c2ab3d4e5f601234569" ] }' ``` How a contact is looked up depends on the provider: - **Apollo** matches on the contact's email, name, company and LinkedIn profile URL. - **Hunter** enriches a contact that has an email from that address. For a contact without one it looks for a work address, from the LinkedIn profile URL or from the first and last name plus the company's domain or name. A contact with none of these is skipped with `insufficient_identity`. ### Response — `200 OK` ```json { "status": "success", "via": "apollo", "requested": 3, "enriched": 1, "not_found": 1, "skipped": 1, "credits_consumed": 1, "deliverability": "queued", "results": [ { "contact_id": "665f1c2ab3d4e5f601234567", "status": "enriched", "email": "ada@example.com", "email_status": "verified" }, { "contact_id": "665f1c2ab3d4e5f601234568", "status": "not_found" }, { "contact_id": "665f1c2ab3d4e5f601234569", "status": "skipped", "reason": "already_enriched" } ] } ``` | Field | Description | | ----- | ----------- | | `via` | The provider that ran the call: `apollo` or `hunter`. | | `requested` | Distinct contact IDs in the request. | | `enriched`, `not_found`, `skipped` | How many results ended in each status. | | `credits_consumed` | Provider credits spent, in that provider's own units. See [Limits and costs](#limits-and-costs). | | `deliverability` | What happens to new addresses. See [Deliverability](#deliverability). | | `results[].status` | `enriched`, `not_found` (the provider has no match) or `skipped`. | | `results[].reason` | Why a contact was skipped. | | `results[].email` | The contact's address when the provider revealed, found or enriched from one. | | `results[].email_status` | The provider's own status for that address, in the provider's words. | A request that reaches a provider is a `200`, even when no contact was enriched. Read `results` to see what happened to each one. | `reason` | Meaning | | -------- | ------- | | `not_found_in_pathbound` | No contact with this ID in your workspace. | | `already_enriched` | The contact was enriched before and `force` is not set. | | `insufficient_identity` | Hunter: the contact has no email, and no LinkedIn profile URL or name plus company to search with. | | `email_belongs_to_contact:` | Hunter found an address that another contact already holds. Neither contact is changed. | | `claimed_email` | The person asked Hunter to stop processing their data. The contact is never sent to Hunter again, `force` included. | | `time_budget` | Hunter: the call stopped starting new lookups after 11 seconds. Call again for these contacts. | | `apollo_error: …`, `hunter_error: …` | The provider failed for this contact. | | `persist_error: …` | The provider answered and the result could not be saved. | With Hunter, when the key is refused, the credits are used up or the rate limit is reached before any contact got through, the whole call is answered with that [refusal](#errors) instead of a list of skipped contacts. ## Enrich Companies ``` POST /v1/enrichment/companies ``` **Required scope:** `enrichment:write` Looks each company up by its domain and merges what the provider returns into the company: name, industry, description, location, phone, employee count, annual revenue and founded year. ### Request Body | Field | Type | Required | Description | | ----- | ---- | -------- | ----------- | | `company_ids` | string[] | yes | 1 to 25 company IDs. Duplicates count once. | | `force` | boolean | no | Enrich companies that were already enriched. This spends credits again. Default `false`. | ### Response — `200 OK` ```json { "status": "success", "via": "hunter", "requested": 2, "enriched": 1, "not_found": 0, "skipped": 1, "credits_consumed": 0.2, "results": [ { "company_id": "665f1c2ab3d4e5f6012345a1", "status": "enriched", "domain": "example.com" }, { "company_id": "665f1c2ab3d4e5f6012345a2", "status": "skipped", "reason": "no_domain" } ] } ``` The fields are those of [Enrich Contacts](#enrich-contacts), with `company_id` and the company's `domain` on each result and no `deliverability`. `reason` is `not_found_in_pathbound`, `already_enriched`, `no_domain` (the company has no domain to look up), `time_budget`, or one of the `apollo_error: …`, `hunter_error: …` and `persist_error: …` forms. ## Find People at Companies ``` POST /v1/prospecting/people-at-companies ``` **Required scope:** `enrichment:write` Finds people who work at companies already in your workspace and saves them as contacts linked to those companies. Pathbound reads each company's domain from its record and searches the provider within that domain, in the order you gave the companies, until `limit` people are collected. ### Request Body | Field | Type | Required | Description | | ----- | ---- | -------- | ----------- | | `company_ids` | string[] | yes | 1 to 25 company IDs. | | `titles`, `seniorities`, `departments`, `locations`, `keywords`, `days_in_title_min`, `days_in_title_max` | | no | [Person filters](#person-filters). Without any, everyone the provider finds at the company matches. | | `limit` | number | no | The most people to save across all companies: 1 to 25, default 10. | | `reveal_emails` | boolean | no | Get each person's work email. Default `true`. | | `reveal_personal_emails` | boolean | no | Also reveal personal email addresses, which may use more credits. Default `false`. Apollo only. | | `tags` | string[] | no | [Tags](#records-tags-and-lifecycle-stage) for what the call saves. | | `lifecyclestage` | string | no | Stage written on contacts this call creates. No default: without it a new contact gets no stage. | Company filters do not apply here. The companies are the filter, and an `org_*` field is not read. ```bash curl -X POST https://api.pathbound.ai/v1/prospecting/people-at-companies \ -H "Authorization: Bearer $PATHBOUND_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "company_ids": ["665f1c2ab3d4e5f6012345a1"], "titles": ["head of operations", "vp of operations"], "limit": 5, "tags": ["campaign:ops-leaders"] }' ``` The two providers work differently: - **Apollo** searches for free and charges for each email it reveals. A person your workspace already got from Apollo and holds an email for is skipped before anything is revealed, and counted in `existing_skipped`. With `reveal_emails: false` people are saved without an email at no cost. A later call with reveals on reveals those same people. - **Hunter** returns people together with their addresses, up to 10 per company, and charges per company that has matches. With `reveal_emails: false` it searches nothing, spends nothing and returns no one. It cannot skip people before it charges, so `existing_skipped` is always `0`. A person already in your workspace comes back with `created: false`. ### Response — `200 OK` ```json { "status": "success", "via": "apollo", "mode": "anchored", "companies": [ { "company_id": "665f1c2ab3d4e5f6012345a1", "domain": "example.com", "found": 2, "created": 2, "existing": 0 } ], "contacts": [ { "contact_id": "670a3b4c5d6e7f8091a2b3c4", "company_id": "665f1c2ab3d4e5f6012345a1", "provider_person_id": "5f2a9c1e8b3d4a0001c7e9b2", "name": "Ada Lovelace", "title": "VP of Operations", "email": "ada@example.com", "email_status": "verified", "created": true } ], "requested_limit": 5, "search_total": null, "existing_skipped": 0, "companies_created": 0, "unlinked": 0, "credits_consumed": 2, "deliverability": "queued", "tags_applied": ["campaign:ops-leaders"], "note": "Contacts saved. Email deliverability (Emailable) is being checked in the background …" } ``` | Field | Description | | ----- | ----------- | | `via` | The provider that ran the call. | | `mode` | `anchored` on this endpoint, `unanchored` on [Find New People](#find-new-people). | | `companies[]` | One entry per company: `company_id`, `domain`, `name` when known, how many people were `found`, how many contacts were `created`, and how many were `existing`. | | `companies[].skipped_reason` | Why a company was not searched: `not_found_in_pathbound`, `no_domain`, `limit_reached` (the limit was filled by earlier companies), `reveal_required` (Hunter with `reveal_emails: false`), `time_budget`, `apollo_error: …` or `hunter_error: …`. | | `companies[].company_created` | On [Find New People](#find-new-people): whether this call created the company. | | `contacts[]` | The people saved: `contact_id`, `company_id`, `name`, `title`, `email`, `email_status` (the provider's own status for the address) and `created` (`false` when the person matched a contact you already had). | | `contacts[].provider_person_id` | Apollo's ID for the person. Not present on Hunter, which has none. | | `requested_limit` | The `limit` that applied. | | `search_total` | How many people match in the provider's whole database, when it reports one. `null` on this endpoint. | | `existing_skipped` | People passed over before any reveal, because your workspace already got them from Apollo and holds an email for them. Always `0` on Hunter. | | `companies_created` | Companies this call created. Always `0` on this endpoint. | | `unlinked` | Contacts saved without a company because the provider returned no employer domain. | | `credits_consumed` | Provider credits spent. | | `deliverability` | See [Deliverability](#deliverability). | | `tags_applied` | The tags written, lowercased. Empty when nothing was tagged. | | `note` | A sentence about the outcome, written for a person or an AI agent. Do not parse it. | ## Find New People ``` POST /v1/prospecting/people ``` **Required scope:** `enrichment:write` Searches the provider's database for people by who they are and by what kind of company they work at, and saves the matches as contacts. Each person's employer is matched to a company in your workspace by its domain, or created, and the contact is linked to it. At least one company filter is required. A search by person filters alone is refused with `400 unanchored_requires_org_filter`. ### Request Body | Field | Type | Required | Description | | ----- | ---- | -------- | ----------- | | `org_employee_ranges`, `org_locations`, `org_keywords`, `org_technologies`, `org_hiring_titles`, `org_hiring_posted_after`, `org_domains` | | at least one | [Company filters](#company-filters), written with the `org_` prefix on this endpoint. | | `titles`, `seniorities`, `departments`, `locations`, `keywords`, `days_in_title_min`, `days_in_title_max` | | no | [Person filters](#person-filters). | | `limit` | number | no | The most people to save: 1 to 25, default 10. | | `reveal_emails` | boolean | no | Get each person's work email. Default `true`. | | `reveal_personal_emails` | boolean | no | Also reveal personal email addresses, which may use more credits. Default `false`. Apollo only. | | `tags` | string[] | no | [Tags](#records-tags-and-lifecycle-stage) for what the call saves. | | `lifecyclestage` | string | no | Stage written on the contacts and companies this call creates. Default `prospect`. | ```json { "titles": ["head of sales"], "org_keywords": ["field service"], "org_employee_ranges": ["11,50", "51,200"], "org_locations": ["United States"], "limit": 10, "tags": ["icp:field-service"] } ``` On this endpoint `locations` and `keywords` describe the person. The company's are `org_locations` and `org_keywords`. The two providers work differently here too: - **Apollo** runs one free search and reveals up to `limit` of the people it finds, passing over those your workspace already got from Apollo with an email (`existing_skipped`). `search_total` is the number of people who match in Apollo's database. A person whose employer has no domain is saved without a company and counted in `unlinked`. - **Hunter** first finds companies that match the company filters, for free, then looks for people inside up to 15 of them and takes up to 3 from each. `search_total` is `null`, and `note` says how many companies matched. ### Response — `200 OK` The response has the shape of [Find People at Companies](#find-people-at-companies), with `mode: "unanchored"`. `companies` lists the employers of the people saved, each with `company_created`. ## Find New Companies ``` POST /v1/prospecting/companies ``` **Required scope:** `enrichment:write` Searches the provider's database for companies and saves the matches as companies in your workspace. A company you already have, matched by domain, is returned with `created: false` and left as it is. At least one filter is required. A request without one is refused with `400 requires_org_filter`. ### Request Body | Field | Type | Required | Description | | ----- | ---- | -------- | ----------- | | `employee_ranges`, `locations`, `keywords`, `technologies`, `hiring_titles`, `hiring_posted_after`, `domains`, `name` | | at least one | [Company filters](#company-filters), without a prefix on this endpoint. | | `limit` | number | no | The most companies to save: 1 to 50, default 25. On Apollo it does not change the cost of the call. | | `tags` | string[] | no | [Tags](#records-tags-and-lifecycle-stage) for the companies this call creates. | | `enrich` | boolean | no | Also [enrich](#enrich-companies) the companies this call creates, the first 25 of them, at the price of an enrichment each. Default `false`. | | `lifecyclestage` | string | no | Stage written on the companies this call creates. Default `prospect`. | ```bash curl -X POST https://api.pathbound.ai/v1/prospecting/companies \ -H "Authorization: Bearer $PATHBOUND_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "keywords": ["field service"], "employee_ranges": ["11,50", "51,200"], "locations": ["United States"], "limit": 25, "tags": ["icp:field-service"] }' ``` ### Response — `200 OK` ```json { "status": "success", "via": "apollo", "search_total": 1840, "requested_limit": 25, "found": 25, "created": 22, "existing": 2, "companies": [ { "company_id": "670a3c5d6e7f8091a2b3c4d5", "provider_company_id": "5e66b6381e05b4008c8331b8", "domain": "example.com", "name": "Example Field Services", "created": true, "saved_in_provider": false } ], "skipped": [ { "provider_company_id": "5e66b6381e05b4008c8331c9", "name": "Example Holdings", "reason": "no_domain" } ], "credits_consumed": 1, "tags_applied": ["icp:field-service"], "note": "Saved 22 new prospect companies (2 already in Pathbound). …" } ``` | Field | Description | | ----- | ----------- | | `search_total` | How many companies match in the provider's whole database, when it reports one. Use it to judge the filters before you ask for more. | | `found` | Companies the provider returned for this call, at most `limit`. | | `created`, `existing` | How many of them this call created, and how many you already had. | | `companies[].company_id` | The Pathbound company ID. Pass these to [Find People at Companies](#find-people-at-companies). | | `companies[].provider_company_id` | The provider's own ID for the company. On Hunter it is the domain. | | `companies[].saved_in_provider` | `true` when the company was already saved as an account in your Apollo team. Always `false` on Hunter. | | `skipped` | Companies the provider returned without a domain. They are reported and never saved. | | `enrichment` | Present when `enrich` was `true` and at least one company was created: the [Enrich Companies](#enrich-companies) result for those companies. | The call returns one page of results. To get different companies, change the filters. On Apollo this search needs an Apollo connection made after September 11, 2026. An older connection is answered `412 scope_missing`: reconnect Apollo under **Integrations**. With `enrich: true`, the search and the save happen first. If the enrichment step is then refused for the [daily budget](#limits-and-costs), the call is answered `429` although the companies were already saved. ## Check a contact's email address ``` POST /v1/integrations/emailable/contacts/{contact_id}/verify ``` **Required scope:** `enrichment:write` Checks the contact's own email address with the workspace's Emailable account now, and stores the verdict on the contact, where `email_verification_state` and the [`GET /v1/contacts`](/docs/api/contacts) filters read it. Emailable checks contacts created after it was connected by itself, in the background, and the endpoints above queue only the addresses a provider found. This is the way to check an address a contact already had. Emailable must be connected under **Integrations**, and a workspace admin must turn on **Check email addresses** under **MCP Tools → Emailable**. The MCP tool `verify_contact_email` calls this endpoint. ### Request Body | Field | Type | Required | Description | | ----- | ---- | -------- | ----------- | | `force` | boolean | no | Check again even when a verdict is already on file for the contact's current address. This spends a credit. Default `false`. | The address checked is the one on the contact. No field names an address, and any field other than `force` is answered `400` with the code `UNKNOWN_FIELD`. ```bash curl -X POST https://api.pathbound.ai/v1/integrations/emailable/contacts/665f1c2ab3d4e5f601234567/verify \ -H "Authorization: Bearer $PATHBOUND_API_KEY" \ -H "Content-Type: application/json" \ -d '{}' ``` ### Response — `200 OK` ```json { "status": "success", "contact_id": "665f1c2ab3d4e5f601234567", "checked": true, "credits_used": 1, "email_verification_state": "deliverable", "email_verified_at": "2026-11-02T14:05:11.204Z", "emailable": { "state": "deliverable", "reason": "accepted_email", "score": 97, "free": true, "role": false, "disposable": false, "accept_all": false, "did_you_mean": null } } ``` | Field | Description | | ----- | ----------- | | `checked` | `true` when Emailable was asked. `false` when a verdict was already on file for this address and was returned as it is. | | `credits_used` | Emailable credits spent: `1` for a check, `0` when nothing was asked. | | `email_verification_state` | `deliverable`, `undeliverable`, `risky` or `unknown`. | | `email_verified_at` | When the verdict was reached. | | `emailable` | Emailable's own detail, or `null` for a verdict a data provider reached (Hunter). `did_you_mean` is an address Emailable suggests instead, and is `[redacted]` for a caller under the workspace's PII setting. | | `note` | Present when nothing was asked, saying so. | A verdict on file counts only when the source that reached it, Emailable or Hunter, reached it for the address the contact holds now. When the contact's address changed since, the new address is checked. A verdict Hunter reached before November 2, 2026 is checked too, because Hunter's record from before that day does not say which address it was for. ### Errors The scope, the shape of the request and the switch are checked first, as on the other endpoints. Then: | Status | `code` | Meaning | | ------ | ------ | ------- | | `404` | `CONTACT_NOT_FOUND` | No contact with this ID in your workspace. | | `412` | `integration_missing` | Emailable is not connected. | | `412` | `tool_disabled` | Emailable is connected and **Check email addresses** is off. | | `412` | `invalid_credentials` | Emailable refused the workspace's API key. Reconnect it under **Integrations**. | | `412` | `EMAILABLE_OUT_OF_CREDITS` | The Emailable account has no credits left. | | `422` | `CONTACT_EMAIL_MISSING` | The contact has no email address. | | `429` | `RATE_LIMITED` | Emailable is rate limiting the key. `retry_after_seconds` says when to call again. | | `502` | `EMAILABLE_UNAVAILABLE` | Emailable failed or did not answer in time. Nothing was stored. | | `503` | `VERIFICATION_PENDING` | Emailable has not finished checking the address. Call again in about a minute: Emailable does not charge for asking again within 5 minutes. Nothing was stored. | ## Filters A filter that the provider running the call cannot express is refused before anything is spent. It is never dropped, because a dropped filter widens the search and you would pay for the wider result. The same goes for a filter value the provider cannot express exactly. Up to 50 values are read from each list (`employee_ranges` 20, `domains` 100), and values past that are not read. A company list of more than 100 values is a `400`. Every entry of a list has to be a string. An entry of another type is not read and raises no error, so `"employee_ranges": [[11, 50]]` is an empty filter, and a search that has other filters then runs without it. Check the types you send: this is the one case in which a filter can go missing. ### Company filters On `POST /v1/prospecting/companies` the fields are written as below. On `POST /v1/prospecting/people` each takes the `org_` prefix (`org_employee_ranges`, `org_locations`, and so on), and `name` is not available. | Field | Type | Description | | ----- | ---- | ----------- | | `employee_ranges` | string[] | Employee-count ranges, each a string written `"min,max"`, for example `["11,50", "51,200"]`. A string in any other form is a `400 invalid_employee_range`. | | `locations` | string[] | Where the company is headquartered: cities, states or countries, for example `["United States", "Berlin, Germany"]`. Give a city its country. | | `keywords` | string[] | Keyword tags the provider files companies under, for example `["field service", "dispatch software"]`. A company matches when it has any of them. | | `technologies` | string[] | Technologies the company uses, for example `["hubspot", "salesforce"]`. A company matches when it uses any of them. Apollo reads each value as one of its technology identifiers, written with underscores for spaces and dots. | | `hiring_titles` | string[] | Titles in the company's open job postings, for example `["sales development representative"]`. | | `hiring_posted_after` | string | Only companies with a job posting from this date on, as `YYYY-MM-DD`. Any other form is a `400 invalid_date`. | | `domains` | string[] | Only these company domains, for example `["example.com"]`. The companies need not be in your workspace. | | `name` | string | Part of a company name, up to 200 characters. `POST /v1/prospecting/companies` only. | ### Person filters | Field | Type | Description | | ----- | ---- | ----------- | | `titles` | string[] | Job titles, for example `["head of sales", "vp of customer success"]`. | | `seniorities` | string[] | Seniority levels, in the provider's vocabulary. Apollo's include `c_suite`, `vp`, `director` and `manager`. Hunter has three: `junior`, `senior`, `executive`. | | `departments` | string[] | Departments, in the provider's vocabulary, for example `["sales", "marketing"]`. | | `locations` | string[] | Where the person is based. Independent of the company's location. | | `keywords` | string | Free text to match on the person, up to 300 characters. | | `days_in_title_min`, `days_in_title_max` | number | Days in the current title, 0 to 36500. A `days_in_title_max` of `90` finds people new in their role. | ### What Hunter cannot express Apollo can express every filter above. Hunter cannot. When Hunter is the provider for a call, these are refused: - **Person `keywords`, `days_in_title_min`, `days_in_title_max`.** Not supported. The call is refused with `400 unsupported_filters`. - **`employee_ranges`.** Hunter has fixed company sizes: 1-10, 11-50, 51-200, 201-500, 501-1000, 1001-5000, 5001-10000 and 10001+. A range must start and end on those edges: `"11,50"` and `"51,500"` are accepted, `"20,100"` is not. For no upper bound, end the range at `1000000` or more, as in `"10001,1000000"`. - **`seniorities`.** Only `junior`, `senior` and `executive`. Name specific roles with `titles` instead. - **`departments`.** Only `executive`, `it`, `finance`, `management`, `sales`, `legal`, `support`, `hr`, `marketing`, `communication`, `education`, `design`, `health`, `operations`, `product`, `research`, `consulting`, `administrative` and `procurement`. Engineering roles are under `it`. - **`locations`**, the company's and the person's. Hunter takes a country by name or ISO code (`"Germany"`, `"FR"`), a US state (`"California"`), a city with its country or state (`"Berlin, Germany"`, `"Austin, Texas"`), a continent (`"Europe"`), or one of the regions `AMER`, `EMEA`, `APAC` and `LATAM`. A city on its own is refused, and so is a name that is both a US state and a country: write `"Georgia, US"` for the state. - **`hiring_posted_after`.** Hunter filters job postings by the last 7, 30 or 90 days only. Your date is applied as the widest of those windows that fits inside it, so the search is never wider than you asked, and `note` says which window was used. A date less than 7 days back is refused. - **`name` together with `domains`.** Refused. Search by one or the other. - **`technologies`.** Needs a paid Hunter plan. Without one the call is answered `412 plan_required`. A value Hunter cannot express is refused with `400 unsupported_filter_value`. The error names the filter and the value in `details`, and its `hint` says what Hunter accepts. ## What a call writes ### Records, tags and lifecycle stage - **A record is matched before it is created.** A company is matched by its domain. A person is matched by email address or LinkedIn profile URL. A match is returned with `created: false`. - **A company is only ever created from a domain.** A company the provider returns without one is never saved. - **A person found at a company is linked to that company.** - **`lifecyclestage` is written on records the call creates, never on one you already had.** A lead or a customer is not turned back into a prospect. The allowed values are `prospect`, `subscriber`, `lead`, `mql`, `sql`, `opportunity`, `customer`, `evangelist` and `other`. - **`tags` are written on the contacts and companies the call creates.** On Apollo a contact you already had that the call returns is tagged too. Tags are trimmed, lowercased and cut to 64 characters, and at most 50 are read. Use a tag to get the batch back later with `tags_any` on [`GET /v1/contacts`](/docs/api/contacts) or [`GET /v1/companies`](/docs/api/companies). - **Every value is merged into the record with the provider as its source** (`apollo` or `hunter`), next to what your other sources say. Read it in the record's unified profile. - **On Hunter a contact you already had keeps its own email address.** One that had none receives the address Hunter found. ### Deliverability Every response that can add email addresses reports `deliverability`: | Value | Meaning | | ----- | ------- | | `verified` | The provider verified each new address itself, and the verdict is already on the contact as `email_verification_state`. Hunter does this for the addresses it finds. | | `queued` | Emailable is checking the addresses that have no verdict, in the background. Read `email_verification_state` on the contact about a minute later. | | `nothing_to_check` | The call added no new address. It says nothing about whether Emailable is connected. | | `not_connected` | The call added addresses that have no verdict, and Emailable is not connected. | Hunter's own status becomes the verdict on the contact this way: `valid` is `deliverable`, `invalid` is `undeliverable`, and `accept_all` and `disposable` are `risky`. Any other status leaves the address without a verdict, for Emailable to check. [`GET /v1/contacts`](/docs/api/contacts) filters on the verdict with `email_verification_state`. ### People who asked Hunter to stop When a person has asked Hunter to stop processing their data, the contact is reported `skipped` with the reason `claimed_email`, nothing Hunter said about them is kept, and the contact is never sent to Hunter again. If a later search returns that person, the contact is left untouched and `note` counts them. ## Limits and costs **Per call:** 25 contacts or companies to enrich, 25 companies to look inside, 25 people to save, 50 companies to save. **Per day:** the calls of a workspace, over REST and MCP together, may spend a set number of credits with each provider a day, counted in UTC. The budget is there so that a loop cannot empty your account with the provider. A call whose most expensive outcome would pass it is refused with `429` and the code `APOLLO_AGENT_BUDGET` or `HUNTER_AGENT_BUDGET`. `details.cap` is the budget and `details.spent` is what was used so far. Nothing is spent by a refused call, and credits a call reserved and did not use are given back. If the budget is too low for your work, write to [support@pathbound.ai](mailto:support@pathbound.ai). **What each provider charges**, in its own credits: | | Apollo | Hunter | | - | ------ | ------ | | Enrich a contact | About 1 credit per match. Nothing for a miss. | 0.2 credit for a contact that has an email. 1 credit to find an email for one that has none, and only when one is found. | | Enrich a company | About 1 credit per match. Nothing for a miss. | 0.2 credit. | | Find people | The search is free. About 1 credit per email revealed. | 1 credit per company that has matches, for up to 10 people. | | Find companies | 1 credit per call that returns results. Nothing when nothing matches. | Free. | `credits_consumed` on Apollo is what Apollo reported for the call, or the number of matches when Apollo reports nothing. On Hunter it is counted from the prices above. Hunter bills a repeated search once per billing period, so its real charge can be lower. These endpoints take no `Idempotency-Key`: a repeated call runs again. A contact or company that was already enriched is skipped unless you set `force`, and on Apollo a person your workspace already got from it with an email is not revealed twice. An Apollo company search costs its credit every time. For the API's own request limits, see [Rate limits](/docs/api/overview#rate-limits). ## Errors Checks run in this order, and a call stops at the first one it fails: 1. The API key and its scope. 2. The shape of the request: types, limits, the form of an ID, an employee range or a date, and a search with no company filter. This answer does not depend on which provider is connected or which switches are on. 3. Whether a provider may run the call: connected, and its switch for this kind of work on. 4. Whether that provider can express every filter and every filter value. 5. The day's budget, before each step that costs credits. An error has the shape `{ "status": "error", "error": "…", "code": "…" }`, with a `hint` and `details` where the table names them. See [Errors](/docs/api/overview#errors) for the answers every endpoint shares. | Status | `code` | Meaning | | ------ | ------ | ------- | | `400` | none | The body failed validation: a field of the wrong type, a list or a `limit` out of range, a malformed ID, an unknown `lifecyclestage`. `details` lists each field. | | `400` | `invalid_employee_range` | An `employee_ranges` value is not `"min,max"`. | | `400` | `invalid_date` | `hiring_posted_after` is not a `YYYY-MM-DD` date. | | `400` | `unanchored_requires_org_filter` | `POST /v1/prospecting/people` without any `org_*` filter. | | `400` | `requires_org_filter` | `POST /v1/prospecting/companies` without any filter. | | `400` | `unsupported_filters` | No provider that may run the call supports a filter you used. `details.unsupported_filters` names them, for example `person:keywords`. Remove the filter, or have an admin turn on a provider that supports it. | | `400` | `unsupported_filter_value` | The provider cannot express a filter value exactly. `details.filter` and `details.value` name it and `hint` says what is accepted. Nothing was spent. | | `400` | `provider_rejected_request` | Hunter refused a filter value. `details.hunter_details` carries Hunter's own text. | | `403` | none | The API key does not have `enrichment:write`. `required_scopes` lists it. | | `412` | `integration_missing` | No data provider is connected. Connect Apollo or Hunter under **Integrations**. | | `412` | `tool_disabled` | A provider is connected and its switch for this kind of work is off. A workspace admin turns it on under **MCP Tools**. | | `412` | `invalid_credentials` | The provider refused the connection's credentials. Reconnect it under **Integrations**. | | `412` | `scope_missing` | The Apollo connection predates company search. Reconnect Apollo. | | `412` | `plan_required` | The provider's plan, or on Apollo the permissions of the connection, does not include what the call needs. On Hunter this includes the `technologies` filter. | | `429` | `APOLLO_AGENT_BUDGET`, `HUNTER_AGENT_BUDGET` | The workspace's daily credit budget for these calls would be passed (`details.cap`, `details.spent`). It resets at midnight UTC. | | `429` | `provider_quota_exhausted` | The Hunter account has no credits left for its billing period. | | `429` | `provider_rate_limited` | Hunter's rate limit was reached. Wait a minute and call again. | | `451` | `claimed_email` | The person asked Hunter to stop processing their data. On these endpoints the person is normally reported as skipped, or left untouched, and the call succeeds. | | `502` | `provider_unavailable` | Hunter did not answer. Try again shortly. | | `500` | none | The call failed for a reason that has no code. This includes Apollo failing or rate-limiting a search. | When no provider may run the call, the `412` names the reason that is closest to working: a switch that is off, before credentials that were refused, before a provider that is not connected. ## Enrich a whole segment These endpoints take up to 25 records a call. To enrich every member of a saved segment as a background job, use [`POST /v1/segments/:ref/actions`](/docs/api/segments#run-an-action-on-a-segment) with the action `apollo_enrich`. That job always runs on Apollo, under Apollo's **Enrichment** switch, and draws on the same daily budget. ## Job changes on contacts saved in Apollo Apollo watches the contacts saved in your Apollo team and records when one changes title or employer. Pathbound reads that from Apollo at no credit cost and writes it on the contact: `job_changed_at`, `previous_jobtitle` and `previous_company`, with `jobtitle` and `company` updated to the new values, plus the tag `signal:job-change`. To get those contacts, call [`GET /v1/contacts`](/docs/api/contacts) with `tags_any=signal:job-change`. Contacts at the `prospect` stage are checked again about every 30 days once a workspace admin turns on **Refresh prospects** under **Integrations → Apollo**. ## Earlier Apollo routes Four older routes remain for existing callers. New code should use the endpoints above. | Earlier route | Use instead | | ------------- | ----------- | | `POST /v1/integrations/apollo/contacts/enrich` | `POST /v1/enrichment/contacts` | | `POST /v1/integrations/apollo/companies/enrich` | `POST /v1/enrichment/companies` | | `POST /v1/integrations/apollo/people/find` | `POST /v1/prospecting/people-at-companies` when the body has `company_ids`, `POST /v1/prospecting/people` when it does not | | `POST /v1/integrations/apollo/companies/find` | `POST /v1/prospecting/companies` | They take the same request fields and the same scope. They always run on Apollo, under Apollo's switches, and never on Hunter. Their responses carry no `via` and use Apollo's names: `apollo_person_id`, `apollo_organization_id` and `saved_in_apollo` in place of `provider_person_id`, `provider_company_id` and `saved_in_provider`. --- # MCP Server — Overview > Connect Pathbound to Claude.ai and other Model Context Protocol clients. Source: https://pathbound.ai/docs/mcp/overview The Pathbound MCP server exposes the [REST API](/docs/api/overview) as a set of tools for [Model Context Protocol](https://modelcontextprotocol.io) clients. The most common use is connecting Pathbound to **Claude.ai** so you can ask questions like "what did this contact do this week?" or "show me the visitors who hit /pricing yesterday" — and have the model read your unified customer profiles, events, and activity history. With the tools a workspace admin switches on, it can also act: tag records, save segments, enrich contacts, and draft or send email through the tools you have connected. ## Server URL ``` https://mcp.pathbound.ai/mcp ``` ## When to use MCP vs REST | Use… | When | | -------------- | --------------------------------------------------------------------- | | **REST API** | You're writing your own backend, integration, or scheduled job. | | **MCP server** | You want Claude.ai (or another MCP client) to read Pathbound data, and act on it, on your behalf. | The two surfaces hit the same backend and respect the same tenant boundaries. Interactive MCP clients (Claude.ai) authenticate via Pathbound's native OAuth login; headless MCP clients can authenticate with a [REST API key](/docs/mcp/authentication#api-keys-for-headless-clients) instead. ## What MCP exposes More than 100 tools across these domains: - **Contacts and companies** — list, get, create (one or many), update, timeline, conversations, meetings, email threads, notes. - **Deals** — list, get, create, update, delete, notes. - **Events** — list, get, types, stats. - **Tags and segments** — tag records one at a time or by segment, save segments, and run an action on everyone in one. - **Batch, aggregate and schema** — multi-entity lookups, bulk meeting notes, server-side group-by, and the list of fields you can filter on. - **Knowledge** — your workspace's own pages about its business, which an agent reads before it writes for you. - **Streaming** — automations that tag records, change a Resend segment, or email everyone who submits a form. - **Actions in your connected tools** — Gmail, Resend, Apollo sequences, Instantly, and enrichment and prospecting through Apollo or Hunter. The tools that read and write your own records are always available. Every other group is off until a workspace admin switches it on under **MCP Tools** in the dashboard, and the tools that act in a connected tool also need that integration connected. See the full [Tools Reference](/docs/mcp/tools). ## Connecting Claude.ai 1. In Claude.ai, open **Customize → Connectors** and select **+ → Add custom connector**. 2. Enter the URL `https://mcp.pathbound.ai/mcp` and select **Add**. 3. Select **Connect**. Claude opens the OAuth flow — log in with your Pathbound account. 4. Approve the connection. You're now ready to ask Claude questions about your Pathbound data. On a Team or Enterprise plan, an owner adds the connector first under **Organization settings → Connectors**; each member then connects their own Pathbound account from **Customize → Connectors**. The OAuth grant is per-user and respects your Pathbound permissions: anything you can read or write in the Pathbound dashboard is what the MCP tools will let Claude do. ## Connecting several workspaces A connection reaches one workspace: the workspace of the account that approved it. To work in several workspaces from the same Claude, add one connector per workspace, approve each one with that workspace's account, and name the workspace at the end of the connector's URL: ``` https://mcp.pathbound.ai/mcp?ws=acmecorp ``` `ws` is the workspace's name in lowercase letters and digits (Acme Corp is `acmecorp`), or the workspace's id, which stays the same if the workspace is renamed. Accents, capitals and punctuation make no difference: Müller GmbH is `mullergmbh`, and `müller-gmbh` finds it too. The `get_usage` tool shows both under `workspace`. - The server tells the model which workspace each connection is, so a conversation with several Pathbound connectors can use the right one. - If the URL names one workspace and the account that approved the connector belongs to another, the server refuses the connection and says which workspace it is signed in to. That happens when the browser was signed in to Pathbound with another account at the time you approved. On the consent screen, choose **use another account** before you approve. - A URL without `ws` connects as before, to the workspace of the account that approved it. ## Connecting headless clients Server-side agents — hosted MCP tool runners (OpenAI, Anthropic API), schedulers, CI pipelines — can't open a browser to complete the OAuth flow. They connect with a **REST API key** sent as a static bearer header: ``` Authorization: Bearer sk_your_api_key ``` Create the key under **Settings → API keys** at [app.pathbound.ai/settings/api-keys](https://app.pathbound.ai/settings/api-keys); its scopes (e.g. `contacts:read`) apply to MCP tool calls exactly as they do on the REST API. See [Authentication](/docs/mcp/authentication#api-keys-for-headless-clients) for details. ## Limits - The MCP server is stateless and rate-limited per user. Heavy bursts (more than ~30 tool calls per minute) may be throttled. - Tool responses are bounded — `get_contact` caps at 20 events. Use `aggregate_data` and `batch_get_*` instead of paginating. --- # MCP — Authentication > How the Pathbound MCP server authenticates clients — OAuth 2.0 for interactive clients, REST API keys for headless agents. Source: https://pathbound.ai/docs/mcp/authentication The MCP server accepts two kinds of credentials: - **OAuth 2.0** with Pathbound's native login — for interactive clients (Claude.ai) where a human can complete a browser flow. The MCP server delegates the actual login to Pathbound's web app and brokers the resulting JWT into MCP-shaped OAuth tokens. - **REST API keys** — for [headless clients](#api-keys-for-headless-clients) (hosted MCP tool runners, server-side agents, pipelines) that can only send a static `Authorization` header. ## OAuth flow ``` ┌──────────────┐ ┌──────────────────┐ ┌────────────────┐ │ MCP client │ │ mcp.pathbound │ │ api.pathbound │ │ (Claude.ai) │ │ .ai │ │ .ai │ └──────┬───────┘ └────────┬─────────┘ └────────┬───────┘ │ │ │ │ 1. GET /authorize │ │ │ ────────────────────────────────▶ │ │ │ │ 2. 302 to │ │ │ /mcp/authorize │ │ │ ────────────────────────────────▶ │ │ │ │ │ │ User logs in (or already │ │ │ has a Pathbound session) │ │ │ │ │ │ 3. 302 back with one-time code │ │ ◀──────────────────────────────── │ ◀──────────────────────────────── │ │ │ │ │ 4. POST /token { code } │ │ │ ────────────────────────────────▶ │ 5. POST /mcp/token { code } │ │ │ ────────────────────────────────▶ │ │ │ 6. JWT (90d default) │ │ │ ◀──────────────────────────────── │ │ 7. MCP access_token + refresh │ │ │ ◀──────────────────────────────── │ │ │ │ │ │ 8. tool calls (Bearer) │ │ │ ────────────────────────────────▶ │ 9. Backend V1 calls (JWT) │ │ │ ────────────────────────────────▶ │ │ │ ◀──────────────────────────────── │ │ ◀──────────────────────────────── │ │ ``` 1. The MCP client (Claude.ai) hits the MCP server's `/authorize` endpoint. 2. The MCP server has no local login form — it 302s to `https://api.pathbound.ai/mcp/authorize`. 3. If the user has an active Pathbound session, the backend issues a one-time code immediately and redirects back to the MCP server. If not, the user is sent through the normal Pathbound login (with 2FA if configured), then redirected. 4–7. The MCP server exchanges the code for a JWT via `POST /mcp/token` on the backend, then maps that JWT onto its own MCP-shaped OAuth access and refresh tokens which it returns to the client. 8–9. Subsequent MCP tool calls use the MCP access token; the MCP server forwards them to the backend as the underlying JWT. ## Consent screen Before the backend issues the one-time code in step 3, the user sees a consent screen showing: - **The address access is sent to**: the origin of the redirect URI this request will deliver the code to. This is the line to check. - **The name the application registered** (for example, “Claude”), shown as a claim. Pathbound doesn’t verify application names. - **What the connection will hold**, one row per permission. “Always included” rows are the read and CRM-edit access every connection gets. “Also requested” rows are the opt-in writes the application asked for: sending email and acting in connected tools (`actions:write`), spending enrichment credits (`enrichment:write`), and creating streaming automations (`streams:write`). - **Which tools are on now.** Rows whose tools a workspace admin toggles say which are on in your workspace. An opt-in that is off is still granted: if an admin turns the tool on later, the connection can use it without asking again. Stream changes are marked admins only for members, because the API refuses them whatever the token holds. - **Which account and workspace.** The connection reaches the workspace of the account you are signed in with, shown at the bottom of the screen. **Use another account** signs you out and brings you back to the same request once you sign in with another one. The request is valid for five minutes; after that, start the connection again from the application. The list is built by the Pathbound API from the same definition that issues and enforces the token, so it shows exactly what the connection will hold. Only authorize applications you trust: the JWT issued at the end of this flow grants those permissions against your tenant for the full token lifetime below. If you see a **"could not be verified"** error on the consent screen, the application's authorization request failed integrity validation between the MCP server and the backend — close the tab and reconnect from inside the application rather than retrying the same link. ## Token lifetime | Token | Default lifetime | | -------------------- | ---------------- | | Backend JWT | 90 days (`MCP_TOKEN_EXPIRY_DAYS`) | | MCP access token | 30 days (`MCP_ACCESS_TOKEN_TTL_DAYS`) | | MCP refresh token | 180 days (`MCP_REFRESH_TOKEN_TTL_DAYS`) | These are the operator defaults; your Pathbound deployment may set different values. When an access token expires, the client uses its refresh token to get a new one without going through the full browser flow again. ## Scopes Every connection made through the MCP flow can read and write the workspace's own records: ``` contacts:read, contacts:write companies:read, companies:write deals:read, deals:write events:read streams:read knowledge:read, knowledge:write ``` Three more scopes are granted only when the client asks for them and you approve them on the consent screen, which lists exactly what the connection will hold: ``` actions:write sending email and acting in connected tools enrichment:write enrichment and prospecting, which spend provider credits streams:write creating and changing automations (workspace admins only) ``` A scope does not switch a tool on by itself. The knowledge, streaming and action tools also need their switch turned on under **MCP Tools** in the dashboard, and an agent can never delete a contact or a company whatever it was granted. If you need a tighter scope set — for example, a read-only integration — connect with a [scoped API key](#api-keys-for-headless-clients) instead. ## API keys for headless clients Server-side MCP clients — OpenAI and Anthropic hosted tool runners, schedulers, CI jobs — can't open a browser or refresh OAuth tokens. They authenticate by sending a tenant [REST API key](/docs/getting-started/authentication) as a static bearer token on every request: ``` Authorization: Bearer sk_your_api_key ``` No OAuth handshake, no token expiry to manage — the key works until you revoke it. 1. Create a key at [app.pathbound.ai/settings/api-keys](https://app.pathbound.ai/settings/api-keys) (tenant admins only), selecting only the scopes the agent needs — `contacts:read` and `events:read` make a good read-only analyst. 2. Configure your MCP client with the server URL `https://mcp.pathbound.ai/mcp` and the key as its authorization/bearer token. Example with the Anthropic API's MCP connector: ```json { "mcp_servers": [ { "type": "url", "url": "https://mcp.pathbound.ai/mcp", "name": "pathbound", "authorization_token": "sk_your_api_key" } ] } ``` ### Scopes and key behavior - The key's scopes are enforced on every tool call, exactly as on the REST API. A `contacts:read` key can call `list_contacts` but gets a descriptive permission error from `update_contact` naming the missing scope. - Write-action tools (Gmail/Resend sends and Apollo sequence drafting, enrollment, and single-email actions) additionally require the opt-in `actions:write` scope **and** the corresponding tool toggle in your integration settings. Keys without `actions:write` never see those tools listed. - A key without `actions:write` also sees none of the other optional tool groups (enrichment, streaming, knowledge), even with their own scopes, because the MCP server reads the workspace's tool switches through an endpoint that needs that scope. Give a headless MCP client `actions:write` if it should use any of them. - Actions performed with a key are attributed as `api_key:` in the action log. - If your tenant has **MCP PII redaction** enabled, it applies to API-key MCP sessions the same as OAuth ones. Direct REST API calls with the same key remain unredacted. ### Revoking a key Deactivate or delete the key at [app.pathbound.ai/settings/api-keys](https://app.pathbound.ai/settings/api-keys). Tool calls fail immediately; the MCP transport stops accepting the key within a minute. ## Revoking OAuth access Users can revoke MCP access from **MCP Tools → Disconnect all** in the Pathbound dashboard. It takes effect at once: every MCP token issued to that user stops working, and each AI client asks them to sign in again. API keys are not affected; revoke a key under **Settings → API keys**. --- # MCP — Tools Reference > The tools the Pathbound MCP server exposes, grouped by domain — records, tags and segments, knowledge, streaming, and the actions in your connected tools. Source: https://pathbound.ai/docs/mcp/tools The MCP server exposes more than 100 tools to MCP clients. Tools are grouped here by domain. Each entry shows the tool name, a one-line description, key required parameters, and the MCP annotations (`readOnly`, `destructive`, `idempotent`). For full parameter shapes including optional fields, the MCP server publishes a schema via the standard MCP `tools/list` method — most clients (including Claude.ai) display this inline. Not every tool is listed for every connection. The tools that read and write your own records are always there. A tool that acts in a connected tool (sending email, enrichment, sequences) appears once that integration is connected and a workspace admin has switched its group on under **MCP Tools** in the dashboard. The streaming and knowledge tools have switches of their own there. Each section below says what its tools need. Every tool calls an endpoint of the [REST API](/docs/api/overview), so anything an agent does here your own code can do with an API key. ## Contacts | Tool | Description | Key params | Annotations | | ---- | ----------- | ---------- | ----------- | | `list_contacts` | List/filter contacts. Supports `count_only`, `fields=summary`, behavioral `has_event_*` filters (contacts who performed a tracked event), and `email_verification_state` (the deliverability verdict on file, from Emailable or from a data provider that verified the address it found: `deliverable`/`undeliverable`/`risky`/`unknown`/`unverified`). Each row returns that verdict as `email_verification_state`; `emailable` carries Emailable's own detail only. | (filters) | read-only | | `get_contact` | Fetch a contact with bounded events (20). | `contact_id` | read-only | | `create_contact` | Create a contact. Min: `email`. | `email` | write | | `create_contacts_bulk` | Create many contacts in one call. A contact whose email already exists comes back `existing` and is left unchanged, so the same list can be sent twice. One bad item does not fail the others. | `contacts` | write, idempotent | | `update_contact` | Update properties on a contact. | `contact_id` | write, idempotent | | `get_contact_timeline` | Chronological events for a contact across all sources. | `contact_id` | read-only | | `get_contact_conversations` | Email, Intercom, HubSpot and Instantly threads, plus meetings (tl;dv recordings, Calendly bookings). Pass `include_messages=true` for bodies and tl;dv AI notes. | `contact_id` | read-only | | `get_meeting_transcript` | A tl;dv recording's transcript, one page at a time (follow `next_offset`). | `contact_id`, `meeting_id` | read-only | | `list_meetings` | Many tl;dv recordings at once, across customers: filter by company, contact, segment, date, or whether notes exist, and pick which note sections to return. | (filters) | read-only | | `list_contact_email_threads` | Compact thread list — `thread_id`, `last_message_id`, subject. | `contact_id` | read-only | | `list_contact_notes` | All notes on a contact. | `contact_id` | read-only | | `create_contact_note` | Add a freeform note (direct, no approval). | `contact_id`, `content` | write | | `update_contact_note` | Edit an existing note. | `contact_id`, `note_id`, `content` | write, idempotent | | `delete_contact_note` | Delete a note. Irreversible. | `contact_id`, `note_id` | destructive, idempotent | ## Companies | Tool | Description | Key params | Annotations | | ---- | ----------- | ---------- | ----------- | | `list_companies` | List/filter companies (search domain, name, industry). | (filters) | read-only | | `get_company` | Fetch a company with up to 25 associated contacts. | `company_id` | read-only | | `get_company_conversations` | `get_contact_conversations` rolled up across every contact at the company, meetings included. | `company_id` | read-only | | `create_company` | Create a company. Min: `domain`. A free email provider's domain or one with no dot is refused. | `domain` | write | | `create_companies_bulk` | Create many companies in one call, matched by domain. An existing company comes back `existing` and is left unchanged. | `companies` | write, idempotent | | `update_company` | Update properties on a company. | `company_id` | write, idempotent | | `list_company_notes` | All notes on a company. | `company_id` | read-only | | `create_company_note` | Add a freeform note. | `company_id`, `content` | write | | `update_company_note` | Edit an existing note. | `company_id`, `note_id`, `content` | write, idempotent | | `delete_company_note` | Delete a note. Irreversible. | `company_id`, `note_id` | destructive, idempotent | An agent cannot delete a contact or a company. There is no tool for it, and the API refuses such a call from an MCP connection. ## Deals A deal is a revenue opportunity linked to a company, a contact, or both. Reading a deal counts as reading the customer it belongs to, so `list_deals` returns ids only unless asked for more. REST reference: [Deals](/docs/api/deals). | Tool | Description | Key params | Annotations | | ---- | ----------- | ---------- | ----------- | | `list_deals` | List and filter deals. Defaults to `fields=ids`, which is free. | (filters) | read-only | | `get_deal` | One deal in full, with compact `company` and `contact` blocks for what it is linked to. | `deal_id` | read-only | | `create_deal` | Create a deal. Not idempotent: calling it twice creates two deals, unless you pass your own `external_deal_id`, which makes it update the deal that has it. | `name`, `stage`, `amount`, … | write | | `create_deals_bulk` | Create many deals in one call. Not idempotent either: give each item an `external_deal_id` to make an import safe to repeat (an item whose id exists updates that deal). | `deals` | write | | `update_deal` | Move a deal between stages, revise the amount, or mark it won or lost. Only the fields passed change. Pass `null` to unlink it from its company or contact. | `deal_id` | write, idempotent | | `delete_deal` | Permanently delete a deal. The agent must confirm with the user first, naming the deal and its amount. | `deal_id` | destructive, idempotent | | `list_deal_notes` | All notes on a deal, newest first. | `deal_id` | read-only | | `create_deal_note` | Add a freeform note to a deal. | `deal_id`, `content` | write | | `update_deal_note` | Edit an existing note. | `deal_id`, `note_id`, `content` | write, idempotent | | `delete_deal_note` | Delete a note. Irreversible. | `deal_id`, `note_id` | destructive, idempotent | ## Tags Tags are free-form labels on contacts, companies and deals. They are lowercased when written, up to 64 characters each and 50 per record. Tagging is free at any volume. REST reference: [Tags](/docs/api/tags). | Tool | Description | Key params | Annotations | | ---- | ----------- | ---------- | ----------- | | `list_tags` | The workspace's tags with how many contacts, companies and deals carry each. Called before tagging, so an existing tag is reused instead of a near-duplicate. | — | read-only | | `add_contact_tags` | Add tags to contacts, by id or to everyone in a saved segment. Adding a tag that is already there changes nothing. | `tags`, then `contact_ids` or `segment` | write, idempotent | | `remove_contact_tags` | Remove tags from contacts. The contacts are not deleted. | `tags`, then `contact_ids` or `segment` | write, idempotent | | `add_company_tags` | The same for companies. | `tags`, then `company_ids` or `segment` | write, idempotent | | `remove_company_tags` | Remove tags from companies. | `tags`, then `company_ids` or `segment` | write, idempotent | | `add_deal_tags` | Add tags to deals, by id. | `deal_ids`, `tags` | write, idempotent | | `remove_deal_tags` | Remove tags from deals. | `deal_ids`, `tags` | write, idempotent | ## Segments A segment is a saved, named set of filters over contacts or companies. Its members are worked out when it is read, so it is always current. REST reference: [Segments](/docs/api/segments). | Tool | Description | Key params | Annotations | | ---- | ----------- | ---------- | ----------- | | `create_segment` | Save a segment from typed filters (the same grammar as the list tools' `filters`). | `name`, `entity`, `filters` | write | | `list_segments` | The workspace's saved segments: id, handle, type, entity and filters. | — | read-only | | `get_segment` | One segment by id or handle, with its member count as of now. Free. | `segment` | read-only | | `enrich_segment` | Run Apollo enrichment over every member of a segment as a background job. Spends Apollo credits. Needs Apollo connected and its *Enrichment* switch on. | `segment` | write, idempotent | | `broadcast_to_segment` | Add every contact in a segment to a Resend segment, as a background job. It assembles the audience and sends nothing. Needs Resend connected and *Manage segments* on. | `segment`, `resend_segment_id` | write, idempotent | | `get_job` | The status of a background job: `pending`, `running`, `succeeded`, `partial` or `failed`, with progress and a result summary. | `job_id` | read-only | Pass a segment's id or handle as `segment` to `list_contacts`, `list_companies`, `aggregate_data` and the tag tools to work on its members. ## Events | Tool | Description | Key params | Annotations | | ---- | ----------- | ---------- | ----------- | | `list_events` | List events with rich filters (by type, `text`/`class`/element, visitor, device, `platform`). Supports `count_only`, `fields=summary`. | (filters) | read-only | | `get_event` | Single event with associated contact/company. | `event_id` | read-only | | `get_event_types` | Distinct event names recorded for the tenant. | — | read-only | | `get_event_stats` | Total + breakdown by type, domain, day. | (filters) | read-only | ## Batch & Aggregate | Tool | Description | Key params | Annotations | | ---- | ----------- | ---------- | ----------- | | `batch_get_details` | Up to 25 contacts or companies in one call. | `entity_type`, `ids` | read-only | | `batch_get_activity` | Recent events for up to 25 contacts in one call. | `contact_ids` | read-only | | `aggregate_data` | Server-side group-by on contacts/companies/events. 50+ dimensions. | `entity`, `group_by` | read-only | `aggregate_data` is the right tool for any "by X, count Y" question. Always prefer it over paginating list tools. ## Schema and usage REST reference: [Schema, usage and capabilities](/docs/api/workspace). | Tool | Description | Key params | Annotations | | ---- | ----------- | ---------- | ----------- | | `describe_schema` | The fields that can be filtered and grouped on for contacts, companies or deals, per connected source, with the type and operators of each. Called before using `filters`, `field_filters` or `group_by`. | `entity` | read-only | | `get_usage` | Which workspace the connection reaches, how many active profiles it has used this billing cycle and how many remain, and when each connected source last synced. | — | read-only | ## Knowledge Knowledge is the workspace's own pages about its business: product, pricing, ideal customers, terminology, playbooks, rules for agents. Agents read them before writing for or reasoning about the business. REST reference: [Knowledge](/docs/api/knowledge). The tools appear when a workspace admin has turned on **Read knowledge** (MCP Tools → Pathbound → Knowledge), and the two writing tools when **Add to knowledge** is on as well. Both are off by default. Reads require the `knowledge:read` scope and writes the opt-in `knowledge:write` scope. Every page and every search result carries a `trust`: `pinned` (the workspace's rules, set by an admin), `verified` (a person saved this version in the app) or `unverified`. Whatever an agent writes is `unverified` until a person saves it in the app. An agent can create pages and edit unlocked ones. It can never delete a page, change a locked or pinned page, or lock, pin or verify one. | Tool | Description | Key params | Annotations | | ---- | ----------- | ---------- | ----------- | | `list_knowledge` | The workspace's pages: title, summary, tags and trust. Called once at the start of a task about the business. | — | read-only | | `search_knowledge` | Keyword search across pages. Returns the matching sections, best first. | `query` | read-only | | `get_knowledge_page` | Read a page, or one section of it. A long page comes back as its opening and an outline, and the agent then reads the sections it needs. | `page` | read-only | | `save_knowledge_page` | Create a page, or replace the title, summary, tags or whole body of an existing one. | `slug` | write, idempotent | | `update_knowledge_section` | Replace one section of a page and leave the rest untouched, or add a new section. | `page`, `section` | write, idempotent | ## Email actions (Gmail) `gmail_send_email` **sends real email** from a Gmail mailbox the calling user connected themselves; agents never send from a teammate's mailbox. Connecting Gmail is limited to workspace admins, so today only admins' agents can send through Gmail: each admin connects a default mailbox plus up to five more of their own (Integrations → Gmail), and a mailbox can send once its grant includes `gmail.send` and a workspace admin turns on *Allow agents to send from this mailbox* for it. The tool appears when at least one of the user's mailboxes can send, and its description lists the allowed addresses. Choose the mailbox with `from`. Without it, the send uses the default mailbox, or the only mailbox that can send; when several can and none is the default, the call is refused with the list of allowed addresses. A named mailbox that cannot send is refused, never swapped for another. A Gmail thread belongs to one mailbox, so a reply with `thread_id` has to come from the mailbox that holds the thread. Writes require the `actions:write` scope and an `Idempotency-Key` (auto-generated by the MCP server), and every send is logged on the contact timeline with the sending address. REST callers list the same mailboxes with `GET /v1/integrations/gmail/senders` and pass `from` to `POST /v1/contacts/{contact_id}/send_email`. Sending can also wait. `gmail_send_email` takes `send_at` (ISO 8601 with an explicit offset, up to 30 days ahead) to schedule, and `gmail_create_draft` saves an email without sending it. Both are held by Pathbound, not by Gmail: a draft never appears in the Gmail Drafts folder, and the agent reviews it with `gmail_list_outbox` (`include_body` returns the text exactly as it will be sent) before `gmail_send_draft` sends it, now or at `send_at`. Drafts expire after 30 days, and `gmail_cancel_outbox_email` discards a draft or cancels a scheduled email until it is sending. When a scheduled email fires, Pathbound re-checks that the person who asked to send is still a verified member with a valid API key or connector, that the chosen mailbox can still send, and that the contact's email is unchanged; otherwise the email fails with a reason rather than going out from another mailbox or to another address. Delivery is at most once: if Gmail never answers, the email ends `failed` with `SEND_UNCONFIRMED` and is not retried, so check the Sent folder before resending. REST callers use `POST /v1/integrations/gmail/outbox` (`draft: true` or `send_at`), `GET /v1/integrations/gmail/outbox`, `POST /v1/integrations/gmail/outbox/{outbox_id}/send` and `DELETE /v1/integrations/gmail/outbox/{outbox_id}`; `send_email` itself rejects `send_at` and `draft`. | Tool | Description | Key params | Annotations | | ---- | ----------- | ---------- | ----------- | | `gmail_send_email` | Send one email to a contact from one of your Gmail mailboxes, now or at `send_at`. `thread_id` (from `list_contact_email_threads`) replies within that thread; `include_signature` appends your saved signature. | `contact_id`, `subject`, `body`, `from`, `send_at` | write | | `gmail_create_draft` | Save an email as a draft that Pathbound holds (not visible in Gmail). Sends nothing until `gmail_send_draft`. | `contact_id`, `subject`, `body`, `from` | write | | `gmail_send_draft` | Send a draft now, or schedule it with `send_at`. Refused, not rerouted, if the mailbox or the contact's email changed. | `outbox_id`, `send_at` | write | | `gmail_list_outbox` | List your drafts and scheduled, sent, failed or canceled emails; `include_body` returns a draft's text for review. `counts` gives the totals, and `after` reads on from the last email received. | `status`, `contact_id`, `include_body`, `after` | read-only | | `gmail_cancel_outbox_email` | Discard a draft or cancel a scheduled email before it is sent. | `outbox_id` | write, destructive | REST reference: [Gmail](/docs/api/gmail). ## Email actions (Resend) These tools act in the workspace's own Resend account: they manage segments, send one email to a contact, send one of the account's events for a contact, and create and send broadcasts. They appear when Resend is connected and a workspace admin has switched their group on (MCP Tools → Resend): *Manage segments*, *Send email*, *Create broadcasts*, *Send broadcasts* and *Send events* are separate switches. Writes require the `actions:write` scope and an `Idempotency-Key` (auto-generated by the MCP server). The REST reference, [Resend](/docs/api/resend), says what each switch governs. | Tool | Description | Key params | Annotations | | ---- | ----------- | ---------- | ----------- | | `resend_list_segments` | The Resend account's segments: id, name and creation date. | — | read-only | | `resend_create_segment` | Create a segment in Resend. | `name` | write | | `resend_add_contacts_to_segment` | Add up to 25 Pathbound contacts to a Resend segment. A contact that is not in Resend yet is created there. | `segment_id`, `contact_ids` | write, idempotent | | `resend_remove_contact_from_segment` | Remove one contact from a Resend segment. The contact is not deleted. | `segment_id`, `contact_id` | destructive, idempotent | | `resend_delete_segment` | Permanently delete a Resend segment. Its contacts are not deleted. | `segment_id` | destructive, idempotent | | `resend_list_contact_segments` | The Resend segments a contact belongs to. | `contact_id` | read-only | | `resend_send_email` | Send one email to a contact through Resend, from a sender on a domain verified in the account, now or at `scheduled_at`. The recipient is the contact's own address. | `contact_id`, `from`, `subject`, `body` | write, idempotent | | `resend_list_contact_emails` | The emails sent to a contact through Pathbound's Resend tools, newest first: subject, and when each was sent or is scheduled. | `contact_id` | read-only | | `resend_create_broadcast` | Create a broadcast addressed to a segment. It does not send. | `name`, `segment_id`, `from`, `subject`, `body` | write | | `resend_send_broadcast` | Send or schedule an existing broadcast to its whole segment. It cannot be recalled, and the agent must confirm with the user first. | `broadcast_id` | destructive | | `resend_list_events` | The events defined in the Resend account, with the fields each declares. | — | read-only | | `resend_send_event` | Send one of those events for a contact, which starts the automations built on it in Resend. Resend accepting the event does not mean an email was sent. | `contact_id`, `event` | write, idempotent | To add a whole Pathbound segment to a Resend segment, use `broadcast_to_segment` under [Segments](#segments). ## Cold email actions (Instantly) These tools appear when Instantly is connected and a workspace admin has switched them on (MCP Tools → Instantly). They require the `actions:write` scope. REST reference: [Instantly](/docs/api/instantly). | Tool | Description | Key params | Annotations | | ---- | ----------- | ---------- | ----------- | | `instantly_add_lead` | Add a person to an Instantly campaign, so that they enter its sending sequence. | `campaign_id`, `email` | write, idempotent | | `instantly_update_lead_interest` | Set the interest status of an Instantly lead, for example interested, meeting booked or not interested. | `lead_email`, `interest_value` | write, idempotent | ## Enrichment and prospecting actions REST reference: [Enrichment and prospecting](/docs/api/enrichment). These tools are **provider-agnostic**: you never name a data provider, the workspace's connected one runs the call, and every response reports which one acted as `via`. Two providers can run them, **Apollo** and **Hunter**, and each spends the tenant's own credits with that provider. A tool appears when at least one provider is connected and a workspace admin has enabled it for that provider (MCP Tools → Apollo or Hunter). When both are connected and both allow an action, Apollo runs it; to have Hunter run an action, leave Apollo's toggle for that action off. They also require the `enrichment:write` scope (opt-in-only; never auto-granted to legacy keys). Whatever they find **always lands in the contact graph** — identity-resolved, provenance-stamped, taggable, and deliverability-checked — so a prospect found here is the same record every other connected source enriches later. That is the part a single-provider connector cannot do, and it is why these tools are not named after the provider. A filter no connected provider can express is refused before anything is spent, rather than dropped — a dropped filter widens the search and you pay for the wider result. The same goes for a filter *value* a provider cannot express exactly: the call is refused with a hint naming what the provider accepts, never run as a wider search. Three admin toggles cover three kinds of work. *Enrichment* refreshes records already in Pathbound. *People discovery* expands an account already in Pathbound (`find_people_at_companies`). *Prospecting* (`prospect_companies`, `prospect_people`) searches the provider's whole database by **company shape** — size, HQ location, keyword tags, technologies in use, active job postings — and saves the matches as `lifecyclestage=prospect` records, tagged so you can segment, enrich, stream or enroll the batch afterwards. A company is only ever created from a domain; companies the provider returns without one are reported, not saved. People already in the graph with an email are skipped so credits are never spent twice; people saved earlier without a reveal are revealed and linked to their company on a later call. **Job-change signal.** Apollo watches the contacts saved in your Apollo team and records when one changes title or employer. Pathbound reads that event on its free saved-record path and writes it onto the contact as `job_changed_at`, `previous_jobtitle` and `previous_company` — with `jobtitle` and `company` updated to the new values — plus the tag `signal:job-change`. Pull the cohort with `list_contacts` `tags_any="signal:job-change"`, refine with a `job_changed_at` date filter, save it as a segment, or point a stream at the tag. Prospect-stage contacts are re-checked about every 30 days when a workspace admin enables *Refresh prospects* under Integrations → Apollo (free, saved records only). **Deliverability.** Every result that adds addresses reports `deliverability`. `verified` means the provider verified the addresses itself and the verdict is already on each contact (`email_verification_state`); Hunter does this for every address it finds. `queued` means Emailable is checking the ones without a verdict in the background. `nothing_to_check` means the call added no new address; it says nothing about Emailable. `not_connected` means there are new addresses to check and no verifier is connected. **Checking an address a contact already has.** Emailable checks contacts created after it was connected, in the background, and the tools above queue only the addresses a provider found. `verify_contact_email` checks one contact's own address now and stores the verdict on the contact. It takes no address: the one on the contact is checked, and a call that passes one is refused. It costs 1 Emailable credit, and a verdict already on file for the contact's current address comes back free with `checked: false` unless `force` is set. It appears when Emailable is connected and a workspace admin has turned on **Check email addresses** (MCP Tools → Emailable), and it requires `enrichment:write`. **Costs on Apollo.** People search is free; revealing a person's email is ~1 credit. Company search is 1 credit per page (up to 50 companies per call). Company enrichment is ~1 credit per match. Every call is bounded (25 people, 50 companies) and counts against the workspace's daily interactive Apollo budget. Company search needs an Apollo connection made after 2026-09-11 — reconnect Apollo if the tool reports a missing scope. **Costs on Hunter.** Company search is free. People and their emails come together, at 1 credit per company that has matches (up to 10 people), so `reveal_emails=false` returns nothing and spends nothing. Enriching a contact or a company is 0.2 credit; finding an email for a contact that has none is 1 credit, and only when one is found. `credits_consumed` is counted from those prices: Hunter bills a repeated search once per billing period, so the real charge can be lower. Calls are bounded the same way (25 people, 50 companies) and count against a daily interactive Hunter budget. **What Hunter cannot express.** Hunter has fixed company sizes (1-10, 11-50, 51-200, 201-500, 501-1000, 1001-5000, 5001-10000, 10001+), so an employee range must start and end on those edges. It knows three seniority levels (`junior`, `senior`, `executive`); name specific roles with `titles` instead. A city needs its country (`"Berlin, Germany"`). Job postings can be limited to the last 7, 30 or 90 days only: a `hiring_posted_after` date is applied as the widest of those that fits inside it, and the result note says so. Free-text person `keywords` and `days_in_title_*` are not supported, and technology filters need a paid Hunter plan. `prospect_people` looks inside up to 15 matching companies per call, takes up to 3 people from each, and returns no `search_total`. A person who has asked Hunter to stop processing their data comes back `skipped` with the reason `claimed_email` and is never sent to Hunter again. | Tool | Description | Key params | Annotations | | ---- | ----------- | ---------- | ----------- | | `enrich_contacts` | Enrich existing contacts (email, title, LinkedIn, phone, location). Up to 25 IDs. Apollo: ~1 credit per match. Hunter: 0.2 credit, or 1 to find a missing email. | `contact_ids` | write | | `enrich_companies` | Enrich existing companies (industry, headcount, revenue, founded year). Up to 25 IDs. Apollo: ~1 credit per match. Hunter: 0.2. | `company_ids` | write | | `find_people_at_companies` | Anchored discovery: find people at a company **already in Pathbound**, reveal emails, and save them as contacts linked to that company. Optional `titles`/`seniorities`/`departments`/`locations`/`keywords`, `limit` (≤25), `tags`, `lifecyclestage`. Apollo: free search, ~1 credit per revealed email. Hunter: 1 credit per company with matches. | `company_ids` | write | | `prospect_companies` | Prospecting: search for companies by shape (`employee_ranges`, `locations`, `keywords`, `technologies`, `hiring_titles` + `hiring_posted_after`, `domains`, `name`) and save them as prospect companies with `tags`. Returns `search_total` (the size of the matching universe) and the Pathbound ids to feed `find_people_at_companies`. Optional `enrich` runs firmographic enrichment on the new ones. Apollo: 1 credit per search page. Hunter: free. | at least one filter | write | | `prospect_people` | Prospecting: search for people by title/seniority/department **and** company shape (`org_*` filters, at least one required), reveal emails, and save them as prospect contacts linked to their employer company, which is created from the reveal's domain when new. `limit` (≤25), `tags`, `reveal_emails`. Apollo: free search, ~1 credit per revealed email. Hunter: 1 credit per company with matches. | `titles`, `org_*` | write | | `verify_contact_email` | Check one contact's own email address with Emailable now, and store the verdict on the contact (`email_verification_state`, plus Emailable's `reason`, `score`, `free`, `role`, `disposable`, `accept_all`, `did_you_mean`). 1 Emailable credit; free when a verdict is already on file for the address, unless `force`. | `contact_id` | write | ## Engagement actions (Apollo sequences & email) REST reference: [Apollo sequences](/docs/api/apollo). These tools manage Apollo outreach; enrollment and single-email tools **send real email** through the tenant's Apollo mailboxes, while sequence creation writes an inactive draft and turning it on is a separate, confirmed step. They appear only when Apollo is connected with a grant that includes the sequence permissions (connections made before September 2026 must be reconnected) and a workspace admin has enabled them (MCP Tools → Apollo → *Sequences* / *Single email*). Writes require the `actions:write` scope and an `Idempotency-Key` (auto-generated by the MCP server); over REST the engagement reads (`GET /v1/integrations/apollo/senders`, `sequences`, `sequence-options`, `contacts/{contact_id}/sequence-activity`, `scheduled-emails`) require `actions:write` too, since they describe the tenant's outbound inventory. Every enrollment or send is anchored to a Pathbound contact or segment and recorded on the contact timeline. Engagement (`apollo_email_sent`, `apollo_email_opened`, `apollo_email_replied`, `apollo_email_bounced`, `apollo_sequence_finished`, …) is read back from Apollo on a schedule and lands there too. Two rules hold on every surface. **The sending mailbox is chosen explicitly every time** — the agent lists the allowed mailboxes, the user confirms one by address, and there is no default; an admin curates the allowed list under Integrations → Apollo (empty = every active mailbox linked in the Apollo team). And **Apollo's safeguards are never overridden**: contacts Apollo refuses (already in another sequence, do-not-contact stage, unverified email, …) come back in `skipped` with the reason. Agents can author automatic-email sequence drafts; Pathbound creates them inactive, and turning one on is a separate `apollo_activate_sequence` call the agent must confirm with the user (turn it off again with `apollo_pause_sequence`). **Scheduling and rulesets.** A sequence's *sending schedule* decides the days and hours Apollo may send its emails. Agents can read the team's schedules and attach one to a sequence — at creation or afterwards — but the windows themselves are only editable in Apollo (Settings → Sequences → Schedules), which publishes no API for creating or changing a schedule. A *sequence ruleset* (shared settings applied to a sequence) can be set only when the sequence is created, and Apollo has no endpoint that lists or names rulesets, so `apollo_list_sequence_options` reports the ids already applied to the team's sequences alongside the sequences using each one. Omit it and the sequence is created with a blank ruleset, which is Apollo's own default. | Tool | Description | Key params | Annotations | | ---- | ----------- | ---------- | ----------- | | `apollo_create_sequence` | Create an inactive Apollo sequence draft with 1–10 automatic-email steps, ready to send once activated. Optionally takes a `schedule_id` and a `ruleset_id`. It does not activate or enroll anyone. | `name`, `steps` | write | | `apollo_activate_sequence` | Turn a sequence on: Apollo starts sending its steps to every enrolled contact on the sequence schedule. Requires explicit user confirmation in the same turn. | `sequence_id` | write | | `apollo_pause_sequence` | Turn a sequence off: every enrolled contact pauses and nothing sends until it is activated again. | `sequence_id` | write | | `apollo_list_sequences` | The team's sequences with stats, plus the sending schedule and ruleset each one runs under; archived hidden by default. Call before enrolling. | `q` | read-only | | `apollo_list_sequence_options` | The team's sending schedules — name, time zone and the weekly windows rendered as `sending_hours` (e.g. "Mon–Fri 8:00–17:00") — plus the sequence rulesets already in use. Call to answer "when will this send", and before creating a sequence or changing its schedule. | — | read-only | | `apollo_set_sequence_schedule` | Point an existing sequence at one of those schedules, changing the days and hours it may send — including for contacts already enrolled. Sends only the schedule field: it never touches steps, owner, copy or on/off state. | `sequence_id`, `schedule_id` | write | | `apollo_list_senders` | Mailboxes an agent may send from (the allowlist). Call before enrolling or sending a single email and confirm the sender with the user. Available when either Apollo tool is enabled. | — | read-only | | `apollo_add_to_sequence` | Enroll 1–25 contacts in an existing sequence from a chosen mailbox. `start_at` enrolls them paused until that time. Contacts are matched or created in Apollo by email (free). Once per contact per sequence. | `contact_ids`, `sequence_id`, `sender_account_id` | write | | `apollo_add_segment_to_sequence` | The whole-segment version, as a background job (`get_job` for progress and the per-reason skip breakdown). | `segment`, `sequence_id`, `sender_account_id` | write | | `apollo_stop_in_sequence` | Stop (keep history) or remove contacts from a sequence. | `contact_ids`, `sequence_id`, `mode` | write | | `apollo_sequence_activity` | One contact's enrollment records plus Apollo's activity feed (enrolled, replied, completed, removed…). Meters like `get_contact_timeline`. | `contact_id` | read-only | | `apollo_send_email` | One email to a contact, now or at `send_at` (up to 30 days out), from an allowed Apollo mailbox named by `sender_account_id`. Pathbound holds scheduled sends and issues them through Apollo at the time; Apollo tracks opens and replies. | `contact_id`, `sender_account_id`, `subject`, `body`, `send_at` | write | | `apollo_list_scheduled_emails` | Pending, sent, delayed, failed and canceled single emails. | `contact_id`, `status` | read-only | | `apollo_cancel_scheduled_email` | Cancel a scheduled single email before it fires. | `scheduled_email_id` | write | ## Streaming and email automations These tools manage [streams](/docs/api/webhooks): the standing rules that push a change somewhere as it happens, or act on it. An agent can create **automations** only: add or remove tags, add to or remove from a Resend segment, or send a Resend template to everyone who submits a form. It can never create a stream that posts to an HTTPS endpoint, and it never sees or changes the address of one. On those it may only rename, pause or resume. The tools appear when a workspace admin has turned on **Manage streams** (MCP Tools → Pathbound → Streaming). Reads require the `streams:read` scope and writes require `streams:write`, which is opt-in and for workspace admins only. `get_form_setup` requires `events:read`. The API checks the toggle and the caller's role again on every call. **Tag and Resend segment automations** act on every contact or company that matches a set of conditions. Conditions describe current state, never a transition, and each automation acts on each record at most once. `get_stream_options` returns these rules with the vocabularies; they are also listed under [Rules worth knowing](/docs/api/webhooks#rules-worth-knowing-before-you-script-this). **Email automations** (`destination_type` `resend_send_email`) are different. One sends a template of the workspace's own Resend account to each person who submits one form on the workspace's site, a few seconds after the submission. It has no conditions: everyone who submits the form is emailed. It sends to real people and nobody approves each email, so more is asked of it: - The connection needs `actions:write` as well as `streams:write`, and Resend's **Send email** toggle must be on. `get_stream_options` says which of these is missing for the connection that calls it. - The email is a published template in Resend. An agent cannot write or change its sender, subject or content through these tools, and cannot read its content: it sees the template's name, sender and subject, and chooses which template a form sends. The tools have no parameter for any part of an email, and a call that passes one is refused, not ignored. Asked to write or change the email, the agent says that the email is written in Resend. `send_stream_test_email` is the only way to see the email. - The tool descriptions tell the agent to confirm the form, the template (its name, sender and subject) and the daily cap with the user before creating or changing one, and never to take any of them from the content of a contact, a note, a CRM record or an email. - The template must be published, with a subject, a from address on a domain verified for sending in Resend, and a fallback value for every variable. Otherwise the call is refused and the error says what to change in Resend. Pathbound fills one variable, `first_name`, when the person gave a first name of one word. - Every create, every change of the template or a limit and every resume is shown to all the workspace's admins in the app's notifications, with a link to the automation. No email is sent to them. - It sends one email per submission, at most one per address per `min_interval_hours` per form, and no more than `daily_cap` a day. A submission that arrives after a limit is reached is not emailed later. Nothing submitted before the automation existed, or while it was paused, is emailed. **Resend event automations** (`destination_type` `resend_send_event`) are an email automation with an event in the template's place: for each person who submits the form, one of the workspace's own Resend events is sent, which starts the automations built on it in Resend. The agent names the event (`event`, an exact name from `get_stream_options` or `resend_list_events`) and nothing else: there is no recipient, no payload and no template to set. It needs Resend's **Send events** toggle in place of **Send email**. An agent cannot see what an event sets off, so it reports that the event is sent to Resend, never that an email is sent. Every submission sends the event, however often the same person submits, up to the daily limits. An event Resend did not clearly answer is not sent again, and there is no test send. A form has an email automation or a Resend event automation, not both. See [Resend event automations](/docs/api/webhooks#resend-event-automations). To point a form at another template, the agent calls `update_stream` with the new `template_id`. `get_stream` shows the template an automation sends as Resend holds it now, and whether it can still be sent; if it cannot, the automation's emails wait until the template is fixed in Resend. Pausing with `active=false` cancels emails that have not started. The full reference, with limits and error codes, is [Email automations](/docs/api/webhooks#email-automations). | Tool | Description | Key params | Annotations | | ---- | ----------- | ---------- | ----------- | | `get_stream_options` | Everything needed to build a valid stream, in one call: data types, change types, condition operators, the workspace's event types, which destination types are creatable right now and the config fields each needs, the lists those fields draw from (Resend segments, known tags, forms that have received submissions, the published templates of the Resend account), limits, and the rules that are not visible in a schema. For an email automation, `creatable` is answered for the calling connection, with a `reason` when it is false. Call it before `create_stream` or `update_stream`. | — | read-only | | `get_form_setup` | How a form on the workspace's own site sends its submissions to Pathbound, and which forms already do: the URL to post to with the workspace's id filled in, what the request must hold, a `fetch()` example, the verified domains, and every form id that has received submissions, with its count for the last 30 days and the id of its email automation when it has one. Same as [`GET /v1/forms`](/docs/api/forms#list-forms). | — | read-only | | `list_streams` | The workspace's streams: id, name, data type, change type, destination type and label, conditions, filters and active state. Tag and segment automations report `enrolled_count`. An email automation reports its counters (`sent_count`, `sent_today`, `failed_count`, `skipped_count`), and its `destination_config` holds the form, the template (`template_id`, `template_name`) and the limits. The list does not say whether the template can still be sent. Destination URLs of HTTPS streams are never returned. | `page`, `limit` | read-only | | `get_stream` | One stream by its id. For an email automation it adds `template`: the template's name, sender, subject, reply-to addresses and variables as Resend holds them now, and `usable`, with a `problem` that says what to change in Resend when it is false. The template's content is never returned. It also returns `blocked_since` while Resend refuses the workspace's key or the sender. | `stream_id` | read-only | | `preview_stream_matches` | Count how many contacts or companies match a condition set right now, without creating anything. Returns counts only, never the records. It does not apply to email automations, which have no conditions. | `resource_type`, `conditions` | read-only | | `create_stream` | Create an automation. A tag or segment automation takes `tags` or `segment_id`, with `resource_type`, `event_type` (required) and `conditions`, and returns `estimated_matches`. An email automation takes `form_id` and `template_id` (the template's id or alias in Resend), optionally `daily_cap` and `min_interval_hours`, and no `resource_type`, `event_type` or `conditions`: a call that sets one is refused, never adjusted. `validate_only` runs every check and creates nothing; for an email automation it returns the template as Resend holds it and notifies nobody. A Resend event automation takes `form_id` and `event` (the event's exact name in Resend) and optionally `daily_cap`; it has no `min_interval_hours`, because it sends for every submission. | `destination_type`, then the fields of that type | write | | `update_stream` | Change a stream. Tag and segment automations: `name`, `event_type`, `conditions`, `active`. HTTPS streams: `name` and `active` only. Email automations: `name`, `active`, `template_id`, `daily_cap` and `min_interval_hours`, never the form and never the email's sender, subject or content. Whichever of them changes, the template is read from Resend again and must be usable as it is then. Resend event automations: `name`, `active`, `event` and `daily_cap`, never the form. | `stream_id` | write, idempotent | | `send_stream_test_email` | Send an email automation's template once, as a person who submitted the form would get it, to the address of the user's own Pathbound account. It has no recipient parameter, and any argument other than `stream_id` is refused. A real send: it counts against the workspace's daily total, not the automation's cap, and at most 5 can be sent a minute and 20 a day per workspace. On `SEND_UNCONFIRMED` the email may have been sent, so the inbox is checked before another is sent. | `stream_id` | write | | `delete_stream` | Permanently delete a stream and its history. Recreating a tag or segment automation afterwards applies its action to every matching record again. Deleting an email automation cancels its queued emails; the record of which addresses it emailed is kept, so recreating it for the same form does not email the same people again before `min_interval_hours` has passed. To stop a stream for a while, use `update_stream` with `active=false`. | `stream_id` | destructive, idempotent | ## Authoring custom MCP integrations The Pathbound MCP server is built on the [`@modelcontextprotocol/sdk`](https://github.com/modelcontextprotocol/sdk). The same tool list above is exposed via the standard `tools/list` method — any compliant MCP client can connect via OAuth (interactive) or with a REST API key (headless) and call them. See [MCP authentication](/docs/mcp/authentication) for both flows. The source of truth for each tool's parameters and behavior is the schema the server returns from `tools/list`. --- # Events — Overview > A 5 KB JavaScript snippet that captures behavioral events on your website and links them to Pathbound contacts. Source: https://pathbound.ai/docs/events/overview Events is Pathbound's built-in event capture. A small JavaScript snippet runs on your website and captures behavioral events — page views, clicks, form submissions, video plays — into the Pathbound platform. Once a visitor identifies (by submitting a form, clicking a tracked email link, or being matched via fingerprint), every event in their history is attached to their [contact](/docs/api/contacts) record. ## Snippet URL ``` https://events.pathbound.ai/snippet.js ``` Sites set up before October 2026 load `https://tracker.pathbound.ai/tracker.js`. That address keeps working unchanged, so there is nothing to update. The script is served minified and gzipped/brotli-compressed, around 5 KB on the wire. There are no client-side dependencies; everything runs in vanilla browser JS. ## What gets tracked Out of the box, no configuration: - **`page_view`** on every navigation, with title, path, UTM params, and load time. - **`first_visit`** the first time a browser is seen. - **`button_click`** on every `