An eight-step payment workflow costs about $0.00055 on Temporal Cloud and $0.00025 on AWS Step Functions Standard. At a million payments a month that is $550 against $250, and neither figure includes the workers or Lambdas that do the actual work. The price gap is real, but it is the least important difference between the two.
The pages that rank for "Temporal vs Step Functions" compare developer experience and AWS integration. A couple list the numeric limits. None works out a cost per payment, none shows how to derive the idempotency key you send to the PSP, and none says how long each engine will stop you starting the same payment twice. Those are the questions that decide whether a saga double-charges someone. The numbers below come from the vendors' own docs and price lists, checked in November 2026.
What Is a Payment Saga?
A payment touches several systems that cannot share a transaction: the PSP, your ledger, a fraud service, a payout rail. A saga runs them as a sequence of local steps and, when a later step fails, runs compensating steps for the earlier ones. Authorise, capture, post to the ledger, pay out; if the payout fails, reverse the ledger entry and refund the capture.
The hard part is not the sequence. It is surviving a crash between steps, a deploy halfway through, and a PSP call that timed out without telling you whether it worked. A durable execution engine records each completed step so the workflow resumes where it stopped. Temporal, Step Functions and Restate all do this. They differ in where the record lives, how big it can grow and what it costs.
Temporal vs Step Functions vs Restate: Limits Compared
| Temporal Cloud | Step Functions Standard | Restate 1.8 | |
|---|---|---|---|
| Workflow written as | Code (Go, Java, TS, Python, .NET, Rust) | JSON state machine (ASL) | Code (TS, Java, Kotlin, Python, Go, Rust) |
| Max duration | No fixed cap (timers up to 100 years) | 1 year | No fixed cap |
| History limit | 51,200 events or 50 MB, then terminated | 25,000 events, then failed | Journal per invocation |
| Payload limit | 2 MB per request | 256 KiB per state | 10 MiB warning threshold |
| Default retry | Unlimited attempts, 1 s initial, x2, 100 s cap | No retry unless you add Retry | 70 attempts, 500 ms initial, x2, 1 min cap, then pause |
| History kept after close | 1 to 90 days (default 30) | 90 days | 1 day by default |
| Runs where | Temporal Cloud or self-hosted cluster | AWS only | Single Rust binary, self-hosted or Restate Cloud |
| Licence | MIT server | Proprietary | BSL 1.1, Apache 2.0 after four years |
Two of those defaults bite payment teams. Temporal retries an Activity forever unless you cap it, so a card decline that your code throws as an ordinary error gets re-submitted every 100 seconds until someone notices. Step Functions does the opposite: with no Retry block, one Lambda timeout fails the whole execution. Neither default is right for a PSP call. You want a handful of retries on network errors and zero on declines.
Temporal also warns at 10,240 events or 10 MB, well before the hard limit. A single payment will never get near it. A long-lived "merchant account" workflow that receives a Signal per transaction will, and Temporal terminates an execution after 10,000 Signals regardless. Use Continue-As-New for those, or keep one workflow per payment.
How Much Does Temporal Cost vs Step Functions per Payment?
Temporal Cloud bills actions at $50 per million for the first five million a month, falling in tiers to $25. A workflow start is one action. Each Activity start is one, and so is each retry. A Timer is one, a Signal is one, a Query is one. Starting a Child Workflow is two. Replay is free.
Step Functions Standard bills state transitions at $0.000025 each, the same in London (eu-west-2) as in us-east-1. AWS's own pricing example counts the start and end nodes, so two Task states bill as four transitions. Every retry is another transition.
Take a payment with eight steps, one of which waits for a webhook with a timeout:
| Temporal Cloud | Step Functions Standard | |
|---|---|---|
| Billable units | 1 start + 8 Activities + 1 Signal + 1 Timer = 11 | 8 states + start + end = 10 |
| Unit price | $0.00005 | $0.000025 |
| Per payment | $0.00055 | $0.00025 |
| 1M payments a month | $550 | $250 (first 4,000 transitions free) |
Add a Choice state for each branch and the Step Functions count creeps up. Add a heartbeat to a slow Activity and the Temporal count does. Temporal Cloud also charges storage ($0.042 per GB-hour active, $0.00105 retained), and its Business plan is the greater of $500 a month or 10% of usage. Self-hosting Temporal removes the action bill and replaces it with a cluster and a database to run.
Step Functions Express looks far cheaper at $1 per million requests plus a duration charge. It is the wrong tool for a payment, as the next section explains.
Step Functions Standard vs Express for Payments
Standard workflows give exactly-once execution of each state, run for up to a year and support the callback pattern. AWS's own guidance names "processing payments" as a Standard use case. Express workflows run for five minutes at most, are at-least-once when started asynchronously and at-most-once when started synchronously, and do not support .waitForTaskToken, redrive or Distributed Map.
AWS states that StartExecution is not idempotent for Express: two starts with the same name run concurrently. For a capture, that is a double charge waiting for a retrying client. Use Express for the stateless parts (fraud feature lookup, enrichment) and call it from a Standard parent.
One caveat on "exactly-once". AWS qualifies it: it holds unless you specify Retry. Once you add a retrier, the task can run more than once, which is correct behaviour and exactly why the downstream call needs an idempotency key.
How to Derive a PSP Idempotency Key in Temporal and Step Functions
The engine will re-run a failed step. The PSP must see the same key on every attempt of that step and a different key for every other step. I covered the PSP side in idempotency keys for payment APIs; here is where the key comes from in each engine.
Temporal. The docs suggest Workflow Run ID plus Activity ID, which is stable across Activity retries. I would not use the Run ID. It changes on Continue-As-New, on a workflow retry and on a reset, and a reset is precisely what an on-call engineer does at 3 a.m. to a stuck payment. Use the Workflow ID, which you set to your own payment ID, plus the Activity ID. Both are onactivity.Info in the TypeScript SDK (workflowExecution.workflowId, activityId). Never include attempt.
Step Functions. The context object gives you $$.Execution.Name and $$.State.Name, both stable across retries. $$.State.RetryCount changes per attempt, and the task token is regenerated if a callback task times out, so neither belongs in a key:
"Authorise": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": {
"FunctionName": "authorise",
"Payload": {
"payment.$": "$.payment",
"idempotencyKey.$": "States.Format('{}:{}', $$.Execution.Name, $$.State.Name)"
}
},
"Retry": [
{ "ErrorEquals": ["CardDeclined"], "MaxAttempts": 0 },
{ "ErrorEquals": ["States.TaskFailed"], "IntervalSeconds": 2,
"MaxAttempts": 4, "BackoffRate": 2.0, "MaxDelaySeconds": 30,
"JitterStrategy": "FULL" }
],
"Catch": [
{ "ErrorEquals": ["States.ALL"], "ResultPath": "$.error", "Next": "Unwind" }
],
"Next": "Capture"
}
MaxAttempts: 0 is how you mark an error non-retryable in ASL. JitterStrategy defaults to NONE, so set FULL or every failed payment retries in step with the others during a PSP incident. Note that States.ALL does not catch States.DataLimitExceeded: a 300 KB PSP response fails the execution with no compensation.
Restate. Generate the payment ID inside a ctx.run step. The journal stores the result, so replay returns the same value rather than a fresh UUID.
How Long Does Each Engine Deduplicate a Workflow Start?
Naming the workflow after the payment ID stops a duplicate start, but only for a while.
- Step Functions Standard: a second
StartExecutionwith the same name and same input returns 200 while the first is running. Once it has closed, or if the input differs, you getExecutionAlreadyExists. The name becomes reusable 90 days after the execution closes. - Temporal: only one execution per Workflow ID can be open at a time. For closed ones, the
Reject Duplicatereuse policy blocks a restart, but it is checked against retained history only. On Temporal Cloud that is 90 days at most and 30 by default. The default policy isAllow Duplicate, which permits a rerun as soon as the first has finished. - Restate: a Workflow's
runhandler executes once per workflow ID, and completed workflows are retained for one day by default. TheIdempotency-Keyheader on ingress calls is remembered for 24 hours.
Saga Compensation: Void vs Refund vs Ledger Reversal
Most saga tutorials pair each step with one undo. Card payments do not work like that. The right undo for an authorisation depends on what happened afterwards: before capture you void, which releases the hold and costs nothing; after capture you refund, which is a new transaction with its own fees and failure modes. I went through the scheme rules in void vs refund vs reversal. A ledger posting is never deleted. You post a reversing entry.
Temporal's guidance is to register the compensation before running the step, because a timed-out authorisation may have succeeded at the PSP. That means the compensation must cope with a forward step that never happened. In TypeScript, where there is no built-in helper (only the Java SDK ships a Saga class), it looks like this:
const { authorise, capture, postLedger, payout, unwindPayment, reverseLedger } =
proxyActivities({
startToCloseTimeout: '30 seconds',
retry: { maximumAttempts: 5, nonRetryableErrorTypes: ['CardDeclined'] },
});
export async function payment(p: PaymentRequest): Promise {
const undo: Array<() => Promise> = [];
try {
undo.unshift(() => unwindPayment(p.paymentId));
await authorise(p);
await capture(p.paymentId);
undo.unshift(() => reverseLedger(p.paymentId));
await postLedger(p);
await payout(p);
} catch (err) {
for (const step of undo) {
try { await step(); } catch (e) { / alert, keep going / }
}
throw err;
}
}
unwindPayment is one Activity that asks the PSP for the payment's current state and then voids, refunds or does nothing. That is my opinion rather than anyone's documentation: a separate voidAuth and refund pushed onto a list means the workflow guesses the PSP's state from which line it reached, and the timeout case proves it cannot know.
Three caveats apply across engines. Compensations fail too (a refund can be rejected), so a failed one needs a human queue, not an infinite retry. In Temporal, Terminate and Reset skip the catch block, so compensations never run; cancel instead. In Restate only a TerminalError triggers compensation, and everything else retries 70 times and then pauses the invocation.
Waiting for a Payment Webhook: Signals vs Task Tokens vs Awakeables
Bank transfers and 3DS challenges finish minutes or days later, by webhook.
In Temporal, your webhook handler sends a Signal (fire and forget) or an Update (acknowledged, can be rejected) to the Workflow ID. Because that ID is your payment ID, there is nothing to look up. In Step Functions, a .waitForTaskToken task hands you an opaque token and you must store a mapping from PSP reference to token yourself, then call SendTaskSuccess. Set TimeoutSeconds, or the task waits for the full year. If it does time out and retry, a new token is issued and the old mapping is dead. Restate's awakeable is the same shape as a task token: an ID you resolve over HTTP.
Temporal is the cleanest here, and Block has presented a talk on using Workflow Update in its checkout payments flow.
Is the Temporal Rust SDK Production Ready in 2026?
It went generally available on 23 September 2026, and the temporalio-sdk crate is at 1.2.0. That removes the old objection that a Rust payments shop had to write its workflows in Go. One caveat from Temporal's own announcement: the nondeterminism detector cannot catch every synchronous call such as SystemTime::now() or rand::random() inside workflow code. Lint for them. Restate's Rust SDK is still pre-1.0 at 0.13.0, and its saga guide has no Rust example. If you handle amounts in those workflows, my notes on rust_decimal vs bigdecimal apply to the payload types.
Do You Need a Workflow Engine, or Is a Postgres State Machine Enough?
My position: for a linear payment with three or four steps, a payments.status column, a transactional outbox and a retry worker is enough, and it keeps the state where your auditors already look. A flow that is really a single UPDATE ... WHERE status = 'authorised' does not need a cluster behind it.
The engine earns its place when you have long waits, branching compensations and more than one team writing flows. Between the two big ones I would pick on a single question: do in-flight payments need to survive code changes for weeks? A Step Functions execution keeps the definition it started with, and redrive (within 14 days of failure) resumes on that original definition. Temporal replays your current code against old history, so reordering two Activities breaks running payments unless you use Worker Versioning or patching. That is the operational tax Temporal charges for writing workflows in a real language, and Coinbase and Block evidently find it worth paying. If you are all-in on AWS with flows under a dozen states, Step Functions Standard is cheaper and has less to get wrong.
What This Means for Payments Engineers
1. Set the workflow ID or execution name to your payment ID, and still enforce uniqueness in your own database. Engine dedupe expires in 1 to 90 days.
2. Build PSP idempotency keys from stable identifiers only: Workflow ID plus Activity ID, or execution name plus state name. Never the run ID, attempt number or task token.
3. Override the retry defaults on every PSP call. Cap Temporal's unlimited attempts; add a Retry block with JitterStrategy: FULL in ASL; mark declines non-retryable in both.
4. Never run money movement on Express workflows. Standard only.
5. Write one unwind step that reads PSP state and chooses void or refund, and route failed compensations to a person.
6. Block Terminate and Reset in production runbooks for Temporal unless the payment has been checked by hand first.
7. Budget by units, not workflows: count actions or transitions per payment, including retries, before you sign a plan.
Key Takeaways
Step Functions Standard is roughly half the price per payment and freezes the definition for each execution. Temporal costs more, lets you write sagas in code and has the better webhook story. Restate is the lightest to run but has the shortest default retention. All three will re-run a step, so the idempotency key and the unwind logic are yours to get right whichever you pick.
I'm Tom Wang, and I build payment infrastructure for a living. Count the units for your own flow before you trust anyone's per-million price.