Foliot pushes a task to your handler
No authentication.
Foliot’s only outbound call. It is a command, not a notification: it tells your application to execute one step, and the run is blocked until you answer. Your handler’s latency is the run’s latency.
It fires once per task-step attempt — so again on every retry, and again after a rewind or redrive.
Where it arrives
POST {handlerBaseUrl}/foliot/{workflowName}/{step}
handlerBaseUrl is set per app. A step can override the path with its own
handler field.
Route on the body, not the path. The payload carries workflowName and
step; a step that overrides handler will not have a path matching them.
Verifying it
foliot-signature: t=1759036800,v1=<hex hmac-sha256>
The signed string is ${t}.${rawBody}, keyed by the app’s signing secret,
HMAC-SHA256, hex — the same construction as Stripe’s.
Verify over the raw bytes. Parsing the JSON and re-serialising it changes the bytes, and the signature will not match. Read the body as a string first.
Reject a timestamp more than 300 seconds from now, and compare in constant time.
The header can carry more than one v1=. During a signing-secret rollover
Foliot signs with both the new and the outgoing secret, so a handler that has
not picked up the new one yet still verifies. Accept the request if any
signature matches a secret you hold, and do not assume there is exactly one.
The other headers — foliot-deadline, foliot-task-token, foliot-app,
traceparent — mirror fields in the body and exist for logging and tracing
convenience. The body is authoritative.
Answering
| You return | Meaning |
|---|---|
200 {"output": …} | Step done. output becomes state[step] and the run advances. |
200 with an empty body | Step done, no output. Equivalent to {"output": null}. |
200 {"output": …, "next": "refund"} | Step done, and you choose where the run goes. Must name a declared step or a sentinel ($end, $fail, $stuck). |
202 | Accepted; you will finish later via POST /apps/{appId}/tasks/{token}/complete or /fail. The run parks until you do, or until deadline passes. |
4xx / 5xx with {"error": {...}} | Failed. The retry policy decides what happens next. |
On failure, error.retryable decides whether another attempt happens, and
error.class is what a retry policy’s retryOn matches against. Without them
Foliot classifies by status: 408 and 429 are retryable, other 4xx are not, 5xx
are.
Delivered more than once
Deduplicate on idempotencyKey. It is
{runId}:{generation}:{step}:{attempt} and is stable across redeliveries of the
same attempt. The timestamp window is replay protection, not delivery-once — a
network failure after you committed but before Foliot read your response
produces a second delivery carrying the same key.
Limits
A step’s output is capped at 256 KB (FOLIOT_PAYLOAD_MAX_BYTES), measured as the
JSON Foliot stores. Over it fails the step with error class output_too_large,
naming the step, and the run goes to the step’s onFailure if it has one (the
handler’s work happened, so a compensation should run) and fails otherwise. It is
not retried, because the same handler would return the same thing. The cap is per step: the run’s accumulated state, which is sent
back to every later step, is not capped by steps (editRunState holds each value
of its patch to the same limit, measured the same way, not the resulting state), so for large data
return a reference (a storage key or a URL) and not the data. Completing a task
out of band (completeTask) with an output over the limit, or a next naming no
step, fails the step the same way and answers 400, so the error shows up where
the bad body was sent. Sizes are the compact JSON, not
escaped, so <, > and & count as one byte each and U+2028 and U+2029 as three. A run’s input and a signal payload
are held to the same limit. A synchronous attempt
is additionally capped by FOLIOT_SYNC_TASK_MAX_MS; work that runs longer
should answer 202 and complete out of band.
Headers
stringt=<unix-seconds>,v1=<hex HMAC-SHA256 of ${t}.${rawBody}>, with one v1= per signing secret: two during a rollover.
pattern ^t=\d+(,v1=[0-9a-f]{64})+$
string (date-time)Mirrors deadline in the body. After it, the attempt is abandoned.
string (uuid)Mirrors taskToken in the body.
stringMirrors appId in the body.
stringW3C traceparent whose trace id is the run id.
Payload
stringinteger (int64)string,nullstring (date-time)After this the attempt is abandoned as timed out.
integer (int64)string{runId}:{generation}:{step}:{attempt}.
anyThe run's input.
string (uuid)map of anyEarlier steps' outputs, keyed by step name.
stringstring (uuid)Complete or fail the task with this.
stringW3C traceparent whose trace id is the run id.
stringinteger (int64)Responses
object
The step is done. An empty body is accepted and treated as completion with no output. next overrides the step the run moves to and must name a declared step or a sentinel ($end, $fail, $stuck).
taskToken, or the deadline passes and the attempt times out.
object
Failed. 408 and 429 are treated as retryable, other 4xx as not; a retryable field in the body overrides that.
object
Failed, retryable by default.