Webhooks and automations
Anatomy of a rule
A rule is JSON: name, enabled, trigger_type, trigger_config and action_spec.steps. Step values can use templates such as {{trigger.row.id}}, {{trigger.body.field}} and {{steps.0.row.id}}; templates are path lookups only, and arithmetic goes in a compute step. Rules run on a durable engine with at-least-once execution, every change to a rule is versioned and restorable, and a new rule saved through MCP is always switched off until someone turns it on.
Create, test and run
| Task | MCP tool |
|---|---|
| Read the reference (triggers, steps, recipes) | get_automation_docs |
| Check a rule without saving | validate_automation |
| Create or replace a rule (saved off) | save_automation |
| Dry-run against a real record: nothing written or sent | simulate_automation |
| Turn on or off | set_automation_enabled |
| Run now, for real (after confirm) | run_automation |
| Runs, failures, retries, versions | list_automation_runs, list_failed_runs, retry_automation_run, restore_automation_version |
| Human approval steps | list_pending_approvals, decide_approval |
The same operations are available on the automation API to a signed-in administrator session, and in the web app.
Trigger types
| Trigger | Fires when |
|---|---|
entity_event | A row is created, updated or deleted, optionally filtered by a condition before the run starts. |
status_transition | A field changes from one value to another, once, with the old and new value. |
cron | On a 5-field cron schedule in a time zone. |
manual | On demand, including from an app button. |
webhook | An external system POSTs to the rule's URL. |
beacon | An email open-tracking pixel is loaded. |
field_threshold | A number crosses a threshold. |
time_in_state | A row has sat in one state too long (SLA checks). |
date_relative | A set time before or after a date column. |
recurring_from_date | Every N days, weeks or months after a date column. |
zone_membership | A record enters or leaves a service zone. |
record_proximity | A tracked person arrives at or leaves a record's location. |
device_silence | A tracked phone goes quiet. |
schedule_conflict | Bookings overlap or someone exceeds a daily-hours cap. |
aggregate_threshold | A roll-up value crosses a line. |
Platform events also arrive as entity_event triggers: inbound email and SMS received, voice calls and text conversations completed, proposals accepted, declined, viewed or expired, and public forms opened, submitted, signed or paid.
"trigger_type": "entity_event",
"trigger_config": {
"table": "jobs", "event": "updated",
"condition": [
{ "col": "priority", "op": "eq", "value": "urgent" },
{ "col": "status", "op": "in", "value": ["new", "assigned"] }
]
}Inbound webhooks
- URL:
POST https://<api-host>/automation/hooks/<workspace-id>/<rule-id>. No session sign-in. - If the rule has
trigger_config.secret, send it in thex-automation-secretheader; otherwise the call gets 401. - The rule must be switched on; otherwise the call gets 404.
- The response is
202 {"accepted": true}and the rule runs in the background. - Steps read the JSON body as
{{trigger.body.<field>}}.
{
"name": "Website request to service request",
"enabled": true,
"trigger_type": "webhook",
"trigger_config": { "secret": "<your-shared-secret>" },
"action_spec": {
"steps": [
{ "type": "create_record", "table": "service_requests",
"values": {
"contact_name": "{{trigger.body.name}}",
"phone": "{{trigger.body.phone}}",
"description": "{{trigger.body.issue}}",
"status": "new"
} }
]
}
}curl -X POST "https://<api-host>/automation/hooks/<workspace-id>/<rule-id>" \
-H "Content-Type: application/json" \
-H "x-automation-secret: <your-shared-secret>" \
-d '{"name": "Dana Lee", "phone": "+15550100", "issue": "No cooling, unit 4B"}'
# => 202 {"accepted": true} (the rule runs in the background)Outbound: HTTP requests and signed webhooks
http_request calls any URL with a method, headers and a JSON body (or a file's bytes with body_from), with a timeout and retries. It returns { ok, status, body } and does not throw on a non-2xx response, so gate later steps on {{steps.N.ok}}. Credentials come from the workspace's encrypted secret vault as {{secrets.NAME}}; an unknown name stops the step rather than sending a blank credential, and secret values never reach run history.
{ "type": "http_request",
"url": "https://api.example.com/v1/work-orders",
"method": "POST",
"headers": { "authorization": "Bearer {{secrets.PARTNER_API_KEY}}" },
"body": { "job_id": "{{trigger.row.id}}" },
"timeout": 15000, "retries": 2 }send_webhook also signs the body with HMAC-SHA256 and logs every attempt. It sends X-Fieldproxy-Signature: t=<unix>,v1=<hex> (the HMAC of `${t}.${body}`), X-Fieldproxy-Timestamp and a unique X-Fieldproxy-Delivery.
{
"name": "Tell our ERP when a job is completed",
"enabled": true,
"trigger_type": "status_transition",
"trigger_config": { "table": "jobs", "field": "status", "to": "completed" },
"action_spec": {
"steps": [
{ "type": "send_webhook",
"url": "https://erp.example.com/hooks/fieldproxy",
"secret": "{{secrets.ERP_WEBHOOK_KEY}}",
"body": { "event": "job.completed", "job_id": "{{trigger.row.id}}" } }
]
}
}// Node.js: verify a Fieldproxy send_webhook delivery
import crypto from "node:crypto"
function verify(rawBody, header, secret, toleranceSec = 300) {
// header: "t=<unix>,v1=<hex>"
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")))
const expected = crypto.createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`).digest("hex")
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < toleranceSec
return fresh && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))
}Step types
| Group | Steps |
|---|---|
| Write | create_record (insert or upsert), update_record (always needs a where), snapshot_record, end_tracking_session |
| Read and fan out | query, aggregate, assign_dispatch, geocode, find_available_slot, create_schedule_slot, geofence_check, optimize_route, for_each, branch |
| Control | condition, rate_limit, dedupe, delay, wait_until, approval_gate, wait_for_event |
| Compute | compute (math, dates and strings) |
| Messaging | send_email, send_sms, place_call, send_push, send_public_link, sign_request, collect_payment, post_to_chat, notify_chat, generate_pdf, generate_document |
| Outbound and files | http_request, send_webhook, fetch_file, upload_file, stamp_pdf, merge_pdf |
| AI | ai_extract, ai_generate, ai_classify, ai_summarise, translate |
| Integrations and sync | apply_mapping, call_integration, pull_integration, db_sync, sql |
Each step can set on_error to halt, continue, retry (up to 5 attempts) or catch.
{ "type": "send_sms", "to": "{{trigger.row.phone}}", "body": "Reminder: visit tomorrow 9am" }Engine limits
- At most 20 runs at once per workspace, with a 60-minute execution limit per run; a failed run is retried twice.
- Rule chains stop after 5 hops, and a rule that fails 5 times in a row is switched off automatically.
- A rule can nest steps up to 10 levels deep and 2,000 steps in all.
- Sends follow the workspace's send rules: opted-out numbers are skipped and messages outside allowed hours are deferred, not dropped.
Checked against the Fieldproxy platform reference on October 18, 2026.
Get your own custom field service management within a day. Tailored to your workflows.
We set up a workspace on your own data and connect your AI or your systems on the call.
Get Demo