Skip to content

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:

  1. Add a wait node to your workflow.
  2. Start the workflow; capture the execution ID.
  3. When the external event happens, signal Martha via a signed webhook with that execution ID.
  4. 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:

PatternExample
Approval gateManager must approve an expense before processing
Payment confirmationWait for a payment provider callback
Document reviewHold until a reviewer marks a document as accepted
External processingWait 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:

json
{
  "id": "wait_for_approval",
  "type": "WAIT",
  "handler": "temporal:wait_for_event",
  "event_type": "manager_approval",
  "timeout_seconds": 3600,
  "output_key": "approval_result"
}

Fields

FieldRequiredDefaultDescription
idYesUnique step identifier within the workflow
typeYesMust be WAIT
handlerYesMust be temporal:wait_for_event
event_typeNoThe event type to wait for. Omit for a pure timer.
event_filterNo{}Match conditions on the incoming event data (AND logic)
timeout_secondsNo60Maximum time to wait before timing out
output_keyNoWorkflow 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.

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

json
{
  "output": {
    "approved": true,
    "approver": "[email protected]"
  },
  "event_type": "manager_approval",
  "source": "signal",
  "sender": "webhook",
  "received_at": "1707820800"
}

On timeout (no matching event within deadline):

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

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

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

bash
# 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
bash
# 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", ... }
bash
# Trigger-started workflow — listen for the execution-started event
# the trigger emits, or query /api/admin/executions filtered by trigger_id

Pass 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

HeaderFormatDescription
X-Webhook-Signaturesha256=<hex_digest>HMAC-SHA256 signature of the raw request body
X-Webhook-TimestampUnix timestamp (seconds)Current time as integer. Must be within 5 minutes of server time.
X-Webhook-IdUnique stringIdempotency key. Duplicates within 10 minutes are rejected.
Content-Typeapplication/jsonRequired

Request Body

json
{
  "tenant_id": "tenant_123",
  "workflow_id": "wf-abc-123",
  "event_data": {
    "approved": true,
    "approver": "[email protected]",
    "comment": "Looks good"
  }
}
FieldRequiredDescription
tenant_idYesTenant that owns the workflow
workflow_idYesWorkflow execution ID returned when you started the workflow
event_dataNoCustom payload passed to the wait node (defaults to {})

Response

202 Accepted — Signal delivered to the workflow:

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

Response (200):

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

StatusCauseDetail
400Missing X-Webhook-Id headerMissing webhook id
401Invalid or missing HMAC signatureInvalid signature
401Timestamp missing, invalid, or outside 5-minute windowTimestamp too old / Timestamp is in the future
404Workflow ID not found or not runningWorkflow not found: {id}
409Duplicate X-Webhook-Id (within 10-minute window)Duplicate webhook
413Request body exceeds 1 MBPayload too large (max 1MB)
422Missing required fields in request bodyInvalid request body
429More than 60 requests per minute for this tenantRate limit exceeded

Security Summary

Webhook requests pass through these checks in order:

  1. Payload validation — Valid JSON with required fields
  2. Size check — Body must be under 1 MB
  3. HMAC verification — Signature must match using the tenant's webhook secret
  4. Timestamp freshness — Must be within 5 minutes (rejects both old and future timestamps)
  5. IdempotencyX-Webhook-Id must not have been seen in the last 10 minutes
  6. Rate limiting — Max 60 requests per minute per tenant

Martha is built by aiaiai-pt.