Skip to content
Wait for Callback Node

Wait for Callback Node

Overview

The Wait for Callback node pauses workflow execution until an external resumer advances it. The pause is durable — the workflow can wait for seconds, hours, or days.

Under the hood, Step Functions parks the execution against a task token. The token is held inside SFN’s own state machine — it is not exposed via the node’s output, and there is no public endpoint that the SFN itself stamps. To advance the wait, some process must call sendTaskSuccess(token, output) against AWS. That process must have somehow obtained the token, which is where this node’s resume block comes in.

How resume actually works

  1. The wait Lambda is invoked with the SFN task token in its input.
  2. The Lambda writes (namespace, lookupKey) → SFN token to a generic resume-tokens store (DynamoDB) and returns. The return value is discarded by SFN; the state stays parked.
  3. An external event eventually fires (a third-party webhook, a frontend POST, an internal job completion).
  4. A statically-defined endpoint in the API receives that event, derives (namespace, lookupKey) from its payload, looks up the SFN token in the store, and calls sendTaskSuccess to advance the workflow.

The YAML does not — and cannot — create endpoints. Resume endpoints are TypeScript handlers in api/src/endpoints/..., registered with the lambda router and CDK at deploy time. The YAML’s resume block declares the correlation key those existing handlers use to find this specific parked wait.

Built-in resumers

Two flavors: generic (any wait can use, no api/ change required) and feature-owned (dedicated TypeScript that enforces invariants the generic primitives can’t).

Generic — available to every wait that doesn’t opt out:

  • workflow-resume agent tool — Sammy / any chat agent can discover and advance parked waits via workflow.listPendingWaits() + workflow.resume({namespace, lookupKey, output}). Configure the agent with allowedNamespaces: ['mything:phase', ...] so each agent is restricted to its own namespaces. The recommended path for agent-driven workflows. Waits must opt in to discovery with resume.pendingFor.
  • POST /orgs/{orgId}/workflows/wait-for-callback/resume — HTTP endpoint, org-scoped JWT. Same auth model: knowing (namespace, lookupKey) plus org JWT is sufficient. Useful for admin UIs, internal scripts, and non-agent callers.

Both refuse waits authored with resume.by: feature.

Feature-owned — dedicated resumers for the concierge stack:

  • POST /documenso/envelope-webhook — the single Documenso → Hutly receiver. Authenticates the inbound call against the Documenso webhook secret, emits the canonical PostHog envelope-lifecycle events, and (on DOCUMENT_SIGNED) resumes waits in the concierge:signing namespace, cross-checking recipient email against the stamped metadata.partyEmail before consuming the token.
  • concierge.completeOffer backend tool — resumes waits in the concierge:offer-decision namespace from inside the chat agent (authenticated via the chat ingress’s Hanko session); cross-checks metadata.offerCode against the agent’s request. The agent reads the corresponding parked wait via concierge.advance. No HTTP endpoint sits between the agent and the resume — the backend tool calls resumeWait directly.

A new external integration (a new payment processor, a new partner webhook with HMAC) requires adding a TypeScript handler — those invariants can’t ride on the generic primitives. An agent-driven workflow (a new Sammy flow that waits on the user) does not — the workflow-resume tool with an appropriately-scoped allowedNamespaces is all that’s needed.

When to Use

Use a Wait for Callback node when you need to:

  • Integrate with external systems that process asynchronously
  • Wait for third-party API webhooks
  • Receive payment processing results
  • Get data from external approval systems
  • Wait for manual data entry from external forms
  • Coordinate with systems that don’t have direct API integration

Configuration

Output Schema

Define the structure of data you expect to receive when the wait is resumed:

  • Add fields matching the resume payload structure
  • Supports all field types (text, number, boolean, object, array)
  • This schema validates incoming resume data before the workflow advances
  • Validated data becomes available in the node’s output at $.results.<nodeId>

Timeout Seconds

Optional SFN-level timeout. When set, an unfulfilled wait raises States.Timeout after this many seconds and routes through the workflow’s Parallel + Catch cleanup wrapper.

  • Unit: seconds. Integer.
  • Maximum: SFN’s STANDARD state machine ceiling (1 year). Production timeouts should be much shorter.
  • When unset, the wait can sit indefinitely up to the SFN ceiling — only safe for waits that have their own out-of-band TTL story.
  • Pair with onFailure to route the timeout to a recovery branch (e.g. noop to continue an offer loop, or a cleanup node for hard timeouts).

