The big rule
Mutations through the Customer API do NOT fire webhook events. The same is true of writes made through the MCP server.
If you call:
POST /api/v1/organizations/{org}/forms/{form}/records
…and your organization has an active record.created webhook
subscription, that subscription will not receive a delivery for
this submission.
The same goes for every other API-originated mutation:
| Event | Fires for dashboard / mobile? | Fires for API or MCP? |
|---|---|---|
record.created / .updated / .deleted | Yes | No |
form.published / .deleted | Yes | No |
form.assigned / .unassigned | Yes | No |
organization.created / .updated / .deactivated | Yes | No |
user.invited / .removed / .roles_updated | Yes | No |
Why
Webhooks exist to notify external systems of state changes the external system didn't already know about. If you're the integrator submitting a record through your own API call, you already know it happened — the response body confirms it. Firing a webhook in that case creates two real problems:
- Loop risk. If your webhook handler ever re-calls the API (e.g. an enrichment pipeline that adds a field after submission), the API call's resulting webhook would re-trigger the handler. We chose to break this category of loop at the source rather than trust every integrator to guard against it.
- Duplicate effort. Most integrators end up writing webhook-handler code that dedupes against their own submissions anyway. Skipping the fire on API origin removes that work.
How it's enforced
Every API mutation that can produce one of the events above runs
through a database function that sets a transaction-local Postgres
setting (app.api_origin = 'true') as its first statement. The
webhook queue insert function (public.queue_webhook_event) checks
that setting at the top and returns early when it's set. The check is
in the single chokepoint every webhook insert flows through — there's
no path to bypass it.
This is why the MCP server behaves the same way. Its write tools call the same database functions the REST routes call, so they inherit the suppression rather than reimplementing it. Anything that reaches those functions is treated as integration-origin, whichever door it came through.
Webhooks for dashboard + mobile + native triggers still work
Internal mutations (a dashboard user creating a record, the mobile app
submitting offline-collected records, an admin inviting a user from
the dashboard) all continue to fire webhooks normally. Only
integration-origin writes are excluded — the /api/v1/* HTTP path and
the MCP server's write tools.
One consequence catches people out: you cannot test a new subscription by writing through the API or an AI tool, because that write is exactly the kind that stays silent. Submit from the mobile app or the dashboard instead, or use the Send test event button on the subscription.
Future: opt-in webhook firing
Some integrators (CRM sync flows, audit pipelines) do want the
webhook even on API mutations. A future ?fire_webhooks=true query
param will let you opt-in per-request without changing the default.
Not in v1.