Integrating Services
Read this if you’re writing the service on the other end of a workflow step. It covers the request headers we send you, the response headers you can send back, and how to make your service behave under retries.
Request headers from Dispatched
Every HTTP call from a step comes with these headers:
| Header | Example | Description |
|---|---|---|
Dispatched-Run |
run_WxEu4lDvF9 |
The run ID. Use it for logs and correlation. |
Dispatched-Step |
charge_payment |
The step ID within the workflow. |
Dispatched-Attempt |
1 |
Attempt number — starts at 1, bumps on retry. |
Idempotency-Key |
run_WxEu4l/charge_payment |
Unique key for this step execution. Use it to avoid double-processing. |
Content-Digest, Signature-Input, Signature |
(RFC 9421) | Request signature. Only present if the tenant has a signing secret configured — see Verifying request signatures. |
Using the idempotency key
The Idempotency-Key header keeps retries from doing work twice. Your service should:
- On the first request, do the work and cache the result keyed by the idempotency key.
- On any retry with the same key, return the cached result without redoing the work.
# Example: idempotent payment processing
@app.post("/charge")
def charge(request):
idem_key = request.headers.get("Idempotency-Key")
# Already seen this one?
existing = db.get(f"idem:{idem_key}")
if existing:
return existing # Return the cached result, don't charge again
# Otherwise, process the charge
result = stripe.charges.create(...)
# Cache for next time
db.set(f"idem:{idem_key}", result, ttl=86400)
return result
Using the attempt number
Dispatched-Attempt tells you how many times we’ve tried this step. Useful for:
- Logging with retry context: “Processing charge (attempt 3 of 3)”
- Skipping non-critical side effects on retries
- Tuning timeouts differently on later attempts
Verifying request signatures
If your tenant has a signing secret configured, every outbound step request carries an HMAC-SHA256 signature built per RFC 9421 (HTTP Message Signatures). Verifying it is the recommended way to confirm a request genuinely came from your Dispatched tenant.
Minting (or rotating) the secret
POST /api/tenant/signing-secret
Dispatched-Session: <your session token>
The response body contains the secret exactly once — store it in your service’s config and treat it like a password:
{
"signing_secret": "dsw_bX2r...",
"algorithm": "hmac-sha256",
"key_id": "ten_abc123",
"created_at": "2026-04-21T12:00:00Z"
}
Calling the endpoint again rotates the secret: the previous value immediately stops producing valid signatures.
What gets signed
Every signed request carries three headers:
Content-Digest: sha-256=:<base64 SHA-256 of the raw request body>:
Signature-Input: sig1=("@method" "@target-uri" "content-digest" "dispatched-run" "dispatched-step" "idempotency-key");created=1700000000;keyid="ten_abc123";alg="hmac-sha256";nonce="..."
Signature: sig1=:<base64 HMAC-SHA256 of the signature base>:
The signature base is built per RFC 9421 §2.3 — one line per covered component in the order listed above, followed by a final line for @signature-params:
"@method": POST
"@target-uri": https://api.example.com/charge
"content-digest": sha-256=:...:
"dispatched-run": run_WxEu4lDvF9
"dispatched-step": charge_payment
"idempotency-key": run_WxEu4lDvF9/charge_payment
"@signature-params": ("@method" "@target-uri" "content-digest" "dispatched-run" "dispatched-step" "idempotency-key");created=1700000000;keyid="ten_abc123";alg="hmac-sha256";nonce="..."
Verification: recompute that string, HMAC-SHA256 it with your signing secret, base64 the MAC, and compare it (constant-time) against the Signature header value.
Node verification example
import crypto from "node:crypto";
function verifyDispatchedSignature(req, secret) {
const method = req.method.toUpperCase();
const targetUri = `${req.protocol}://${req.get("host")}${req.originalUrl}`;
const body = req.rawBody ?? "";
const expectedDigest =
"sha-256=:" + crypto.createHash("sha256").update(body).digest("base64") + ":";
if (req.get("content-digest") !== expectedDigest) return false;
const sigInput = req.get("signature-input"); // e.g. sig1=("@method" ...);created=...
const sig = req.get("signature"); // e.g. sig1=:<b64>:
if (!sigInput || !sig) return false;
const paramsStart = sigInput.indexOf(")") + 1;
const params = sigInput.slice(paramsStart);
const created = Number(/;created=(\d+)/.exec(params)?.[1]);
if (Math.abs(Date.now() / 1000 - created) > 300) return false; // 5 min freshness
const componentList = [
"@method", "@target-uri", "content-digest",
"dispatched-run", "dispatched-step", "idempotency-key",
];
const lines = componentList.map((c) => {
const v = c === "@method" ? method
: c === "@target-uri" ? targetUri
: req.get(c);
return `"${c}": ${v}`;
});
const sigParamsValue =
"(" + componentList.map((c) => `"${c}"`).join(" ") + ")" + params;
lines.push(`"@signature-params": ${sigParamsValue}`);
const base = lines.join("\n");
const expectedMac = crypto.createHmac("sha256", secret).update(base).digest("base64");
const expectedSig = `sig1=:${expectedMac}:`;
// constant-time compare
return sig.length === expectedSig.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig));
}
Elixir verification example
def verify(conn, body, secret) do
[digest] = Plug.Conn.get_req_header(conn, "content-digest")
[input] = Plug.Conn.get_req_header(conn, "signature-input")
[signature] = Plug.Conn.get_req_header(conn, "signature")
expected_digest =
"sha-256=:" <> Base.encode64(:crypto.hash(:sha256, body)) <> ":"
true = Plug.Crypto.secure_compare(digest, expected_digest)
"sig1=" <> rest = input
[_components, params] = String.split(rest, ~r/(?<=\))/, parts: 2)
created = ~r/;created=(\d+)/ |> Regex.run(params) |> List.last() |> String.to_integer()
true = abs(System.system_time(:second) - created) <= 300
target_uri = "#{Atom.to_string(conn.scheme)}://#{conn.host}#{conn.request_path}"
headers = [
{"content-digest", digest},
{"dispatched-run", hd(Plug.Conn.get_req_header(conn, "dispatched-run"))},
{"dispatched-step", hd(Plug.Conn.get_req_header(conn, "dispatched-step"))},
{"idempotency-key", hd(Plug.Conn.get_req_header(conn, "idempotency-key"))}
]
base =
Dispatched.Step.RequestSigner.build_signature_base(
conn.method,
target_uri,
headers,
Dispatched.Step.RequestSigner.covered_components(),
params
)
mac = :crypto.mac(:hmac, :sha256, secret, base) |> Base.encode64()
Plug.Crypto.secure_compare(signature, "sig1=:#{mac}:")
end
Rotation
-
POST /api/tenant/signing-secret→ get a new secret. - Deploy it to your services (alongside the old one if you want zero-downtime — accept both for a grace period).
- Remove the old secret from your service config once you’re satisfied nothing is still using it.
Response status codes
Your status code tells the engine what to do next:
| Status | What we do |
|---|---|
200-299 |
Success. Step completes, response body lands in context for downstream steps. |
400, 401, 403, 404, 422 |
Permanent failure. No retry. Step fails immediately. |
408 |
Timeout. Retried if you’ve set up a retry policy. |
429 |
Rate limited. Retried with backoff. If you send Retry-After, we respect it. |
500, 502, 503, 504 |
Transient failure. Retried per the step’s retry policy. |
Pick the right status code
Don’t return 500 for validation errors — use 422. Otherwise we’ll retry something that was never going to work:
# Good: 422 for bad input (won't retry)
if not valid_input(data):
return {"error": "Invalid amount"}, 422
# Good: 503 for temporary unavailability (will retry)
if database_overloaded():
return {"error": "Service temporarily unavailable"}, 503
Response headers your service can send
Directives
Your service can push Dispatched-* response headers to steer the engine:
| Header | Example | Effect |
|---|---|---|
Dispatched-Spawn |
fulfill-order; input=eyJ...; mode=detach |
Kick off a child workflow. input is base64-encoded JSON. |
Dispatched-Signal |
run=run_abc; name=payment-ready; data=eyJ... |
Send a signal to a waiting step in another run. |
Dispatched-Cancel |
run_xyz |
Cancel another run. |
Dispatched-Delay |
30s |
Hold off before starting the next step. |
Dispatched-Log |
Processed 42 items |
Add a log line to the run’s event stream. |
Opting in per step
Directives are opt-in per step. List the directive names a step may emit on
the step’s directives field; any Dispatched-* header whose type isn’t listed
is recorded as a directive.skipped event and discarded.
{
"steps": {
"process_order": {
"request": { "method": "POST", "url": "https://..." },
"directives": ["spawn", "log"]
}
}
}
An empty or missing directives list means no directives are accepted from
that step — services can send Dispatched-* headers, but the engine will skip
them. This default is deliberate: directives let an upstream service trigger
cross-run effects, so each step must explicitly grant the privilege.
Event taxonomy
Every directive a step receives produces at least one event in the run’s stream:
| Event | When | Notable fields |
|---|---|---|
directive.executed |
A directive ran to completion |
type, step_id, plus type-specific keys |
directive.skipped |
Header received but not in the step’s allowlist |
type, step_id, reason: "not_allowlisted" |
directive.failed |
Parse error, missing param, target run unknown, or a runtime exception |
type, step_id, reason, detail |
directive.spawned |
Dispatched-Spawn started a child run |
child_run_id, workflow, version, step_id |
run.log |
Dispatched-Log emitted a message |
message, step_id, source: "directive" |
Directive failures never fail the step — the step still completes and the run
advances. Errors are surfaced via the directive.failed event and a warning
log line.
Limits
-
Dispatched-Delayis capped at 15 minutes. Longer holds should use a dedicatedwaitstep. -
Dispatched-Spawnis capped at a depth of 8 to prevent runaway recursion (run A spawning run B spawning run A…). Spawned runs carry the depth via a reserved_dispatched.spawn_depthfield in their trigger data. - Log messages are truncated to 4096 bytes.
-
Base64-decoded
input/datapayloads larger than 256 KB are rejected.
Spawning a child workflow
import base64, json
@app.post("/process-order")
def process_order(request):
order = process(request.json)
# Kick off a fulfillment workflow as a child
child_input = base64.b64encode(json.dumps({"order_id": order["id"]}).encode()).decode()
return order, 200, {
"Dispatched-Spawn": f"fulfill-order; input={child_input}; mode=detach"
}
Sending a signal
@app.post("/webhook/payment-confirmed")
def payment_webhook(request):
run_id = request.json["metadata"]["run_id"]
return {"ok": True}, 200, {
"Dispatched-Signal": f"run={run_id}; name=payment-confirmed; data=eyJ..."
}
Retry-After
If you return 429 or 503, you can hint at when to retry with Retry-After:
HTTP/1.1 429 Too Many Requests
Retry-After: 60
Response body
If your Content-Type is application/json, we parse the body and store it in the run context. Downstream steps reach it via expressions:
{{ steps.charge_payment.response.body.transaction_id }}
{{ steps.charge_payment.response.status }}
{{ steps.charge_payment.response.headers.x-request-id }}
Keep responses lean
Return the fields downstream steps actually need. Response bodies are encrypted and stored as events — bloated payloads make debugging harder and storage larger.
# Good: return only what the workflow needs
return {"transaction_id": "txn_123", "status": "captured", "amount": 4999}
# Bad: return the entire Stripe charge object (100+ fields)
return stripe_charge.to_dict()
Compensation (saga rollback)
If your step has a compensate config, we’ll call your rollback endpoint when a later step fails. The compensation request uses the same idempotency key with /compensate appended:
Idempotency-Key: run_WxEu4l/charge_payment/compensate
Make your compensation endpoint idempotent — we may call it more than once:
@app.post("/refund")
def refund(request):
idem_key = request.headers.get("Idempotency-Key")
if already_refunded(idem_key):
return {"status": "already_refunded"}
result = stripe.refunds.create(charge=request.json["charge_id"])
mark_refunded(idem_key)
return result
Timeout handling
Steps have a configurable timeout (default: 30 seconds). If your work takes longer:
-
Return
202 Acceptedimmediately with a reference ID. - Send a signal to wake the workflow up when you’re done.
@app.post("/generate-report")
def generate_report(request):
run_id = request.headers.get("Dispatched-Run")
job_id = enqueue_report_generation(request.json, run_id)
return {"job_id": job_id, "status": "processing"}, 202
Then the step waits for the signal:
"generate_report": {
"request": {"method": "POST", "url": "https://api.example.com/generate-report"},
"wait": {"signal": "report-ready", "timeout": "10m"}
}
And your worker fires the signal when the job finishes:
# Background worker
def on_report_complete(job):
requests.post(
f"https://dispatched.work/api/runs/{job.run_id}/signal/report-ready",
headers={"Dispatched-Session": session_token},
json={"report_url": job.result_url}
)
Security considerations
-
Verify the caller. If you’ve minted a signing secret, verify the RFC 9421
Signatureheader on every request — see Verifying request signatures above.Dispatched-RunandDispatched-Stepalone are not authentication. - Don’t trust the body blindly. The body comes out of expression evaluation against the workflow definition — validate it like any other external input.
-
Log the run ID. Including
Dispatched-Runin your logs makes cross-service tracing a lot easier. -
Redacted headers. We automatically redact
AuthorizationandX-Api-Keyfrom event data, so your API keys won’t end up in the run’s event stream.
Checklist for a well-integrated service
-
[ ] Handle
Idempotency-Keyto avoid double-processing -
[ ] Verify the RFC 9421
Signatureheader on every request (reject if absent, stale, or invalid) - [ ] Return the right status codes (422 for bad input, 503 for transient issues)
- [ ] Keep response bodies focused on what downstream steps actually need
- [ ] Make compensation endpoints idempotent
-
[ ] Log
Dispatched-RunandDispatched-Stepfor correlation - [ ] For long jobs, return 202 and use signals
- [ ] Validate incoming request bodies