Batch & Aggregate
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
Section titled “Batch Get Details”POST /v1/batch/detailsRequired 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
Section titled “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
Section titled “Example”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
Section titled “Response — 200 OK”{ "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
Section titled “Batch Get Activity”POST /v1/batch/activityRequired 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
Section titled “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
Section titled “Response — 200 OK”{ "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
Section titled “Query Meetings”POST /v1/meetings/queryRequired 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.
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
Section titled “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. 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
Section titled “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:
painsfindsTop pains (ranked). A heading returns everything under it. On a meeting that only has topics, names match topic titles. topicsreturns the topic list with its bullets.allreturns 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
Section titled “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
Section titled “Response — 200 OK”{ "status": "success", "meetings": [ { "_id": "6650f1c2e4b0a1b2c3d4e5f6", "name": "Acme discovery", "happened_at": "2026-09-22T15:00:00.000Z", "duration_seconds": 2280, "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. 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
Section titled “Aggregate”POST /v1/aggregateRequired 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.
Request Body
Section titled “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
Section titled “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 assource.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
Section titled “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,iosorandroid; anything else is a400),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
Section titled “Example — events per day for the last 30 days”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
Section titled “Response — 200 OK”{ "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 <metric>_<metric_field> 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.