logo

How API Orchestration Works

API orchestration coordinates multiple API calls into a single, managed workflow. Instead of scattering service calls across your codebase and hoping they all succeed, an orchestrator controls the sequence, passes data between steps, handles failures, and tracks progress from one central point.

If you have ever written a 200-line controller calling five services in a row with nested try/catch blocks, you have already built ad-hoc orchestration. This guide shows how to replace that mess with a proper task queue.

Prerequisites: Familiarity with task queues, webhooks, and callbacks. See How to Chain Multiple API Calls for the simpler sequential pattern.

Step 1: Understand What API Orchestration Is

API orchestration differs from simple chaining in both scope and control:

API ChainingAPI Orchestration
Control flowLinear, step-by-stepBranching, parallel, conditional
Error handlingRetry or failCompensate, skip, or route to fallback
State managementPassed between stepsCentralized workflow state
VisibilityPer-task logsFull workflow timeline
ComplexityLowMedium to high

Choose orchestration when your workflow has any of these characteristics:

  • Steps that can run in parallel
  • Conditional branching (if payment fails, try a different provider)
  • Compensation logic (if shipping fails, refund the payment)
  • Multiple services that need a consistent view of workflow state

Step 2: Choose Your Orchestration Pattern

Sequential

Each step depends on the previous result. Best for simple pipelines.

Validate Order -> Charge Payment -> Create Shipment -> Send Email

Parallel (Fan-Out/Fan-In)

Independent steps run concurrently. Results are aggregated once every branch finishes.

┌─ Fetch User Profile ──┐
Start Workflow ──┼─ Fetch Order History ──┼── Merge & Respond
└─ Fetch Preferences ───┘

Hybrid

Blends sequential and parallel execution. Most real-world workflows follow this pattern.

Validate Order ─┬─ Charge Payment ────┬─ Send Confirmation
└─ Reserve Inventory ─┘

Step 3: Build an Orchestrated Workflow with AsyncQueue

Here is a hybrid workflow for processing a new subscription. First, validate the user. Then charge payment and provision the account in parallel. Finally, send a welcome email.

Define the workflow

const WORKFLOW_STEPS = {
validate: {
targetUrl: 'https://your-app.com/api/steps/validate-user',
next: ['charge', 'provision'], // fan-out after validation
retries: 1,
timeout: 10,
},
charge: {
targetUrl: 'https://your-app.com/api/steps/charge-payment',
next: ['welcome'], // converge before welcome email
retries: 2,
timeout: 30,
},
provision: {
targetUrl: 'https://your-app.com/api/steps/provision-account',
next: ['welcome'],
retries: 3,
timeout: 20,
},
welcome: {
targetUrl: 'https://your-app.com/api/steps/send-welcome',
next: [],
waitFor: ['charge', 'provision'], // fan-in: wait for both
retries: 3,
timeout: 15,
},
};

Start the workflow

app.post('/api/subscribe', async (req, res) => {
const workflowId = crypto.randomUUID();
await saveWorkflow({
id: workflowId,
type: 'subscription',
status: 'running',
completedSteps: [],
results: {},
context: req.body,
});
// Kick off the first step
await dispatchStep(workflowId, 'validate', req.body);
res.json({ workflowId, status: 'processing' });
});

Central dispatch function

async function dispatchStep(workflowId, stepName, payload) {
const step = WORKFLOW_STEPS[stepName];
await aq.tasks.create({
targetUrl: step.targetUrl,
payload: { workflowId, step: stepName, ...payload },
webhookUrl: 'https://your-app.com/api/orchestrator',
retries: step.retries,
timeout: step.timeout,
});
await updateWorkflow(workflowId, {
[`steps.${stepName}`]: 'running',
});
}

Central orchestrator endpoint

This is the core of the pattern. A single webhook endpoint receives every step’s result and decides what happens next:

app.post('/api/orchestrator', async (req, res) => {
const { workflowId, step: completedStep } = req.body.payload;
const result = req.body.result;
const workflow = await getWorkflow(workflowId);
// Store the result and mark the step complete
workflow.completedSteps.push(completedStep);
workflow.results[completedStep] = result;
await updateWorkflow(workflowId, {
[`steps.${completedStep}`]: 'completed',
completedSteps: workflow.completedSteps,
[`results.${completedStep}`]: result,
});
// Determine which steps to dispatch next
const stepDef = WORKFLOW_STEPS[completedStep];
for (const nextStep of stepDef.next) {
const nextDef = WORKFLOW_STEPS[nextStep];
// Check fan-in: are all prerequisites complete?
if (nextDef.waitFor) {
const allReady = nextDef.waitFor.every(
dep => workflow.completedSteps.includes(dep)
);
if (!allReady) continue; // Wait for other branches
}
// Build payload from previous results
const stepPayload = {
...workflow.context,
previousResults: workflow.results,
};
await dispatchStep(workflowId, nextStep, stepPayload);
}
// Check if workflow is complete
const allSteps = Object.keys(WORKFLOW_STEPS);
const allDone = allSteps.every(
s => workflow.completedSteps.includes(s)
);
if (allDone) {
await updateWorkflow(workflowId, { status: 'completed' });
}
res.status(200).json({ received: true });
});

Step 4: Handle Failures and Partial Completions

When a step fails after exhausting its retries, compensation logic must undo work from earlier steps:

app.post('/api/orchestrator/failed', async (req, res) => {
const { workflowId, step: failedStep } = req.body.payload;
const { error } = req.body;
const workflow = await getWorkflow(workflowId);
await updateWorkflow(workflowId, {
[`steps.${failedStep}`]: 'failed',
status: 'failed',
error: { step: failedStep, message: error },
});
// Compensate completed steps in reverse order
const compensations = {
charge: async (results) => {
await refundPayment(results.charge.transactionId);
},
provision: async (results) => {
await deprovisionAccount(results.provision.accountId);
},
};
for (const completedStep of [...workflow.completedSteps].reverse()) {
if (compensations[completedStep]) {
await compensations[completedStep](workflow.results);
}
}
await notifyOpsTeam(workflowId, failedStep, error);
res.status(200).json({ received: true });
});

Key principles:

  • Compensate in reverse order so dependencies are unwound correctly
  • Make compensations idempotent since they may also need retries
  • Log everything so you can audit what was rolled back

Step 5: Add Observability to Your Workflows

Track timing and status for every step to monitor health and debug failures:

app.get('/api/workflows/:id', async (req, res) => {
const workflow = await getWorkflow(req.params.id);
res.json({
workflowId: workflow.id,
type: workflow.type,
status: workflow.status,
steps: Object.entries(WORKFLOW_STEPS).map(([name]) => ({
name,
status: workflow.steps?.[name] || 'pending',
result: workflow.results?.[name] || null,
})),
error: workflow.error || null,
createdAt: workflow.createdAt,
});
});

In production, extend your orchestrator with these additions:

  • Duration tracking: Record startedAt and completedAt per step to identify bottlenecks
  • Alerting: Notify your team when workflows stay in running state beyond an expected threshold
  • Metrics: Track completion rates, average duration, and failure rates per step

Orchestration vs. Choreography

This guide covers orchestration, where a central controller manages the workflow. The alternative is choreography, where each service reacts to events independently with no central coordinator. Orchestration works best when you need visibility, centralized error handling, and explicit control flow. Choreography suits services that are truly independent and loosely coupled.