Resume Binding

Registers the wait against an opaque correlation key so a resumer can advance it without seeing the SFN task token. Required for the node to be advanceable — without this block, the SFN parks on a token that nothing can address.

  • namespace — stable scope for the lookup key (e.g. concierge:signing, payments:stripe-charge). Resumers read the same namespace.
  • lookupKey — composed at runtime via JSONata. Whatever value the resumer can compute from its own payload — Documenso’s recipient.token, an envelope-id, a String.format('{}::{}', envelopeId, partyId) composite. The :: separator is forbidden inside namespace (would alias rows) but permitted inside lookupKey.
  • metadata — forwarded to the resumer verbatim. Use for cross-check fields the resumer validates against the inbound payload (e.g. partyEmail so the handler can refuse a token resume if the wrong party signs).
  • by"generic" (default) or "feature". "generic" allows the wait to be advanced by the workflow-resume agent tool or the generic HTTP endpoint. "feature" refuses both — use it when a dedicated resumer enforces invariants the generic primitives can’t (HMAC verification, business-rule cross-checks). Concierge’s signing + offer-decision waits use "feature"; new agent-driven waits should leave it unset.
  • pendingFor — optional opt-in for discovery. JSONata-resolved to a user identifier (typically $.results.trigger.hankoUserId). When set, the wait Lambda server-composes ${organizationId}::${pendingFor} and stamps it on the row’s sparse GSI partition; workflow.listPendingWaits then surfaces this wait to an agent in the same org acting on the same user. Cross-org isolation is structural — the org prefix is server-trusted, not YAML-supplied. Omit for waits that should not appear in agent discovery (feature-owned waits typically leave this unset; their resumers know the lookupKey by other means).

How It Works

  1. Workflow pauses. Execution reaches the wait-for-callback state. SFN invokes the wait Lambda via the lambda:invoke.waitForTaskToken integration, passing $$.Task.Token in the input. Unlike a normal Lambda task, SFN does not advance when the Lambda returns — the state stays parked, holding the task token internally, until sendTaskSuccess(token, output) is called from somewhere.
  2. Token registered. The wait Lambda writes a row to the resume-tokens DynamoDB store keyed on (namespace, lookupKey). The row carries the SFN task token, the YAML’s metadata block, and the wait’s outputSchema. Server-stamped fields (organizationId, workflowId, workflowExecutionId, resumeBy) are added so resumers can cross-check identity and intent.
  3. Lambda returns. SFN treats this as “Lambda succeeded — keep the state parked.” The Lambda’s actual return value is discarded by the waitForTaskToken integration; it survives only in CloudWatch logs.
  4. External event. Some out-of-band thing happens — a third-party webhook fires, a user clicks a button in a frontend, an internal job completes.
  5. Resumer receives the event. A statically-defined endpoint (Documenso envelope webhook, generic resume endpoint, or a feature-owned handler) receives the inbound call, authenticates it, and derives (namespace, lookupKey) from the payload.
  6. Validation. The resume helper reads the row, validates the resume payload against the persisted output schema, and refuses on mismatch.
  7. Resume. sendTaskSuccess(token, output) is called against AWS. SFN unparks the state, places output at $.results.<nodeId>, and the workflow advances. The resume-tokens row is deleted.

Examples

Example: Document signed (feature-owned, third-party webhook)

Node ID: wait_for_signing

YAML:

wait_for_signing:
  name: wait_for_signing
  type: wait-for-callback
  timeoutSeconds: 604800   # 7 days
  resume:
    namespace: concierge:signing
    # Documenso's recipient.token IS the correlation key the webhook
    # delivers. The wait Lambda writes (namespace, recipientToken) → SFN
    # task token; the webhook handler resumes via the same key.
    lookupKey: $.results.resolve_documenso_recipient.recipientToken
    by: feature
    metadata:
      envelopeId: $.results.trigger.envelopeId
      partyEmail: $.results.trigger.partyEmail
  outputSchema:
    type: object
    required: [signedAt]
    properties:
      signedAt:
        type: string
  onSuccess: write_signed

