Skip to content

Callback Hooks

Suspend a workflow at a step until your app receives an HTTP callback (e.g. payment webhook, OAuth redirect). When the request arrives, call injectHook(state, { hookId, value }) and re-run the workflow with the updated resume state to continue with the callback payload. Server-agnostic: you own the URL and the handler.

  • Payment provider webhooks – Start checkout, suspend until the provider calls your POST /webhooks/payment/:hookId, then resume with the payment result.
  • OAuth or external redirects – Send the user to an external site, suspend until they hit your callback URL with the token/code, then resume.
  • Async approvals from external systems – Wait for a webhook from a third party instead of polling.

Unlike Human-in-the-Loop, hooks are driven by an incoming HTTP callback (or any event you map to injectHook), not by a human approval flow.

Generate a unique hookId and use it in both the workflow step and your callback URL:

import { createWorkflow, createResumeStateCollector } from 'awaitly/workflow';
import { createHook, pendingHook } from 'awaitly/workflow';
// One hook per “wait for callback” point; create once or per run
const { hookId, stepKey } = createHook();
const workflow = createWorkflow(
'payment-flow',
{
waitForPayment: async () => pendingHook(hookId),
}
);
const result = await workflow.run(async ({ step, deps }) => {
const order = await step('createOrder', () => createOrder(data), { key: 'create-order' });
// Workflow suspends here until you call injectHook with this hookId
const payment = await step('wait', () => deps.waitForPayment(), { key: stepKey });
return await step('fulfill', () => fulfillOrder(order, payment), { key: 'fulfill' });
});

Use stepKey (which equals "hook:" + hookId) as the step key so resume state matches. Expose a route that includes hookId, e.g. POST /hook/:hookId or POST /webhooks/payment/:hookId.

When the workflow returns, check if it stopped because it’s waiting for a callback:

import { isPendingHook } from 'awaitly/workflow';
if (!result.ok && isPendingHook(result.error)) {
// result.error.hookId is the hook to resolve
console.log('Waiting for callback:', result.error.hookId);
// Persist resume state and associate it with hookId so the callback handler can load it
const state = collector.getResumeState();
await store.saveResumeState(result.error.hookId, state);
}

When the HTTP callback hits your server, load the resume state, call injectHook, then run the workflow again with the updated state:

import { createWorkflow, injectHook } from 'awaitly/workflow';
import { pendingHook } from 'awaitly/workflow';
// In your HTTP handler (e.g. POST /hook/:hookId or POST /webhooks/payment/:hookId)
app.post('/hook/:hookId', async (req, res) => {
const { hookId } = req.params;
const state = await store.loadResumeState(hookId);
if (!state) {
return res.status(404).json({ error: 'Unknown or expired hook' });
}
const stateWithPayload = injectHook(state, { hookId, value: req.body });
const workflow = createWorkflow(
'payment-flow',
{ waitForPayment: async () => pendingHook(hookId) },
{ resumeState: stateWithPayload }
);
const result = await workflow.run(async ({ step, deps }) => {
const order = await step('createOrder', () => createOrder(data), { key: 'create-order' });
const payment = await step('wait', () => deps.waitForPayment(), { key: 'hook:' + hookId });
return await step('fulfill', () => fulfillOrder(order, payment), { key: 'fulfill' });
});
if (result.ok) {
await store.deleteResumeState(hookId);
res.json(result.value);
} else {
res.status(500).json({ error: result.error });
}
});

injectHook(state, { hookId, value }) returns a new resume state with the step "hook:" + hookId set to ok(value). Re-run the same workflow with resumeState: stateWithPayload so that step is skipped and the workflow continues with the injected value.

To see which hooks in a resume state are still waiting:

import { getPendingHooks, hasPendingHook } from 'awaitly/workflow';
const state = collector.getResumeState();
const pendingIds = getPendingHooks(state);
// ['hook-id-1', 'hook-id-2']
if (hasPendingHook(state, 'hook-id-1')) {
// This hook is still pending in this state
}

You can attach metadata when creating the pending hook (e.g. for logging or debugging):

pendingHook(hookId, { metadata: { orderId: 'ord_123', source: 'checkout' } });
ExportPurpose
createHook()Returns { hookId, stepKey }; use stepKey as the step key.
pendingHook(hookId, options?)Returns Err<PendingHook> to suspend the workflow until injectHook is called.
injectHook(state, { hookId, value })Returns new resume state with that hook step set to ok(value).
isPendingHook(error)Type guard: is this error a PendingHook?
hasPendingHook(state, hookId)Does this state have a pending hook for hookId?
getPendingHooks(state)Array of hookIds that are pending in this state.

Step keys for hooks use the prefix "hook:"; the full key is "hook:" + hookId (available as stepKey from createHook()).

Human-in-the-Loop – Pause for human approval and resume when approved.
Durable Execution – Checkpoint and resume across restarts.