Human-in-the-Loop Workflows
Martha workflows can pause execution and wait for an external event before continuing. This enables approval gates, payment confirmations, document reviews, and any pattern where a workflow needs input from a person or external system.
This guide covers two audiences:
- Workflow designers — how to add wait nodes to workflow definitions
- Integrators — how to send events to waiting workflows via webhooks
For Workflow Designers
When to Use a Wait Node
Use a wait node (type: WAIT) when a workflow step needs to block until something happens outside the system:
| Pattern | Example |
|---|---|
| Approval gate | Manager must approve an expense before processing |
| Payment confirmation | Wait for a payment provider callback |
| Document review | Hold until a reviewer marks a document as accepted |
| External processing | Wait for a third-party system to finish a job |
If you only need a fixed delay (e.g., "wait 30 seconds before retrying"), a wait node without event_type acts as a simple timer — no webhook needed.
Adding a Wait Node
A wait node is a step in your workflow definition with type: WAIT and handler: temporal:wait_for_event:
{
"id": "wait_for_approval",
"type": "WAIT",
"handler": "temporal:wait_for_event",
"event_type": "manager_approval",
"timeout_seconds": 3600,
"output_key": "approval_result"
}Fields
| Field | Required | Default | Description |
|---|---|---|---|
id | Yes | — | Unique step identifier within the workflow |
type | Yes | — | Must be WAIT |
handler | Yes | — | Must be temporal:wait_for_event |
event_type | No | — | The event type to wait for. Omit for a pure timer. |
event_filter | No | {} | Match conditions on the incoming event data (AND logic) |
timeout_seconds | No | 60 | Maximum time to wait before timing out |
output_key | No | — | Workflow context key where the event result is stored |
Event Filtering
Use event_filter to only accept events whose event_data matches specific field values. All conditions use AND logic — every field must match.
{
"id": "wait_for_approval",
"type": "WAIT",
"handler": "temporal:wait_for_event",
"event_type": "manager_approval",
"event_filter": {
"approved": true
},
"timeout_seconds": 3600,
"output_key": "approval_result"
}With this filter, only events where event_data.approved is true will resolve the wait. An event with {"approved": false} is ignored and the node keeps waiting.
An empty filter ({}) or no filter at all matches any event with the correct event_type.
Accessing Event Data Downstream
When a wait node resolves, the result is stored under the output_key in the workflow context. Subsequent steps can reference it.
On signal (event received):
{
"output": {
"approved": true,
"approver": "jane@example.com"
},
"event_type": "manager_approval",
"source": "signal",
"sender": "webhook",
"received_at": "1707820800"
}On timeout (no matching event within deadline):
{
"output": null,
"event_type": "manager_approval",
"source": "timeout",
"timed_out": true
}Check the source field to branch on whether the event arrived or the node timed out.
Common Patterns
Approval Gate with Timeout Fallback
[
{
"id": "request_approval",
"type": "ACTION",
"handler": "send_approval_request",
"output_key": "approval_request"
},
{
"id": "wait_for_approval",
"type": "WAIT",
"handler": "temporal:wait_for_event",
"event_type": "expense_approval",
"event_filter": { "approved": true },
"timeout_seconds": 86400,
"output_key": "approval_result"
},
{
"id": "check_approval",
"type": "CONDITION",
"condition": {
"field": "approval_result.source",
"operator": "equals",
"value": "signal"
},
"then_step": "process_expense",
"else_step": "notify_timeout"
}
]Payment Confirmation
{
"id": "wait_for_payment",
"type": "WAIT",
"handler": "temporal:wait_for_event",
"event_type": "payment_confirmed",
"timeout_seconds": 1800,
"output_key": "payment_result"
}The payment provider sends a webhook when the transaction completes. The workflow continues with the payment details in payment_result.output.
For Integrators
To resume a waiting workflow, send an HTTP request to Martha's webhook endpoint. The request must be signed with HMAC-SHA256 for security.
Endpoint
POST /api/webhooks/{event_type}Path parameter:
event_type— The event type that the wait node is listening for (e.g.,manager_approval,payment_confirmed)
Required Headers
| Header | Format | Description |
|---|---|---|
X-Webhook-Signature | sha256=<hex_digest> | HMAC-SHA256 signature of the raw request body |
X-Webhook-Timestamp | Unix timestamp (seconds) | Current time as integer. Must be within 5 minutes of server time. |
X-Webhook-Id | Unique string | Idempotency key. Duplicates within 10 minutes are rejected. |
Content-Type | application/json | Required |
Request Body
{
"tenant_id": "tenant_123",
"workflow_id": "wf-abc-123",
"event_data": {
"approved": true,
"approver": "jane@example.com",
"comment": "Looks good"
}
}| Field | Required | Description |
|---|---|---|
tenant_id | Yes | Tenant that owns the workflow |
workflow_id | Yes | Temporal workflow execution ID to signal |
event_data | No | Custom payload passed to the wait node (defaults to {}) |
Response
202 Accepted — Signal delivered to the workflow:
{
"status": "delivered",
"workflow_id": "wf-abc-123"
}!!! note The 202 response means the signal was sent to the workflow, not that the wait node matched. If the event doesn't match the node's event_filter, the signal is buffered but the node keeps waiting.
Computing the HMAC Signature
Sign the raw request body bytes with your tenant's webhook secret using HMAC-SHA256.
=== "Python"
```python
import hashlib
import hmac
import json
import time
import requests
secret = "your_webhook_secret"
body = json.dumps({
"tenant_id": "tenant_123",
"workflow_id": "wf-abc-123",
"event_data": {"approved": True, "approver": "jane@example.com"}
})
timestamp = str(int(time.time()))
signature = hmac.new(
secret.encode(), body.encode(), hashlib.sha256
).hexdigest()
response = requests.post(
"https://martha.example.com/api/webhooks/manager_approval",
headers={
"Content-Type": "application/json",
"X-Webhook-Signature": f"sha256={signature}",
"X-Webhook-Timestamp": timestamp,
"X-Webhook-Id": "evt-unique-id-001",
},
data=body,
)
print(response.status_code, response.json())
```
=== "curl"
```bash
SECRET="your_webhook_secret"
BODY='{"tenant_id":"tenant_123","workflow_id":"wf-abc-123","event_data":{"approved":true}}'
TIMESTAMP=$(date +%s)
SIGNATURE=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
curl -X POST "https://martha.example.com/api/webhooks/manager_approval" \
-H "Content-Type: application/json" \
-H "X-Webhook-Signature: sha256=$SIGNATURE" \
-H "X-Webhook-Timestamp: $TIMESTAMP" \
-H "X-Webhook-Id: evt-$(uuidgen)" \
-d "$BODY"
```
Querying Pending Events
To check what events a workflow is currently waiting for:
GET /api/admin/executions/{workflow_id}/pending-eventsResponse (200):
{
"workflow_id": "wf-abc-123",
"pending_events": [
"manager_approval",
"document_review"
]
}An empty pending_events array means the workflow is not currently waiting for any external events.
Error Codes
| Status | Cause | Detail |
|---|---|---|
| 400 | Missing X-Webhook-Id header | Missing webhook id |
| 401 | Invalid or missing HMAC signature | Invalid signature |
| 401 | Timestamp missing, invalid, or outside 5-minute window | Timestamp too old / Timestamp is in the future |
| 404 | Workflow ID not found or not running | Workflow not found: {id} |
| 409 | Duplicate X-Webhook-Id (within 10-minute window) | Duplicate webhook |
| 413 | Request body exceeds 1 MB | Payload too large (max 1MB) |
| 422 | Missing required fields in request body | Invalid request body |
| 429 | More than 60 requests per minute for this tenant | Rate limit exceeded |
Security Summary
Webhook requests pass through these checks in order:
- Payload validation — Valid JSON with required fields
- Size check — Body must be under 1 MB
- HMAC verification — Signature must match using the tenant's webhook secret
- Timestamp freshness — Must be within 5 minutes (rejects both old and future timestamps)
- Idempotency —
X-Webhook-Idmust not have been seen in the last 10 minutes - Rate limiting — Max 60 requests per minute per tenant