Flow:

resolve_documenso_recipient (Tool Call) — produces recipientToken
  └─ wait_for_signing (Wait for Callback) — parks until DOCUMENT_SIGNED
     └─ write_signed (Tool Call) — records the signed event

The Documenso webhook handler at POST /documenso/envelope-webhook cross-checks the inbound recipient.email against metadata.partyEmail before resuming. resume.by: feature refuses the generic endpoint and workflow-resume tool — only the feature-owned handler can advance this wait.

Example: Frontend offer decision (feature-owned, in-app resumer)

Node ID: wait_for_offer_decision

YAML:

wait_for_offer_decision:
  name: wait_for_offer_decision
  type: wait-for-callback
  timeoutSeconds: 1800   # 30 minutes
  onFailure: noop_offer_timeout
  resume:
    namespace: concierge:offer-decision
    lookupKey: String.format('{}::{}', $.results.trigger.envelopeId, $.results.trigger.hankoUserId)
    by: feature
    metadata:
      offerCode: $.results.offer_loop.iterator.element.offer_code
  outputSchema:
    type: object
    required: [decision]
    properties:
      decision:
        type: string
        enum: [accept, decline]
  onSuccess: branch_on_decision

Flow:

present_offers (Execute Workflow) — resolves the offer to present
  └─ wait_for_offer_decision (Wait for Callback) — parks until user clicks
     └─ branch_on_decision (Conditional)
        ├─ accept → record_accepted → fulfil
        └─ decline → record_declined

The agent reads this parked wait via the concierge.advance backend tool (which polls the store at (concierge:offer-decision, envelopeId::hankoUserId)). The frontend derives the offer card from that result — no tool call renders it. When the user clicks accept/decline the frontend posts {offerCode, decision} to POST /me/concierge/callback/offer-decision, which cross-checks metadata.offerCode against the request to reject stale clicks and calls resumeWait. The concierge.completeOffer backend tool applies the same check and remains the resume path for a decision the party states in chat.

Example: Payment Processing

Node ID: waitForPayment

Output Schema:

  • transactionId (String) - Payment transaction ID
  • status (String) - Payment status (success/failed)
  • amount (Number) - Amount processed
  • timestamp (String) - When payment completed

Flow:

initiatePayment (Tool Call)
  └─ waitForPayment (Wait for Callback)
     └─ Conditional: checkPaymentStatus
        ├─ Success → fulfillOrder
        └─ Failed → refundCustomer

Configure resume keyed on the payment processor’s correlation ID (e.g. Stripe’s payment_intent.id), set by: feature, and resume from a dedicated Stripe webhook handler that verifies the Stripe signature before calling into the resume helper.

Access Data:

$.results.waitForPayment.transactionId
$.results.waitForPayment.status
$.results.waitForPayment.amount

Example: External Approval System

Node ID: externalApproval

Output Schema:

  • approved (Boolean) - Whether approved
  • approverId (String) - Who approved it
  • comments (String) - Approval comments
  • approvedAt (String) - Timestamp

Flow:

sendToApprovalSystem (Tool Call)
  └─ externalApproval (Wait for Callback)
     └─ Conditional: checkApproval
        ├─ Approved → processRequest
        └─ Rejected → notifySubmitter

For an internal approval system you control (or an agent-driven flow), leave resume.by unset (defaults to generic) and resume via the workflow-resume agent tool or the generic endpoint. For a third-party approval system with HMAC, set resume.by: feature and build a feature-specific webhook handler.

Example: Document Processing

Node ID: documentProcessing

Output Schema:

  • documentId (String) - Processed document ID
  • extractedData (Object) - Data extracted from document
    • name (String)
    • date (String)
    • amount (Number)
  • confidence (Number) - Confidence score (0-1)
  • errors (Array) - Any processing errors

Flow:

uploadDocument (Tool Call)
  └─ documentProcessing (Wait for Callback)
     └─ Conditional: checkConfidence
        ├─ High Confidence → saveToDatabase
        └─ Low Confidence → manualReview

Example: Third-Party API Webhook

