Webhooks v1 → v2
Webhooks v1 is per workspace and configured as an Action. Webhooks v2 is per organization: one endpoint, one place, every workspace.
Nothing forces you to move. Both systems are supported and run side by side. This page is for deciding whether v2 does your job better, and for doing the move if it does.
The short version
| Webhooks v1 | Webhooks v2 | |
|---|---|---|
| Configured in | Each workspace, as an Action | Organization → Webhooks |
| Number of things to manage | One rule per event per binding | One endpoint per receiver |
| New workspace joins | Recreate every rule there | Nothing to do |
| Vocabulary | Event Rule triggers | The activity event catalog |
| Payload | Five shapes by family, includes field values | One shape, identifiers only |
| Per-Form / per-Workflow targeting | Yes — a rule binds to one object | No — you filter on your side |
| Data record events | Yes | Yes — emitted, gated on the per-Form opt-in |
| Retries | 5 attempts, 4xx is permanent | 12 attempts over ~a day, 4xx retried |
| Backlog while your receiver is down | Lost after the retries | Held for 7 days, delivers on resume |
| Test send | Per rule, in the action form | Per endpoint, with an editable payload |
Move to v2 when
- You manage the same integration across several workspaces, and adding a workspace means recreating a pile of rules.
- You want a receiver outage to be recoverable rather than a gap — v2 holds the backlog for seven days and delivers it when you resume.
- You want one place to see what is subscribed and what has been delivered.
Stay on v1 when
- You need data record events that fire today. v2 now emits
data_record.createdanddata_record.updatedfor a Form whosewebhook_events_enabledopt-in is on (off by default), and not yet for records written by a connector sync. If you need record events for a connector-fed Form today, keep that part on v1; see below. - You need the payload to carry field values, not just identifiers. v2 delivers ids and you re-read over the API.
- You need the event to fire only for one specific Form, Workflow or Journey, and doing that filter on your side is not acceptable.
The two differences that actually bite
There is no per-object filtering in v2
A v1 rule can be bound to one object: this Form, this Workflow, this Journey. Only that object's events fire it.
v2 has no equivalent. An endpoint subscribed to an event type receives every
occurrence of that type, from every workspace it covers, and you drop the ones
you do not want in your handler. The identifiers you need to make that decision
are in the payload — workspace_uuid, subject, and the ref fields inside
detail.
This is a real trade, and mostly a good one: instead of N rules to keep in step
across M workspaces, you have one endpoint and one if in code you already
control. The cost is that events you do not care about cross the wire.
Server-side per-endpoint filtering (on Form id, say) is a reasonable thing to add later. If your integration needs it, say so — it is a feature request, not a limitation of the design.
Data record events
v1's data_record:created, data_record:updated and data_record:changed are
the usual foundation of a "sync this Form's submissions to our system"
integration.
v2 emits data_record.created and data_record.updated for a Form that has
opted in, carrying the data_type reference and the slugs of the fields that
changed — never their values. Three things to know before you move onto them:
- Emission is opt-in, per Form, and off by default. Turn on
webhook_events_enabledon the Form; until you do, an endpoint subscribing to these receives nothing from it. Connector-synced records do not emit yet (the eCW/Elation bridge and Monday record-target inbound), so if a connector writes your Form's rows, keep that part of the integration on v1 for now. Records written through the REST API, the AI tools, CSV/import, signup and the enrollment/submitfunnel do emit. - It replaces
:createdand:updated, not:changed. v1's:changedis a catch-all; subscribing to both v2 types is the closest equivalent. - There is no per-Form targeting — as with every v2 event, you filter on
dataTypeon your side. See There is no per-object filtering in v2.
member.attributes_changed remains the type to use when what you care about is
the Member's own record rather than a standalone Form submission: it carries the
data_type reference and changed field slugs for the member attributes.
Mapping the vocabulary
Only some v1 triggers have a v2 counterpart, and the counterpart is often coarser, because v2 records occurrences rather than rule firings.
| v1 trigger | v2 event type | Notes |
|---|---|---|
data_record:created | data_record.created | Emitted for a Form whose per-DataType opt-in is on; not for connector-synced records |
data_record:updated | data_record.updated | Emitted for a Form whose per-DataType opt-in is on; not for connector-synced records |
data_record:changed | data_record.created + data_record.updated | v1's catch-all has no single counterpart |
member:updated | member.attributes_changed | Field slugs only, no values |
workspace:member_joined | member.created | v1 trigger is not implemented; v2's is |
workspace:member_left | member.removed | v1 trigger is not implemented; v2's is. Carries a removal reason |
chat:created | chat.started | Manager and agent-job chats never delivered |
chat:summary | chat.summary | Signal only: names the Chat and channel, never the summary text |
workflow:execute | assignment.started | v2 names the Member's run, not the step |
task:* | — | v2 has no per-Task events |
journey:enrolled | journey.enrolled | Subject is the member, not the journey |
journey:completed | journey.completed | Subject is the member |
journey_step:execute | journey.step_transitioned | Carries from/to status |
call:started | call.started | |
call:completed | call.completed | Carries a disposition |
call:summary | call.summary | Phone channels only; signal only, never the summary text |
calendar_event:* | — | No v2 calendar events |
routine:triggered | routine.started | v2 also has routine.completed / routine.failed |
voicemail:received | voicemail.received | No transcript in the payload |
fax:received | — | No v2 fax events |
connector:event | — |
The v2 event catalog is the authoritative list, generated from the vocabulary itself.
Worked example: many per-Form rules for one data type
The shape this comes up in: an integration partner needed one workspace's
Intake Form kept in sync with their system, so they created a Send Webhook
Action per event, each bound to that Form — data_record:created,
data_record:updated, plus a few member events — and then repeated the set for
every workspace that came online.
What to do with that today:
-
Inventory what you actually have. In each workspace, go to Actions and filter to Send Webhook actions. Note, for each: the trigger, the object it is bound to, and the destination URL. The Event Rules API will list them if there are too many to read by hand.
-
Split the list in two. Move the
data_record:*rules to v2 if the Form is opted in (webhook_events_enabled) and its rows are written through the REST API, AI tools, CSV/import, signup or the enrollment/submitfunnel. Leave them on v1 for now if a connector sync writes the Form's rows — connector-synced records do not emit yet (details). Everything else (member, chat, call, journey, routine, voicemail events) can move. -
Create one v2 endpoint at Organization → Webhooks. Leave the workspace list empty so it covers every workspace including future ones — that is the part that removes the per-workspace work. Subscribe it to the v2 types from step 2.
-
Add the filter you used to get from the binding. Where a v1 rule was bound to the
IntakeForm, your handler now checks the payload instead. Formember.attributes_changedthe Form isdetail.data_type.uuid; for other types it is whichever ref names the object. Ignore what does not match. -
Test before you cut over. Press Send test event on the endpoint, pick a type, and edit the sample payload so the ids are ones your handler will resolve. Nothing is queued and nothing is retried, so you can do this as many times as you need. Check your handler ran and that the
X-Webhook-Signatureverified. -
Run both for a while. Keep the v1 rules enabled until you have seen the v2 endpoint deliver real traffic in the delivery log. There is no double-processing hazard you cannot handle: dedupe your handler on v2's
dedupe_keyand on v1'seventId. -
Delete the v1 rules you replaced — but only those. Leave any
data_record:*rules whose Form you did not opt in / whose rows a connector writes in place.
Net result for that partner: any data_record:* rules for connector-fed Forms
remain, one per workspace, and everything else collapses into a single
endpoint that covers new workspaces automatically.
Things to change in your handler
- Verify against the raw body. Same as v1: keys arrive alphabetically sorted and the signature covers the exact bytes.
- Dedupe on
dedupe_key. v2 is at-least-once; the same value arrives on every retry and replay. v1's equivalent iseventId. - Return
2xxfast. v2 retries4xxas well as5xx, so "reject it and it goes away" is not true here. Accept, then process asynchronously. - Ignore unknown enum values. Every vocabulary in a v2 payload is closed but
growing. An unrecognised
event_type,subject.kindordetailenum member means "a new member exists", not "something is wrong". - Re-read for detail. v2 delivers identifiers. Where a v1 payload handed you
member.emailordataRecord.fields, v2 hands you a uuid and you fetch. - Handle
"test": true. A test send sets it at the top level of the body. Discard or route those however you like — it is inside the signed body, so you can trust it.
Related
- Webhooks v1 — the workspace system
- Webhooks v2 — the organization system and its event catalog
- Webhook payloads — the exact v1 body for every event