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:

  1. On the first request, do the work and cache the result keyed by the idempotency key.
  2. 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

  1. POST /api/tenant/signing-secret → get a new secret.
  2. Deploy it to your services (alongside the old one if you want zero-downtime — accept both for a grace period).
  3. 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-Delay is capped at 15 minutes. Longer holds should use a dedicated wait step.
  • Dispatched-Spawn is 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_depth field in their trigger data.
  • Log messages are truncated to 4096 bytes.
  • Base64-decoded input/data payloads 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 Accepted immediately 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 Signature header on every request — see Verifying request signatures above. Dispatched-Run and Dispatched-Step alone 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-Run in your logs makes cross-service tracing a lot easier.
  • Redacted headers. We automatically redact Authorization and X-Api-Key from event data, so your API keys won’t end up in the run’s event stream.

Checklist for a well-integrated service

  • [ ] Handle Idempotency-Key to avoid double-processing
  • [ ] Verify the RFC 9421 Signature header 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-Run and Dispatched-Step for correlation
  • [ ] For long jobs, return 202 and use signals
  • [ ] Validate incoming request bodies