Node ID: stripeWebhook

Output Schema:

  • eventType (String) - Stripe event type
  • customerId (String) - Customer ID
  • subscriptionId (String) - Subscription ID
  • status (String) - Subscription status
  • currentPeriodEnd (String) - Billing period end

Flow:

createStripeSubscription (Tool Call)
  └─ stripeWebhook (Wait for Callback)
     └─ Conditional: checkEvent
        ├─ subscription.created → activateAccount
        ├─ subscription.updated → updateAccount
        └─ subscription.deleted → deactivateAccount

Example: Long-Running Job

Node ID: batchJob

Output Schema:

  • jobId (String) - Job identifier
  • status (String) - Job status
  • processedCount (Number) - Items processed
  • failedCount (Number) - Items failed
  • results (Array) - Processing results
  • completedAt (String) - Completion timestamp

Flow:

startBatchJob (Tool Call)
  └─ batchJob (Wait for Callback)
     └─ Function: summarizeResults
        └─ sendCompletionEmail

Resume Surfaces

A wait with a resume block is advanced by one of three surfaces. Pick based on who needs to drive the resume.

workflow-resume agent tool (generic, default)

Attach the workflow-resume configurable tool to an agent with an allowedNamespaces list. The attachment provisions two tools on the agent:

  • workflow.listPendingWaits() — returns the resume-tokens parked for the user this conversation is bound to (those authored with resume.pendingFor and within the agent’s allowedNamespaces). No params; the org + user scope come from the chat ingress, never the agent. Use this when the conversation has reached a point that may need to advance a parked workflow — the agent gets back (namespace, lookupKey, metadata) for each pending wait and picks the right one by inspecting metadata. The sfnToken is never returned to the agent.
  • workflow.resume({namespace, lookupKey, output}) — advances the chosen wait. Validates the namespace against the agent’s allow-list, cross-checks the org, validates output against the wait’s outputSchema, then resumes. Refuses waits authored with resume.by: feature.

The intended flow is discover, then resume: the agent does not compose lookupKey from session context — it discovers the wait and passes the discovered (namespace, lookupKey) straight to resume. This is the path for any new agent-driven wait — no api/ change required.

For a wait to surface in listPendingWaits, the YAML must opt in via resume.pendingFor.

Generic HTTP endpoint (generic, default)

POST /orgs/{organizationId}/workflows/wait-for-callback/resume (auth: org-scoped JWT). Same shape as the agent tool, exposed as HTTP for admin UIs, internal scripts, and non-agent callers. Refuses waits authored with resume.by: feature.

Body:

{
  "namespace": "mything:phase",
  "lookupKey": "...",
  "output": {
    "...": "..."
  }
}

Validation failure returns 400 and preserves the row so the caller can retry with the correct shape.

Feature-owned resumer (resume.by: feature)

For waits that must enforce invariants the generic surfaces can’t — HMAC verification, identity binding, business-rule cross-checks — build either a dedicated HTTP endpoint or a backend tool that calls into the shared resumeWait helper. The Documenso envelope webhook (POST /documenso/envelope-webhook) is an HTTP example; the concierge.completeOffer backend tool is an in-agent example — each verifies its own auth and cross-checks metadata fields against the inbound payload before resuming. The YAML must declare resume.by: feature so the generic surfaces refuse.

Accessing Resume Data

After the wait is resumed:

// Access all callback data
$.results.<nodeId>

// Access specific fields
$.results.<nodeId>.fieldName

// Examples:
$.results.waitForPayment.transactionId
$.results.externalApproval.approved
$.results.documentProcessing.extractedData.name

Best Practices

  • Define a clear output schema. The schema validates the resume payload at the boundary; without it, a malformed resume can corrupt $.results.<nodeId> for downstream nodes.
  • Always set timeoutSeconds. Pair it with an onFailure route. Without a timeout the wait can sit up to SFN’s 1-year ceiling.
  • Pick a stable lookupKey the resumer can derive on its own. Whatever the third-party webhook sends in its payload (recipient.token, payment_intent.id, transfer_id) is the natural choice. Composite keys (String.format('{}::{}', a, b)) are fine inside lookupKey; :: is reserved as a separator inside namespace.
  • Stamp business identifiers into metadata. Resumers cross-check these (e.g. partyEmail, offerCode) against the inbound payload before consuming the token.
  • Leave resume.by unset for agent-driven waits. The default (generic) lets the workflow-resume tool advance them. Only set feature when a dedicated resumer enforces invariants the generic surfaces can’t.
  • Build feature-owned resumers when invariants matter. HMAC verification, identity binding, and stale-event detection belong in a dedicated handler — and pair them with resume.by: feature so the generic surfaces refuse.

