Streaming webhooks
Streaming is Pathbound’s third way to get your data, alongside the REST API and the MCP server:
- 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.
Streams are managed in the dashboard. Their trigger — conditions, filters, change type, on/off — can also be managed over the REST API and by an AI agent through the MCP server. Their destination cannot: see below.
What you can stream
Section titled “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 or an integration 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
Section titled “Subscribing”In the dashboard, open Events → Streaming → New stream, then:
- Pick the data type (events, contacts, or companies).
- Pick the change type — for events, the event name or
*; for contacts/companies,created/updated/deleted/*. - Set the destination URL. Must be
https://and publicly reachable — internal IP ranges are rejected. - Optionally add filters (see below).
- Optionally add custom headers (encrypted at rest) and a signing secret.
- Save.
Delivery format
Section titled “Delivery format”Pathbound sends a POST with a JSON body and these headers:
Content-Type: application/jsonUser-Agent: Pathbound-Webhooks/1.0X-Pathbound-Signature: sha256=<hex> (only if a signing secret is set)Plus any custom headers you configured.
Event payload
Section titled “Event payload”{ "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": "ct_abc"}Contact / company payload
Section titled “Contact / company payload”{ "id": "ec_abc123", "type": "contact.updated", "resource": "contact", "action": "updated", "timestamp": "2026-06-08T12:00:00.000Z", "source": "hubspot", "object": { "contact_id": "ct_abc", "properties": { "email": "[email protected]", "jobtitle": "VP Engineering", "lifecyclestage": "mql" } }, "changed_fields": ["jobtitle", "lifecyclestage"]}objectis the full record, identical in shape toGET /v1/contacts/:id/GET /v1/companies/:id. Fordeletedit is the last-known state, ornull.sourceis the origin of the change: an integration name (hubspot,apollo, …), ormanual/form/api/system.changed_fieldslists the fields that changed when available — it can be empty (creates, deletes, and updates where a per-field diff isn’t computed). Treatobjectas the source of truth and reconcile byupdated_at.
Filters
Section titled “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
Section titled “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.
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));}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
Section titled “Delivery semantics”- Timeout. Respond
2xxwithin 30 seconds. - Retries. A non-
2xx, timeout, or network error is retried up to 3 times with backoff (1s, 10s, 60s). A4xxis 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
objectas the latest state and reconcile byupdated_atrather 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
Section titled “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, create_stream, update_stream, delete_stream, preview_stream_matches, get_stream_options).
| 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 |
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 server settings. 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.
What the API cannot do
Section titled “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. destination_url, custom headers and signing secrets are never settable and never returned.
What it can change depends on where the stream delivers:
| Automations | HTTPS streams | |
|---|---|---|
| Create | yes | no — dashboard only |
name, active | yes | yes |
conditions, event_type, filters, resource_type | yes | no |
destination_config (the tags, the segment) | immutable — recreate | — |
destination_url, headers, secret | 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
Section titled “Automations”An automation is a stream whose destination is an action. Four are available; GET /v1/streams/options reports which are creatable for your workspace right now and lists the valid values for each one’s config field (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_segment is still accepted as an alias of resend_segment_add.)
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"] } ] }'# 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
Section titled “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
inoperator. Across fields, create two automations — tag automations may share the same tags. - Each 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.
- 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. Thesourcereported 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 automations meter one activation per contact per cycle, exactly like a delivery.
- One add and one remove automation per Resend segment per change type. A second returns
409 STREAM_DESTINATION_TAKENnaming the existing stream; update that stream’s conditions rather than adding another. Tag automations have no such limit. - 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,
PATCHit with"active": false. - Pausing longer than 24 hours is lossy. A reactivated stream resumes from the present; changes during the pause are not replayed.
Best practices
Section titled “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
idas a dedup key. - Inspect deliveries in the dashboard — response code, latency, body, and attempt count per subscription.