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.
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, email automations and Resend event automations.
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, an integration or the public form endpoint 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": "665f1c2ab3d4e5f601234567", "platform": "web", "os": "Mac OS X", "device_type": "Desktop"}platformisweb,iosorandroid: a browser, or a native app’s webview. Together withosanddevice_type(Desktop/Mobile/Tablet) it lets a receiver tell a tap in your mobile app from a click in a desktop browser without parsingdata.userAgent. See Server-added fields for how each is derived.contact_idis absent for a visitor who isn’t linked to a contact.platform,osanddevice_typeare absent on an event that did not come from the snippet.
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": "665f1c2ab3d4e5f601234567", "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, get_stream, create_stream, update_stream, delete_stream, preview_stream_matches, get_stream_options, send_stream_test_email; see the tools reference).
| 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. 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 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.
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. 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
Section titled “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. A sixth, resend_send_event, sends one of your Resend events for that person instead, and has its own section 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 | no |
resend_send_event | form submissions | a form and a Resend event: see Resend event automations | no |
(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 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. 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 segment automations meter one activation per contact per cycle, exactly like a delivery. An email automation does not meter: see Metering.
- 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. 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,
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.
Email automations
Section titled “Email automations”An email automation emails each person who submits one form on your site, through your own 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 and to one template of your Resend account. The Forms 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
Section titled “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:writeandactions:writeon the API key or the MCP connection.streams:writealone is refused with403 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
Section titled “The template”The Forms page says how to 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 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 thefirstnamesent 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,bccorvariablesindestination_configis refused with400 STREAM_CONFIG_FIELD_UNKNOWNand a message that says it is set on the template in Resend. It is never silently ignored.
When the template changes in Resend
Section titled “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 stay
pendingwith the reasontemplate_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. MeanwhileGET /v1/streams/:idreturnstemplate.usable: falsewith aproblemthat says what to change. - A form should send a different template. Change the automation’s
template_id: see Change.
Create
Section titled “Create”POST /v1/streams with destination_type: "resend_send_email". Send no event_type, no resource_type and no conditions.
{ "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. |
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
{ "status": "success", "stream": { "_id": "6703f1c2a4b5c6d7e8f90123", "destination_type": "resend_send_email", "…": "…" }, "form": { "form_id": "playbook", "submissions_last_30_days": 12 }}stream is 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
{ "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", "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
Section titled “The stream object”{ "_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", "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_idis always Resend’s id of the template, also when you named the template by its alias.template_nameis the template’s name in Resend when the automation was last saved. The server sets it; sending it is refused.templateis the template as Resend holds it when you ask. It is not stored.GET /v1/streams/:id, a create, avalidate_onlyand a change of the config or a resume return it.GET /v1/streams(the list) does not.variablesare the variables the template declares.fillsare those Pathbound gives a value to:first_name, or none.- When the template can no longer be sent,
usableisfalseandproblemis a sentence that says what to change in Resend. For a template that was deleted, onlyid,name,usableandproblemare returned. - When Resend could not be asked, it is
{ "id": "…", "name": "…", "unavailable": true }.
active_sinceis set when the automation is created and again at every resume. Nothing submitted before it is emailed.blocked_sinceis present only while Resend refuses the key or the sender. See What is emailed, and what is not.- The counters count runs by status, and
sent_todaycounts from 00:00 UTC.GET /v1/streamsandGET /v1/streams/:idreturn them. There is noenrolled_countfor this type.
Change
Section titled “Change”PATCH /v1/streams/:id. For this type the body may hold only name, active and destination_config.
{ "destination_config": { "template_id": "playbook-email-v2", "daily_cap": 100 }}destination_configmay hold any oftemplate_id,daily_capandmin_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 insidedestination_configthat is not in the list above (template_nameand every part of an email included), and any other top-level field (conditions,event_type,resource_type, filters) are refused with403 STREAM_FIELD_FORBIDDEN.validate_onlyis refused with400 STREAM_FIELD_UNSUPPORTED. nameis one line of up to 100 characters (400 STREAM_NAME_INVALIDotherwise).- A credential without
actions:writethat sendsdestination_configis refused with403 STREAM_SEND_SCOPE_REQUIREDbefore 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_idapplies 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
Section titled “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.
{ "status": "success", "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
Section titled “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_hoursper form (24 hours unless you change it), even if the person submits again, and even if the automation is deleted and created again.[email protected]counts as[email protected], 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.
- 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
unconfirmedand 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. 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
Section titled “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.
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.
{ "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. |
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
Section titled “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
Section titled “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
Section titled “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 (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
Section titled “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: 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 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
Section titled “Create an event automation”POST /v1/streams with destination_type: "resend_send_event". Send no event_type, no resource_type and no conditions.
{ "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. |
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
Section titled “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 declaresfirst_nameas 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
Section titled “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
Section titled “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
Section titled “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
Section titled “Error codes of an event automation”The error codes of an email automation 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
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.