Security Considerations

  • Bearer task tokens never leave the server. SFN task tokens are bearer credentials. The wait Lambda writes them to the resume-tokens store and only the resume helper reads them; nothing exposes a task token over the wire.
  • Validate every inbound resume against the persisted outputSchema. The resume helper enforces this before sendTaskSuccess.
  • Cross-check metadata against the inbound payload. A webhook can deliver a resume for the right correlation key but the wrong party — feature endpoints validate metadata.partyEmail, metadata.offerCode, etc. before consuming the token.
  • HTTPS only on resume endpoints. Combine with HMAC for third-party webhooks (Documenso, Stripe) and signed JWT for in-app clients.
  • Rate-limit resume endpoints as part of the standard endpoint posture.

Common Patterns

Wait for Multiple Webhooks

Parallel: waitForMultiple
├─ Branch 1: waitForPayment
├─ Branch 2: waitForShipping
└─ Branch 3: waitForInventory

All three callbacks must be received before proceeding.

Conditional Processing Based on Callback

waitForWebhook
└─ Conditional: checkEventType
   ├─ Choice 1: eventType = "success" → processSuccess
   ├─ Choice 2: eventType = "failed" → handleFailure
   └─ Default → logUnknownEvent

Retry with Timeout

initiateExternalProcess
└─ waitForCallback
   onFailure: retryProcess

Callback with Data Transformation

waitForData
└─ Function: transformCallbackData
   └─ validateTransformedData
      └─ saveToDatabase

Limitations

  • Workflow pauses: Execution halts until the wait is resumed (or timed out).
  • Single resume: Each wait accepts one resume. Subsequent calls hit TaskAlreadyConsumed and are swallowed by the resume helper.
  • Schema required: An output schema is required and is enforced on every resume.
  • External dependency: The wait relies on a resumer firing — if it never fires, the wait sits until timeout.

Error Handling

Resume not received

When a wait isn’t resumed before its timeoutSeconds, SFN raises States.Timeout and routes through the onFailure branch (or the workflow’s outer Parallel + Catch cleanup if onFailure is unset).

  • Use onFailure to route the timeout to a recovery branch (e.g. a noop to continue an iteration loop, or a cleanup node for hard timeouts).
  • Always set timeoutSeconds — without it, an absent resumer pins the wait up to the SFN ceiling.

Invalid resume payload

When the resume payload fails outputSchema validation, the resume helper throws ResumeOutputValidationError before sendTaskSuccess fires. The resume-tokens row is preserved so the resumer can retry with the corrected shape; the generic resume endpoint surfaces this as 400.

Example error handling

waitForCallback
  onSuccess: processData
  onFailure: handleTimeout
    └─ Function: logTimeout
       └─ Conditional: checkRetryPolicy
          ├─ retriesRemaining > 0 → reissueRequest
          └─ otherwise → notifyOps

Debugging

To debug wait-for-callback issues:

  1. Inspect the SFN execution. Look for the wait state’s status and any States.Timeout entries in the execution history.
  2. Inspect the resume-tokens row. The row at (namespace, lookupKey) should exist while the wait is parked and be deleted on resume. A row that lingers past a known resume is a stuck wait.
  3. Check resumer logs. Feature endpoints log skipped resumes (no row, identity mismatch, schema mismatch). The generic resume endpoint surfaces the same in HTTP responses.
  4. Verify the YAML. lookupKey is a JSONata expression — confirm it resolves to the value the resumer actually delivers. The namespace must match what the resumer reads.
  5. Check metadata cross-checks. A 403 from a feature endpoint usually means the resumer’s payload didn’t match a stamped metadata field (e.g. wrong partyEmail).