Implementing Payment, Approval, and Onboarding Workflows with the Temporal.io TypeScript SDK — How to Make Long-Running Business Processes Durable Without Cron Jobs or Message Queues
When a server dies mid-payment, how do you prevent a retry queue from charging an already-billed card again? If a process exits while waiting for manager approval, where do you resume? I initially tried solving these problems with BullMQ, but ended up stacking a status-tracking DB table, a retry scheduler, and timeout-handling code on top of each other — effectively writing my own mini workflow engine. I discovered Temporal.io after that, and my first thought was: "This should have existed from the start."
Temporal.io is a Durable Execution platform that lets you express long-running business processes as code, automatically preserving and resuming workflow state even through server crashes, deployment restarts, and network partitions. Logic like keeping a payment cancellation window open for 24 hours, waiting up to 7 days for manager approval, or billing a subscription every 30 days can all be expressed in a single set of TypeScript functions.
This article covers Temporal's core operating principles (event-history-based durability) and TypeScript SDK implementation patterns for real-world business scenarios. We'll walk through payment Saga compensating transactions, Signal-based multi-step approvals, subscription billing without Cron, and an onboarding pipeline — all code-first.
Core Concepts
Durable Execution: Write Code As If Failures Don't Exist
Temporal's philosophy in one line: "Let you write workflow code as if failures don't exist." The secret is the Event History. The Temporal Server records every event that occurs during a Workflow execution (Activity scheduling/completion, Signal receipt, Timer start/expiry, etc.) to persistent storage. When a Worker process dies, a new Worker replays this history in order to restore the exact state at the point of interruption, then picks up the next task from there.
This structure means you don't need to write separate deduplication logic to guarantee idempotency. Already-completed Activities are not re-invoked during Replay — the result stored in history is returned as-is. In exchange, Workflow functions must be Deterministic.
The TypeScript SDK enforces this determinism constraint via a Node.js context-based workflow sandbox. Inside the sandbox, global APIs like Date, Math.random, and setTimeout are replaced with deterministic versions, so calling them by mistake won't break Replay. However, meaningful delays must use sleep() from @temporalio/workflow so they are managed as server-side Timers that survive Worker restarts. External I/O like HTTP requests and DB queries must still be separated into Activities — follow that principle and the determinism constraint is largely satisfied naturally.
Workflow · Activity · Worker: The Principle of Role Separation
Understanding Temporal's components is the first hurdle.
| Component | Role | Example |
|---|---|---|
| Workflow | Durable function that defines the flow of business logic | "Charge card → wait 24 hours → refund if not approved" |
| Activity | The actual unit of work responsible for side effects | Charge card API call, send email, DB write |
| Worker | Executes Workflow and Activity code and reports results to the server | The process where code actually runs |
| Signal | Asynchronously delivers an external event to a running Workflow | Approval completion event, cancellation request |
| Query | Synchronously reads internal Workflow state | Current processing step, remaining amount |
| Update | Hybrid of Signal + Query. Handles state change and immediate response together | Return approval decision result immediately after processing |
The Workflow decides "what, in what order," while the Activity handles "actually doing something external."
// activities.ts — responsible for actual side effects
export async function chargeCard(orderId: string): Promise<void> {
await paymentGateway.charge(orderId);
}
export async function refundPayment(orderId: string): Promise<void> {
await paymentGateway.refund(orderId);
}
export async function sendReceipt(orderId: string): Promise<void> {
await emailService.send(orderId, 'receipt');
}// worker.ts — Worker process setup
import { Worker } from '@temporalio/worker';
import * as activities from './activities';
async function run() {
const worker = await Worker.create({
workflowsPath: require.resolve('./workflows'),
activities,
taskQueue: 'payment-queue',
});
await worker.run();
}
run().catch(console.error);Signal · condition · Query: Bringing External Events Into a Workflow
While a Workflow is running, how do you deliver events like "approval complete" or "cancellation requested" from the outside? Temporal provides the following mechanisms:
- Signal: Asynchronously delivers an external event to a running Workflow. Received inside the Workflow with
setHandler - condition(): Waits until a specific condition becomes true. Accepts a timeout; returns
falseon timeout - Query: Synchronously reads internal Workflow state. Read-only with no side effects
- Update: Hybrid of Signal + Query. Handles state change and immediate response simultaneously
This combination lets you implement logic like "wait for a manager to click approve, but auto-reject after 7 days" — without a separate polling server or scheduler.
Practical Application
Payment Workflow and Saga Compensating Transactions
In a distributed environment, payment involves multiple steps: card charge, inventory reservation, shipping preparation — figuring out how to roll back earlier steps when a middle step fails is always a headache. The pattern that implements this without traditional 2PC (Two-Phase Commit) is the Saga, and Temporal expresses this naturally through Compensating Activities.
Implementing this pattern with the TypeScript SDK looks like this:
// workflows/orderWorkflow.ts
import { proxyActivities, defineSignal, setHandler } from '@temporalio/workflow';
import type * as activities from '../activities/orderActivities';
const {
chargeCard,
reserveInventory,
scheduleShipment,
refundPayment,
releaseInventory,
sendReceipt,
} = proxyActivities<typeof activities>({
startToCloseTimeout: '30 seconds',
retry: {
maximumAttempts: 3,
nonRetryableErrorTypes: ['InsufficientFundsError', 'CardDeclinedError'],
},
});
export const cancelSignal = defineSignal<[{ reason: string }]>('cancel');
export async function orderWorkflow(orderId: string): Promise<string> {
let cancelled = false;
let cancelReason = '';
setHandler(cancelSignal, ({ reason }) => {
cancelled = true;
cancelReason = reason;
});
let charged = false;
let inventoryReserved = false;
try {
await chargeCard(orderId);
charged = true;
// Cancellation check only happens between Activities.
// To abort immediately during an Activity, wrap it in a CancellationScope.
if (cancelled) throw new Error('Cancellation requested');
await reserveInventory(orderId);
inventoryReserved = true;
if (cancelled) throw new Error('Cancellation requested');
await scheduleShipment(orderId);
await sendReceipt(orderId);
return 'completed';
} catch (err) {
if (inventoryReserved) await releaseInventory(orderId);
if (charged) await refundPayment(orderId);
return `failed: ${cancelReason || String(err)}`;
}
}The key point is using nonRetryableErrorTypes to specify error types where retrying makes no sense. Retrying an insufficient funds error three times serves no purpose. The charged and inventoryReserved flags track "how far execution got" and precisely control the scope of compensating transactions.
One caveat: this cancellation check only occurs at the boundaries between Activities. To abort an already-running Activity immediately, you need to wrap it in CancellationScope.cancellable(...) and handle heartbeat-based cancellation requests on the Activity side as well.
Multi-Step Approval Workflow
A scenario where a purchase request requires manager approval, and finance team additional approval if the amount is large. The key is that even though clicking the approval button comes in as an HTTP request, inside the Workflow it is received as a Signal.
// workflows/purchaseApprovalWorkflow.ts
import {
proxyActivities,
defineSignal,
setHandler,
condition,
} from '@temporalio/workflow';
import type * as activities from '../activities/approvalActivities';
interface ApprovalDecision {
approved: boolean;
comment?: string;
}
interface PurchaseRequest {
id: string;
amount: number;
requester: string;
description: string;
}
const { notifyManager, notifyFinance, processPurchase, rejectRequest } =
proxyActivities<typeof activities>({
startToCloseTimeout: '10 seconds',
retry: { maximumAttempts: 3 },
});
export const managerApprovalSignal =
defineSignal<[ApprovalDecision]>('managerApproval');
export const financeApprovalSignal =
defineSignal<[ApprovalDecision]>('financeApproval');
export async function purchaseApprovalWorkflow(
request: PurchaseRequest
): Promise<string> {
let managerDecision: ApprovalDecision | null = null;
let financeDecision: ApprovalDecision | null = null;
setHandler(managerApprovalSignal, (d) => { managerDecision = d; });
setHandler(financeApprovalSignal, (d) => { financeDecision = d; });
await notifyManager(request);
const managerResponded = await condition(
() => managerDecision !== null,
'7 days'
);
if (!managerResponded || !managerDecision?.approved) {
await rejectRequest(request, 'Manager did not approve or timed out');
return 'rejected';
}
if (request.amount > 10_000) {
await notifyFinance(request);
const financeResponded = await condition(
() => financeDecision !== null,
'3 days'
);
if (!financeResponded || !financeDecision?.approved) {
await rejectRequest(request, 'Finance team did not approve or timed out');
return 'rejected';
}
}
await processPurchase(request);
return 'approved';
}When an approval button is clicked, how does that get connected as a Signal? In practice, you create an approval endpoint in an API server (e.g., Express), and inside that handler you call handle.signal() on the Temporal Client to deliver the event to the running Workflow.
// api/approval.ts — approval API endpoint
import express from 'express';
import { Client } from '@temporalio/client';
import { managerApprovalSignal } from '../workflows/purchaseApprovalWorkflow';
const app = express();
const temporal = new Client();
app.post('/purchases/:id/approve', async (req, res) => {
const handle = temporal.workflow.getHandle(`purchase-${req.params.id}`);
await handle.signal(managerApprovalSignal, {
approved: req.body.approved,
comment: req.body.comment,
});
res.json({ ok: true });
});In this structure, the API server fires a Signal and responds immediately. The actual processing after approval is handed off to the Workflow, so HTTP response latency or server restarts don't affect the approval processing flow.
Subscription Billing Workflow Without Cron
This pattern was the most surprising when I first saw it. You can express billing every 30 days — without Cron — using a while loop and sleep().
// workflows/subscriptionWorkflow.ts
import {
proxyActivities,
sleep,
defineSignal,
setHandler,
continueAsNew,
} from '@temporalio/workflow';
import type * as activities from '../activities/subscriptionActivities';
const { chargeMonthlyFee, sendPaymentFailureNotice, cancelSubscription } =
proxyActivities<typeof activities>({
startToCloseTimeout: '1 minute',
retry: { maximumAttempts: 5, initialInterval: '1 hour' },
});
export const cancelSignal = defineSignal('cancel');
const ITERATIONS_BEFORE_CONTINUE = 100;
export async function subscriptionWorkflow(
customerId: string,
iteration = 0
): Promise<void> {
let isCancelled = false;
setHandler(cancelSignal, () => { isCancelled = true; });
let current = iteration;
while (!isCancelled) {
try {
await chargeMonthlyFee(customerId);
} catch (err) {
await sendPaymentFailureNotice(customerId);
}
// Timer is managed by Temporal Server — unaffected by Worker restarts
await sleep('30 days');
current += 1;
if (current >= ITERATIONS_BEFORE_CONTINUE) {
// Continues as a new execution to prevent unbounded history growth
await continueAsNew<typeof subscriptionWorkflow>(customerId, 0);
}
}
await cancelSubscription(customerId);
}The key point is that sleep('30 days') does not run a timer inside the Worker process. Timer expiry is managed by the Temporal Server, which notifies the Worker when the time comes. Even if the Worker restarts due to a deployment, the timer is unaffected.
Workflows with an infinite-loop nature have the problem of continuously accumulating event history. As history size grows, Replay cost increases, and once the server-imposed upper limit is reached (the official guide cites roughly 50,000 events as the upper bound, with warnings well before that), execution is rejected. That's why the standard pattern — as in the example above — is to start a new execution with continueAsNew at a regular interval to reset the history. The actual threshold varies by SDK/server version, so check the official documentation for the latest figures.
User Onboarding Pipeline
Temporal particularly shines in onboarding because it can consolidate external verification steps that involve human interaction — email verification, KYC, document upload — into a single code flow. "Send a reminder after 24 hours" is expressed declaratively in the code, without a separate scheduler.
// workflows/onboardingWorkflow.ts
import {
proxyActivities,
defineSignal,
setHandler,
condition,
} from '@temporalio/workflow';
import type * as activities from '../activities/onboardingActivities';
const {
sendVerificationEmail,
sendKycRequest,
sendReminderEmail,
activateAccount,
expireOnboarding,
} = proxyActivities<typeof activities>({
startToCloseTimeout: '30 seconds',
retry: { maximumAttempts: 3 },
});
export const emailVerifiedSignal = defineSignal('emailVerified');
export const kycCompletedSignal =
defineSignal<[{ passed: boolean }]>('kycCompleted');
export async function onboardingWorkflow(userId: string): Promise<string> {
let emailVerified = false;
let kycResult: { passed: boolean } | null = null;
setHandler(emailVerifiedSignal, () => { emailVerified = true; });
setHandler(kycCompletedSignal, (result) => { kycResult = result; });
await sendVerificationEmail(userId);
let verified = await condition(() => emailVerified, '24 hours');
if (!verified) {
await sendReminderEmail(userId, 'email-verification');
verified = await condition(() => emailVerified, '48 hours');
}
if (!verified) {
await expireOnboarding(userId);
return 'expired';
}
await sendKycRequest(userId);
const kycDone = await condition(() => kycResult !== null, '7 days');
if (!kycDone || !kycResult?.passed) {
await expireOnboarding(userId);
return 'kyc-failed';
}
await activateAccount(userId);
return 'activated';
}Implementing this logic the traditional way would have required a separate scheduler table, status columns, and a reminder Job. In Temporal, all that infrastructure converges into a single Workflow code file.
Pros and Cons
Advantages
| Item | Detail |
|---|---|
| Durability guarantee | State is preserved and automatically resumed through server crashes, deployment restarts, and network partitions |
| Cron replacement | Periodic execution with retry history, without a scheduler single point of failure |
| Message queue replacement | Queue + state DB + scheduler consolidated into a single Workflow code file |
| Precise retry policies | Per-Activity configuration of max attempts, backoff coefficient, and non-retryable error types |
| Testability | Unit and integration testing with time-skipping via TestWorkflowEnvironment |
| Real-time visibility | Query all running Workflow states, history, and failure causes via Temporal UI and CLI |
| Multi-language support | TypeScript, Go, Java, Python, .NET, and Rust SDKs |
Disadvantages
| Item | Detail |
|---|---|
| Deterministic code constraint | External I/O must be separated into Activities, delays must use sleep(), Workflow code must contain only pure logic flow |
| Versioning complexity | Changing code while Workflows are in flight causes non-determinism errors. Requires the patched API or Worker Versioning |
| Self-hosted operational cost | Significant engineering cost for Temporal Server + Elasticsearch + Cassandra/PostgreSQL infrastructure |
| Event history limit | An upper bound on history accumulation means long-running loops require continueAsNew or external storage |
| Overkill for simple tasks | A simple queue like BullMQ is more appropriate for single-step async tasks |
| Temporal Cloud cost | Usage-based billing model requires cost planning at high execution volumes |
Comparison with Other Tools
| Tool | When Temporal has the edge | When that tool has the edge |
|---|---|---|
| AWS Step Functions | Code-centric expression, local execution and testing, vendor independence | When only IaC and service integration in an AWS-only stack are needed |
| Apache Kafka | Multi-step orchestration, state tracking, retry policies | When the goal is high-throughput event streaming and state management is unnecessary |
| BullMQ | Multi-step coordination, long-running execution, deterministic resumption | When only a simple task queue (thumbnail generation, notification dispatch, etc.) is needed — infrastructure and learning costs are far lower |
| Inngest | Self-hostable, fine-grained retry and timeout control | When only serverless event-driven function triggers are needed |
| Conductor | Code expressiveness, TypeScript type safety | When workflows need to be shared with non-developers via JSON definitions |
Common Mistakes in Practice
1. Calling external I/O directly inside a Workflow
The most common mistake. Making direct HTTP requests or DB queries inside a Workflow function means the external service is actually called again during Replay.
// Wrong — direct external call inside Workflow
export async function badWorkflow(orderId: string) {
const result = await fetch(`/api/charge/${orderId}`); // Risk of double-charging on re-execution
}
// Correct — separated into an Activity
export async function goodWorkflow(orderId: string) {
await chargeCard(orderId); // Activity call wrapped via proxyActivities
}2. Changing running Workflow code without versioning
Changing branching logic while Workflows are in flight in production causes NonDeterminismError during Replay. The patched API provided by the TypeScript SDK is the solution.
import { patched } from '@temporalio/workflow';
export async function orderWorkflow(orderId: string) {
if (patched('add-email-step-v2')) {
// Code path for Workflows running on new Workers
await sendConfirmationEmail(orderId);
await chargeCard(orderId);
} else {
// Compatible path for already-running Workflows
await chargeCard(orderId);
}
}Once the patch is fully deployed and all execution paths flow through the new route, mark it with deprecatePatch to manage the cleanup timing.
3. The trap of setting Activity timeouts uniformly
Applying the same timeout from proxyActivities to all Activities bundles a fast DB query and a slow external payment API under the same timeout. It's better to separate Activities by their characteristics and narrow each proxy using Pick at the type level so it only exposes the Activities it owns — reducing room for mistakes.
// Assumes all activity functions are exported from activities/index.ts
import type * as activities from '../activities';
type FastActivities = Pick<typeof activities, 'updateOrderStatus' | 'logEvent'>;
type PaymentActivities = Pick<typeof activities, 'chargeCard' | 'refundPayment'>;
// For fast internal tasks
const { updateOrderStatus, logEvent } = proxyActivities<FastActivities>({
startToCloseTimeout: '5 seconds',
});
// For external payment APIs — longer timeout and retries
const { chargeCard, refundPayment } = proxyActivities<PaymentActivities>({
startToCloseTimeout: '2 minutes',
retry: { maximumAttempts: 3, initialInterval: '10 seconds' },
});This catches the mistake of accidentally calling updateOrderStatus from the payment proxy at compile time.
Closing Thoughts
Temporal.io is particularly strong for business processes that require multi-step orchestration, long waits, human intervention, and precise retry policies. Flows where state and time are intertwined — payment Sagas, approval pipelines, subscription billing, onboarding — can mostly be expressed by mastering just three primitives: Signal, condition, and Activity.
On the other hand, for systems that only need single-step async tasks (thumbnail generation, email dispatch, log aggregation), adopting Temporal easily becomes over-engineering. A simple queue like BullMQ or SQS is far cheaper in terms of learning cost, infrastructure complexity, and operational burden. The decision criterion for tool selection can be narrowed down to one question: "Are we actually doing multi-step state orchestration?"
If you're considering adoption, the following approach is recommended:
- Local environment setup: Start a dev server with
temporal server start-devand scaffold a TypeScript project with@temporalio/createto get a feel for the structure. - Complete the official tutorial: The Learn Temporal TypeScript subscription billing tutorial is a good way to experience Workflow, Activity, Worker, and Client in one cycle.
- Find a small adoption target: Pick one multi-step process currently handled with Cron or BullMQ where state tracking and retry logic are growing, and migrate it. That's where Temporal's value is most clearly demonstrated.
Topics like versioning (patched, Worker Versioning), history management (continueAsNew), and Activity cancellation (CancellationScope, heartbeat) are each worth digging into separately — be sure to check the official documentation on these before your first production migration.
References
- Temporal Official Docs — TypeScript Workflow Basics
- Learn Temporal — First Program in TypeScript SDK
- Learn Temporal — Build a Subscription Billing System in TypeScript
- Temporal TypeScript SDK Official Sample Repository
- Temporal TypeScript API Reference
- Temporal Versioning — TypeScript SDK
- Temporal Production Deployment Guide
- Official Use Cases and Design Patterns Documentation
- Temporal Official Blog — Reliable Data Processing with Queues and Workflows
- Temporal — Coinbase Case Study
- Saga Pattern and Distributed Transactions with Temporal
- Best Practices for Temporal Workflows and Activities