Webhooks
Hyphen sends a webhook when something happens to a run, an approval, a form, a match, a document, an agent, or a described case. This page lists every event with its payload, and how delivery works.
Base URL used below: https://your-hyphen.example.com with X-Org-Id.
Two ways to receive events
| Target | How to set it | What it receives |
|---|---|---|
| A registered target | POST /webhooks with an event, a url, and optionally a secret, a filter, and an auto_payload |
Only that event. Registration accepts the events marked registrable below |
| The organization's catch-all | Org config key webhook_url |
Every event the runtime emits, unsigned. Use it for the events that cannot be registered yet |
curl -X POST https://your-hyphen.example.com/webhooks \
-H "X-Org-Id: acme-corp" \
-H "Content-Type: application/json" \
-d '{
"event": "pbot_approval_requested",
"url": "https://example.com/hyphen/approvals",
"secret": "whsec_…",
"auto_payload": { "source": "hyphen" }
}'
curl -X POST https://your-hyphen.example.com/org-config \
-H "X-Org-Id: acme-corp" \
-H "Content-Type: application/json" \
-d '{ "key": "webhook_url", "value": "https://example.com/hyphen/all-events" }'
GET, PATCH, and DELETE /webhooks/:id manage a registration. A filter of { "tags": [...], "content_type": "..." } applies to document events. auto_payload is merged into every payload sent to that target.
The envelope
Every delivery is a POST with this body:
{
"delivery_id": "8f1c…",
"event": "pbot_approval_requested",
"timestamp": "2026-09-06T10:30:00.000Z",
"payload": { … }
}
Headers: Content-Type: application/json, X-Hyphen-Delivery-Id, and, when the target has a secret, X-Hyphen-Signature: sha256=<hex>, an HMAC-SHA256 over the raw body. Verify the signature against the raw bytes, not a re-serialized object. Use delivery_id to de-duplicate: a retry carries the same id.
Delivery, retries, and the circuit
- Answer
2xxwithin 10 seconds. Anything else is a failure. - A failure with
429,408, or a5xxis retried up to 5 times with exponential backoff starting at 1 second; aRetry-Afterheader is honored. A4xxother than those is not retried. - After 3 consecutive failures to one URL the circuit opens for 5 minutes and deliveries to it are skipped, then it is tried again. A registered target that keeps failing is temporarily disabled and re-enabled on the next success.
- Deliveries are not ordered across events and not guaranteed exactly once. Design the receiver to be idempotent on
delivery_idand on the ids in the payload.
Event reference
Each event below shows the payload object. A field ending in ? is absent or null when it does not apply. Registrable means POST /webhooks accepts the event; catch-all only means it reaches only the organization's webhook_url.
Runs
workflow_run_started
Registrable. Alias workflow.started.
{
"run_id": "run-…",
"workflow_id": "wfl-…",
"input_payload": { "invoices": [ … ] }
}
workflow_run_completed
Registrable. Alias workflow.completed.
{
"run_id": "run-…",
"workflow_id": "wfl-…",
"result": { … }
}
workflow_run_failed
Registrable. Alias workflow.failed.
{
"run_id": "run-…",
"workflow_id": "wfl-…",
"reason": "Step 3 failed: …",
"context": { … }
}
workflow_run_cancelled
Registrable. Alias workflow.cancelled.
{
"run_id": "run-…",
"workflow_id": "wfl-…",
"reason": "operator request",
"actor": { "type": "authenticated_actor", "id": "usr_…" }
}
Steps and loops
Catch-all only. Fired for every step of every run, so expect volume.
step.started
Catch-all only.
{ "run_id": "run-…", "step_index": 3, "step_type": "nway_match" }
step.completed
Catch-all only.
{ "run_id": "run-…", "step_index": 3 }
step.failed
Catch-all only.
{ "run_id": "run-…", "step_index": 3, "error": { "message": "…" } }
loop.started
Catch-all only.
{ "run_id": "run-…", "step_id": "4", "step_index": 4, "mode": "foreach", "total_items": 120 }
loop.failed
Catch-all only.
{ "run_id": "run-…", "step_id": "4", "step_index": 4, "mode": "foreach", "error": "…" }
Approvals
pbot_approval_requested
Registrable. A run has paused for a person. From a workflow step:
{
"run_id": "run-…",
"step_id": "4",
"workflow_id": "wfl-…",
"comment": "Release payment for the full groups and decide the exceptions",
"request_payload": { "counts": { … }, "exceptions": [ … ] },
"status": "pending"
}
From a standalone agent that asked for a person, the same event carries:
{
"source": "standalone_agent",
"agent_run_id": "agent-…",
"run_id": "agent-…",
"step_id": "…",
"question": "Should I proceed with the write-off?",
"options": ["Yes", "No"],
"context_summary": "…",
"urgency": "high",
"status": "pending"
}
pbot_approval_decided
Registrable.
{
"run_id": "run-…",
"step_id": "4",
"approval_id": "apr_…",
"approved": true,
"comment": "Looks right",
"decided_at": "2026-09-06T10:41:12.000Z"
}
Forms
form_pause_created
Catch-all only. A run has paused on a PbotForm step.
{
"form_pause_id": "fp_…",
"run_id": "run-…",
"step_id": "2",
"expected_keys": ["po_number", "corrected_amount"],
"expires_at": "2026-09-08T10:30:00.000Z"
}
form_submitted
Catch-all only.
{
"form_pause_id": "fp_…",
"submission_id": "sub_…",
"run_id": "run-…",
"step_id": "2",
"claimed_by?": "usr_…"
}
form_expired
Catch-all only.
{ "run_id": "run-…", "step_id": "2" }
external_form_created
Registrable.
{ "form_id": "frm-…", "form_name": "Vendor exception intake", "is_public": true, "created_at": "2026-09-06T10:30:00.000Z" }
external_form_updated
Registrable.
{ "form_id": "frm-…", "form_name": "Vendor exception intake", "changes": ["schema", "settings"] }
external_form_deleted
Registrable.
{ "form_id": "frm-…", "form_name": "Vendor exception intake", "deleted_at": "2026-09-06T10:30:00.000Z" }
external_form_submitted
Registrable. Someone submitted an intake form. triggered_execution_type is workflow_run or agent_run; submitted_by is present only when the form required sign-in.
{
"form_id": "frm-…",
"form_name": "Vendor exception intake",
"submission_id": "sub_…",
"submitted_at": "2026-09-06T10:30:00.000Z",
"submitted_by?": "[email protected]",
"triggered_run_id": "run-…",
"triggered_execution_type": "workflow_run",
"has_triggered_workflow": true
}
Matching
Catch-all only. Fired when a match runs on the worker, which happens for large inputs. Small inline matches emit nothing.
matcher.started
Catch-all only.
{ "run_id": "run-…", "step_index": 1, "left_size": 4200, "right_size": 4180 }
matcher.completed
Catch-all only.
{
"run_id": "run-…",
"step_index": 1,
"schema_version": "…",
"matched_count": 4100,
"unmatched_left_count": 100,
"unmatched_right_count": 80,
"policy": { … }
}
matcher.failed
Catch-all only.
{ "run_id": "run-…", "step_index": 1, "error": "…" }
nway_matcher.started
Catch-all only.
{
"run_id": "run-…",
"step_index": 1,
"leg_counts": { "purchase_order": 5000, "receipt": 4900, "invoice": 4920 }
}
nway_matcher.completed
Catch-all only.
{
"run_id": "run-…",
"step_index": 1,
"schema_version": "hyphen.match-group.v1",
"counts": { "groups": 5000, "full": 4700, "missing_leg": 200, "variance": 100, "orphan": 20 },
"result_reference": "…"
}
nway_matcher.failed
Catch-all only.
{ "run_id": "run-…", "step_index": 1, "error": "…" }
Agents
Catch-all only. Fired when a standalone agent run reaches a terminal state or pauses. The four events share one payload.
agent.completed, agent.paused, agent.cancelled, agent.failed
Catch-all only.
{
"agent_run_id": "agent-…",
"status": "paused",
"iterations": 4,
"final_answer?": "…",
"confidence?": 0.9,
"paused_reason?": "human_input_requested"
}
Documents
Registrable, and the only events that honor a registration filter.
document.uploaded
Registrable.
{ "document_id": "doc_…", "name": "invoices.csv", "content_type": "text/csv", "size_bytes": 48211 }
document.version_created
Registrable.
{ "document_id": "doc_…", "version": 2, "content_type": "text/csv", "size_bytes": 48990 }
document.deleted
Registrable.
{ "document_id": "doc_…" }
document.expired
Registrable.
{ "document_id": "doc_…", "name": "invoices.csv", "expires_at": "2026-10-06T00:00:00.000Z" }
Described cases
Catch-all only. The Describe Your Own path reports where a request landed.
playbook_request.listed
Catch-all only. listed_by is auto or the admin who listed it.
{
"request_id": "req-…",
"package_id": "pkg-…",
"key": "yrs_09_denied_claims_appeal",
"code": "YRS-09",
"title": "Denied claims appeal",
"listed_by": "auto"
}
playbook_request.awaiting_admin
Catch-all only.
{
"request_id": "req-…",
"package_id": "pkg-…",
"title": "Denied claims appeal",
"code": "YRS-09",
"deadline_hours": 72
}
playbook_request.returned
Catch-all only.
{
"request_id": "req-…",
"package_id?": "pkg-…",
"title": "Denied claims appeal",
"notes": [ { "key": "draft", "kind": "needs_change", "text": "…", "detail?": [ "…" ] } ]
}
Authoring and housekeeping
Catch-all only.
workflow_creation_status
Catch-all only.
{ "process_id": "proc-…", "status": "completed", "workflow_id?": "wfl-…", "workflow_name?": "…", "error?": "…" }
llm_generation_status
Catch-all only.
{ "generation_id": "gen-…", "status": "completed", "result?": { … } }
llm_key_missing
Catch-all only.
{ "run_id": "run-…", "event": "llm_key_missing_using_fallback", "message": "…" }
custom_table_dropped_fields
Catch-all only. Sent when a write carried fields the table does not declare.
{ "run_id": "run-…", "step_index": 6, "table": "review_packets", "dropped": ["field_a"] }
Routing an approval to the right person
Hyphen names the role that may decide, never the person. That is deliberate: the integrator knows who holds which role today, and Hyphen does not want to be a directory. The intended pattern:
- Register a target for
pbot_approval_requested. - On receipt, read
run_id,step_id,comment, andrequest_payload. Look up the step's role if you need it:GET /approvals/:runIdreturns the pending approval with itsapprover_roleandexpires_at. - Decide in your system who should act: on-call, the owner of the account in the payload, a rota. Notify them your way, with a link into your product or the hosted task list.
- That person signs in with a one-time code as an SDK user whose
permissions.approver_rolesincludes the role, and decides in the task sidebar, in Process Studio, or throughPOST /sdk/tasks/:taskId/decide. Your backend can also decide throughPOST /approvals/:runId/:stepIdwith the organization header. - Receive
pbot_approval_decidedand close the loop in your system.
Two or more people who must each sign are two approval steps with two roles, in sequence; the catalog's fraud cases do exactly that. Any signed-in holder of the role can pick up a task; there is no per-person assignment and no quorum count inside Hyphen.
Registrable events at a glance
- Documents:
document.uploaded,document.version_created,document.deleted,document.expired - Approvals:
pbot_approval_requested,pbot_approval_decided - External forms:
external_form_created,external_form_updated,external_form_deleted,external_form_submitted - Runs:
workflow_run_started,workflow_run_completed,workflow_run_failed,workflow_run_cancelled, and theworkflow.*aliases
Every other event above reaches only the organization's webhook_url catch-all. Widening registration to those events is a validation change on the server and is on the roadmap.