Human-in-the-loop workflows
Workflows can pause and wait for an external event before continuing. Approval gates, payment confirmations, document reviews — anything that takes input from a person or another system. State persists across the pause; nothing's polling.
The flow:
- Add a wait node to your workflow.
- Start the workflow; capture the execution ID.
- When the external event happens, signal Martha via a signed webhook with that execution ID.
- Workflow resumes from where it paused.
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": "[email protected]"
},
"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.
Resuming a paused workflow
To resume a waiting workflow, send a signed HTTP request to Martha's webhook endpoint.
Where do I get the workflow_id?
The workflow execution ID is returned the moment you start the workflow. Three ways to capture it:
# CLI — execute and capture
EXEC_ID="$(martha workflows execute approve-purchase \
--inputs '{"item":"server","cost":1000}' \
--json | jq -r .execution_id)"
echo "$EXEC_ID" # → wf-abc-123# REST API — capture from the start response
curl -X POST "$MARTHA_API_URL/api/admin/workflows/approve-purchase/execute" \
-H "Authorization: Bearer $TOKEN" \
-d '{"inputs":{"item":"server","cost":1000}}'
# → { "execution_id": "wf-abc-123", ... }# Trigger-started workflow — listen for the execution-started event
# the trigger emits, or query /api/admin/executions filtered by trigger_idPass that ID as workflow_id in the resume body.
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": "[email protected]",
"comment": "Looks good"
}
}| Field | Required | Description |
|---|---|---|
tenant_id | Yes | Tenant that owns the workflow |
workflow_id | Yes | Workflow execution ID returned when you started the workflow |
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": "[email protected]"}
})
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