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
idYes—Unique step identifier within the workflow
typeYes—Must be WAIT
handlerYes—Must be temporal:wait_for_event
event_typeNo—The 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_keyNo—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.

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. Idempotency — X-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.