Two Systems, Same Events
dForge has two event-driven systems that watch the same record events but do different things when they fire:
| System | Config file | What happens | Use case |
|---|---|---|---|
| Triggers | logic/triggers.json | Runs a DSL action (sync or async) | Internal automation: notifications, cascades, workflows |
| Webhooks | logic/webhooks.json | Sends an HTTP POST to an external URL | External integration: Slack, Zapier, custom services |
Both fire on the same event types: insert, update, delete, status_change, and any.
Event Types
| Event | Fires when | Old values available |
|---|---|---|
insert | A new record is created | No |
update | Any field on an existing record changes | Yes |
delete | A record is deleted | Yes (the deleted row) |
status_change | A specific field’s value changes (compares old vs new) | Yes |
any | All of the above | Depends |
Triggers
triggers.json
{
"triggers": [
{
"code": "on_opportunity_won",
"description": "Notify owner when opportunity is closed as won",
"entity": "opportunity",
"event": "status_change",
"condition": "[stage] = 'Closed Won'",
"action": "on_won_notify",
"async": true
}
]
}
| Field | Required | Description |
|---|---|---|
code | Yes | Unique identifier within the module |
entity | Yes | Entity to watch (can reference other modules’ entities) |
event | Yes | One of insert, update, delete, status_change, any |
condition | No | Formula evaluated against record data — trigger only fires if true |
action | Yes | Action code to execute (must be defined in ui/actions.json) |
async | No | true (default) queues a background job; false runs in the same transaction |
params | No | Static parameters passed to the action |
enabled | No | Default true. Set false to disable without removing |
Conditions
Conditions use the same formula syntax as canExecute on actions:
[status] = 'Active'
[amount] > 1000
[stage] = 'Closed Won' AND [priority] = 'High'
The condition is parsed at module install time and evaluated against the record at event time. If it doesn’t match, the trigger doesn’t fire (fail-closed).
Sync vs Async
Async triggers ("async": true, the default):
- Queue a row in the
background_actiontable - A background worker picks them up within ~10 seconds
- Run in their own transaction
- No deadlocks, no cascades inside the originating transaction
- Use for notifications, emails, logs, non-critical side effects
Sync triggers ("async": false):
- Execute immediately, inside the originating transaction
- Atomic with the original mutation — if the trigger fails, the whole change rolls back
- Risk of infinite loops, so the platform enforces a max nesting depth of 5 and a per-record re-entrancy guard
- Use for cascading field updates, validation, derived state
Trigger-Only Actions
If an action should fire only from triggers (never from a UI button), set its canExecute to false. Background triggers bypass canExecute, so the action still runs from the trigger but the button is hidden in the UI:
canExecute:
false
execute:
notify([owner_id], 'Automated: ' + [title] + ' completed')
Webhooks
webhooks.json
{
"subscriptions": [
{
"code": "on_task_completed",
"entity": "task",
"event": "status_change",
"condition": "[status] = 'completed'",
"endpointUrl": "https://hooks.example.com/dforge",
"secretCd": "webhook_signing_key",
"payload": {
"include": ["task_id", "title", "status"],
"includeOld": true
}
}
]
}
| Field | Required | Description |
|---|---|---|
code | Yes | Unique identifier within the module |
entity | Yes | Entity to watch |
event | Yes | Event type |
condition | No | Formula — only fire if true |
endpointUrl | No | HTTP endpoint to POST to (can be set in the admin UI later) |
secretCd | No | Reference to a secret used for HMAC-SHA256 signing |
payload.include | No | Array of fields to include — omit for all |
payload.includeOld | No | true to include old values for update/delete |
enabled | No | Default true |
Payload
{
"deliveryId": "163053801702424576",
"event": "status_change",
"timestamp": "2026-03-26T22:34:25Z",
"subscription": { "code": "on_task_completed", "module": "tasks" },
"folder": { "folderId": 123 },
"entity": "task",
"recordId": { "task_id": 456 },
"data": { "task_id": 456, "title": "My Task", "status": "completed" },
"changes": { "status": { "old": "in_progress", "new": "completed" } }
}
Delivery and Retry
- Background service polls every 5 seconds
- Exponential backoff: immediate → 30s → 2m → 10m → 1h
- Max 5 attempts, then marked
failed - Headers include
X-DForge-Event,X-DForge-Entity,X-DForge-Subscription, andX-DForge-Signature - Delivery history is retained for 30 days
HMAC Signing
When secretCd is set, the payload is signed with HMAC-SHA256 and the signature is sent in X-DForge-Signature. Verify on the receiver:
HMAC-SHA256(secret, request_body) == X-DForge-Signature
Triggers vs Webhooks: Which to Use
| Scenario | Use |
|---|---|
| Notify a user when a record changes | Trigger → action with notify() |
| Send an email on status change | Trigger → action with sendEmail() |
| Update a related record | Trigger (sync) → action with field set |
| Post to Slack/Teams/Discord | Webhook |
| Sync data to an external CRM/ERP | Webhook |
| Run complex multi-step business logic | Trigger → action with full DSL |
Triggers stay inside dForge and have the entire DSL and database at their disposal. Webhooks are for crossing the boundary into other systems.