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
bash
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" }
  }'
bash
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:

json
{
  "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 2xx within 10 seconds. Anything else is a failure.
  • A failure with 429, 408, or a 5xx is retried up to 5 times with exponential backoff starting at 1 second; a Retry-After header is honored. A 4xx other 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_id and 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.

json
{
  "run_id": "run-…",
  "workflow_id": "wfl-…",
  "input_payload": { "invoices": [] }
}

workflow_run_completed

Registrable. Alias workflow.completed.

json
{
  "run_id": "run-…",
  "workflow_id": "wfl-…",
  "result": {}
}

workflow_run_failed

Registrable. Alias workflow.failed.

json
{
  "run_id": "run-…",
  "workflow_id": "wfl-…",
  "reason": "Step 3 failed: …",
  "context": {}
}

workflow_run_cancelled

Registrable. Alias workflow.cancelled.

json
{
  "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.

json
{ "run_id": "run-…", "step_index": 3, "step_type": "nway_match" }

step.completed

Catch-all only.

json
{ "run_id": "run-…", "step_index": 3 }

step.failed

Catch-all only.

json
{ "run_id": "run-…", "step_index": 3, "error": { "message": "…" } }

loop.started

Catch-all only.

json
{ "run_id": "run-…", "step_id": "4", "step_index": 4, "mode": "foreach", "total_items": 120 }

loop.failed

Catch-all only.

json
{ "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:

json
{
  "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:

json
{
  "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.

json
{
  "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.

json
{
  "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.

json
{
  "form_pause_id": "fp_…",
  "submission_id": "sub_…",
  "run_id": "run-…",
  "step_id": "2",
  "claimed_by?": "usr_…"
}

form_expired

Catch-all only.

json
{ "run_id": "run-…", "step_id": "2" }

external_form_created

Registrable.

json
{ "form_id": "frm-…", "form_name": "Vendor exception intake", "is_public": true, "created_at": "2026-09-06T10:30:00.000Z" }

external_form_updated

Registrable.

json
{ "form_id": "frm-…", "form_name": "Vendor exception intake", "changes": ["schema", "settings"] }

external_form_deleted

Registrable.

json
{ "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.

json
{
  "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.

json
{ "run_id": "run-…", "step_index": 1, "left_size": 4200, "right_size": 4180 }

matcher.completed

Catch-all only.

json
{
  "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.

json
{ "run_id": "run-…", "step_index": 1, "error": "…" }

nway_matcher.started

Catch-all only.

json
{
  "run_id": "run-…",
  "step_index": 1,
  "leg_counts": { "purchase_order": 5000, "receipt": 4900, "invoice": 4920 }
}

nway_matcher.completed

Catch-all only.

json
{
  "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.

json
{ "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.

json
{
  "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.

json
{ "document_id": "doc_…", "name": "invoices.csv", "content_type": "text/csv", "size_bytes": 48211 }

document.version_created

Registrable.

json
{ "document_id": "doc_…", "version": 2, "content_type": "text/csv", "size_bytes": 48990 }

document.deleted

Registrable.

json
{ "document_id": "doc_…" }

document.expired

Registrable.

json
{ "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.

json
{
  "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.

json
{
  "request_id": "req-…",
  "package_id": "pkg-…",
  "title": "Denied claims appeal",
  "code": "YRS-09",
  "deadline_hours": 72
}

playbook_request.returned

Catch-all only.

json
{
  "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.

json
{ "process_id": "proc-…", "status": "completed", "workflow_id?": "wfl-…", "workflow_name?": "…", "error?": "…" }

llm_generation_status

Catch-all only.

json
{ "generation_id": "gen-…", "status": "completed", "result?": {} }

llm_key_missing

Catch-all only.

json
{ "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.

json
{ "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:

  1. Register a target for pbot_approval_requested.
  2. On receipt, read run_id, step_id, comment, and request_payload. Look up the step's role if you need it: GET /approvals/:runId returns the pending approval with its approver_role and expires_at.
  3. 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.
  4. That person signs in with a one-time code as an SDK user whose permissions.approver_roles includes the role, and decides in the task sidebar, in Process Studio, or through POST /sdk/tasks/:taskId/decide. Your backend can also decide through POST /approvals/:runId/:stepId with the organization header.
  5. Receive pbot_approval_decided and 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 the workflow.* 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.