Skip to content
Pathbound DOCS

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.

Each subscription targets one data type:

Data typeChange typesFires when
eventa tracked event name, or *the events snippet or an integration records an event
contactcreated, updated, deleted, or *a unified contact is created, changed, or removed
companycreated, 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.

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.

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=<hex> (only if a signing secret is set)

Plus any custom headers you configured.

{
"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"
}
{
"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"]
}
  • object is the full record, identical in shape to GET /v1/contacts/:id / GET /v1/companies/:id. 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.

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

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.

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

  • 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.

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

MethodPathScope
GET/v1/streamsstreams:read
GET/v1/streams/optionsstreams:read
GET/v1/streams/:idstreams:read
GET/v1/streams/:id/deliveriesstreams:read
POST/v1/streams/previewstreams:write
POST/v1/streamsstreams:write
PATCH/v1/streams/:idstreams:write
DELETE/v1/streams/:idstreams: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.

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:

AutomationsHTTPS streams
Createyesno — dashboard only
name, activeyesyes
conditions, event_type, filters, resource_typeyesno
destination_config (the tags, the segment)immutable — recreate
destination_url, headers, secretnevernever

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.

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_typeActs onConfigMeters?
pathbound_tag_addcontacts, companies{ "tags": ["vip", "customer"] } (1–10)no
pathbound_tag_removecontacts, companies{ "tags": ["lead"] } (1–10)no
resend_segment_addcontacts{ "segment_id": "seg_…" }yes
resend_segment_removecontacts{ "segment_id": "seg_…" }yes

(resend_segment is still accepted as an alias of resend_segment_add.)

Terminal window
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"] }
]
}'
Terminal window
# 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 in operator. 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. 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 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_TAKEN naming 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, 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.
  • 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.