Skip to Content
IntegrationsDirect AWS Durable Callbacks

Direct AWS Durable Callbacks

Let Convoy resume your AWS Lambda durable function directly — no webhook endpoint, no receiver Lambda, no signing scheme. Convoy assumes a narrowly-scoped IAM role in your account and calls SendDurableExecutionCallbackSuccess / Failure itself when your batch finishes.

This is the direct delivery (Tier 2) variant of the AWS Lambda Durable Functions integration. If you’d rather keep delivery on plain webhooks (works everywhere, no IAM setup), use that guide instead — both resume the same durable workflow.


Why direct delivery

Compared to the webhook + receiver pattern:

Webhook + receiverDirect delivery
Public endpoint to protectYes (Function URL)None
Receiver Lambda to maintainYesNone
Webhook auth/signingYour problemIAM/SigV4 is the auth
SSRF / replay surfaceExistsRemoved (no URL; a consumed callback ID can’t be resumed twice)
SetupDeploy a receiverOne IAM role + one API call

The security model: you create an IAM role that trusts Convoy’s delivery account only when the caller presents your connection’s unique ExternalId, and grants only the three lambda:SendDurableExecutionCallback* actions on only your durable function. Convoy can resume your workflow and do literally nothing else in your account.


How it works

┌────────────────────────┐ 1. wait_for_callback(submitter) │ Durable workflow │──────────────────────────────────┐ │ Lambda │ │ │ (suspended, $0) │ 2. POST /cargo/load ▼ └───────────▲────────────┘ callback: {connection_id, ┌─────────────┐ │ callback_id} │ Convoy API │ │ └──────┬──────┘ │ 4. AssumeRole(your role, ExternalId) │ │ SendDurableExecutionCallbackSuccess │ 3. batch │ (callback_id, result envelope) │ processing └──────────────────────────── Convoy delivery ◀───────┘ worker
  1. Your durable workflow calls wait_for_callback — the SDK hands your submitter a one-time callback ID.
  2. The submitter POSTs to /cargo/load with a structured callback object naming your connection and the callback ID. No callback_url.
  3. Convoy batches and processes the request (minutes to hours).
  4. Convoy’s delivery worker assumes your role (with your connection’s ExternalId + a session policy restricted to the three callback actions) and calls the Lambda callback API — your workflow resumes with the result.

Setup

Register a connection (dashboard)

In the dashboard, open your project’s AWS tab and launch the Connect AWS wizard:

  1. Choose your target — pick the durable callback capability, your region, your 12-digit AWS account ID, and the IAM role name (the wizard composes the predicted role ARN from these — the role doesn’t need to exist yet). Leave “Also generate a project API key” checked: the key the wizard mints is linked to this connection, which is what deliveries through the connection require (see Scoped keys below).
  2. Generate credentials — the wizard registers the connection and hands you the connection_id (awsconn_…), the one-time external_id (cnvyext_…), the one-time linked API key, Convoy’s delivery account ID, and a ready-to-paste terraform.tfvars block.

Treat the external_id as a secret. It goes into your role’s trust policy and nowhere else. It cannot be retrieved again — if you lose it, revoke the connection and create a new one.

Create the IAM role

The role trusts Convoy’s delivery account conditioned on your ExternalId, and grants only the callback actions on only your durable function.

The wizard’s final step offers a one-click Launch Stack button pre-filled with your capability, target ARN, delivery account ID, and ExternalId — or manual IAM console steps with generated policies. Note the pre-filled ExternalId appears in your browser URL/history — use the manual path or the CLI below if that matters in your environment. The same standalone CloudFormation template lives in the examples repo at examples/aws-durable-direct/convoy-durable-callback-role.json — deploy it with one command:

aws cloudformation deploy \ --template-file convoy-durable-callback-role.json \ --stack-name convoy-durable-callback \ --capabilities CAPABILITY_NAMED_IAM \ --parameter-overrides \ ConvoyDeliveryAccountId=<from these docs> \ ConvoyExternalId=<from step 1> \ DurableFunctionArn=arn:aws:lambda:us-east-1:123456789012:function:my-batch-workflow

Terraform:

variable "convoy_delivery_account_id" { # Published in Convoy docs; enterprise self-hosted deployments # substitute their own worker account. type = string } variable "convoy_external_id" { description = "external_id from the Connect AWS wizard — treat as a secret" type = string sensitive = true } resource "aws_iam_role" "convoy_durable_callback" { name = "convoy-durable-callback" assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [{ Effect = "Allow" Principal = { AWS = "arn:aws:iam::${var.convoy_delivery_account_id}:root" } Action = "sts:AssumeRole" Condition = { StringEquals = { "sts:ExternalId" = var.convoy_external_id } } }] }) } resource "aws_iam_role_policy" "send_callbacks" { name = "send-durable-callbacks" role = aws_iam_role.convoy_durable_callback.id policy = jsonencode({ Version = "2012-10-17" Statement = [{ Sid = "SendCallbacks" Effect = "Allow" Action = [ "lambda:SendDurableExecutionCallbackSuccess", "lambda:SendDurableExecutionCallbackFailure", "lambda:SendDurableExecutionCallbackHeartbeat" ] # Scope to exactly the durable function(s) Convoy should resume. Resource = "arn:aws:lambda:us-east-1:123456789012:function:my-batch-workflow:*" }] }) }

CloudFormation:

Parameters: ConvoyDeliveryAccountId: Type: String ConvoyExternalId: Type: String NoEcho: true Resources: ConvoyDurableCallbackRole: Type: AWS::IAM::Role Properties: RoleName: convoy-durable-callback AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Principal: AWS: !Sub "arn:aws:iam::${ConvoyDeliveryAccountId}:root" Action: sts:AssumeRole Condition: StringEquals: sts:ExternalId: !Ref ConvoyExternalId Policies: - PolicyName: send-durable-callbacks PolicyDocument: Version: "2012-10-17" Statement: - Sid: SendCallbacks Effect: Allow Action: - lambda:SendDurableExecutionCallbackSuccess - lambda:SendDurableExecutionCallbackFailure - lambda:SendDurableExecutionCallbackHeartbeat Resource: !Sub "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:my-batch-workflow:*"

Verify the connection

Once the role exists, return to the wizard (or open the connection from the AWS tab) and click Verify.

Convoy assumes the role with your ExternalId, then runs a dry-run permission probe (a heartbeat against a nonexistent callback ID — the expected ResourceNotFound proves the permission grant without needing a live execution). The dashboard runs the probe for the capability you chose; the token-addressed probes (durable, sfn_token) are side-effect-free, while the ARN-addressed probes (lambda_invoke, eventbridge, sqs, kinesis, sfn_start, s3) take the target ARN and — except for lambda_invoke’s DryRun — create one real, clearly-marked test artifact (rate-capped at 1/minute per connection). On success the connection becomes active.

If a verify fails with an access-denied detail right after you attached the policy, it’s usually IAM propagation delay — wait ~60 seconds and run Verify again.


Submitting cargo

Instead of callback_url (or metadata gymnastics), pass a structured callback object. Your submitter inside the durable workflow:

def submit_cargo(callback_id: str, ctx) -> None: body = json.dumps({ "params": { "model": "claude-haiku-4-5", "max_tokens": 1024, "messages": [{"role": "user", "content": prompt}], }, "callback": { "type": "aws_durable_callback", "connection_id": os.environ["CONVOY_AWS_CONNECTION_ID"], "callback_id": callback_id, }, }).encode() # POST to https://api.cnvy.ai/cargo/load with your X-API-Key as usual

Rules:

  • callback and callback_url are mutually exclusive — pick one (or neither, for mailbox-only retrieval).
  • The connection_id must belong to your project and be active.
  • The callback_id is treated as an opaque capability token: Convoy encrypts it at rest and never logs it.

There is no receiver Lambda — delete it from your stack. Your durable workflow’s wait_for_callback returns the envelope below directly.

Optional: liveness heartbeats

Add "heartbeat": true to the callback object and Convoy will call SendDurableExecutionCallbackHeartbeat roughly every 5 minutes while your cargo is in flight. This gives your durable function liveness, not just completion: pair it with a heartbeatTimeout on your waitForCallback and a lost cargo trips the heartbeat timeout quickly instead of waiting out your full callback timeout.

"callback": { "type": "aws_durable_callback", "connection_id": os.environ["CONVOY_AWS_CONNECTION_ID"], "callback_id": callback_id, "heartbeat": True, },

Set your heartbeatTimeout to at least 15 minutes — comfortably above the 5-minute cadence — so a single missed sweep (worker deploy, transient AWS issue) never falsely times out your workflow. Heartbeats are off by default (each one costs an extra cross-account API call per cargo per interval).


The result envelope

On success, your workflow resumes with a JSON envelope as its waitForCallback result:

{ "cargo_id": "cargo_a1b2c3...", "success": true, "response": { "content": [...], "usage": {...} }, "response_truncated": false, "result_url": "https://api.cnvy.ai/cargo/cargo_a1b2c3.../result", "metadata": { "your": "metadata" } }

AWS caps the durable callback Result at 256 KB. When the serialized envelope would exceed ~240 KB, Convoy sends a pointer instead:

{ "cargo_id": "cargo_a1b2c3...", "success": true, "response": null, "response_truncated": true, "result_url": "https://api.cnvy.ai/cargo/cargo_a1b2c3.../result", "metadata": null }

Your workflow’s next step fetches GET /cargo/{cargo_id}/result with your project API key — the body is the full result.

Data received via waitForCallback originates from an external system — validate before processing. At minimum, check cargo_id matches what your submitter recorded, and treat response as untrusted model output.

On failure, your workflow’s wait_for_callback raises a CallbackError with ErrorType: ConvoyCargoFailed and a short error message. Full error detail is retrievable from the result endpoint.


Timeouts

KnobGuidance
waitForCallback timeoutAlways set one. Recommend ≥ 26 hours: Convoy’s completion window is up to 24h, plus delivery-retry headroom.
Too-short timeoutIf your durable timeout elapses before Convoy delivers, your workflow gets its own Timeout error and Convoy’s later delivery lands on an expired callback (recorded as callback_expired on Convoy’s side — not an error, just a sizing mismatch).

Failure semantics

Convoy conditionWhat your workflow sees
Cargo completedwaitForCallback returns the envelope
Cargo failed (model/provider error)CallbackError (ConvoyCargoFailed)
Callback timed out before deliveryYour own Timeout error (size your durable timeout ≥ 26h)
Bad/consumed callback IDNothing — check the ID you submitted
Role deleted / trust policy changedNothing resumes; the connection is auto-flagged failed — re-verify after fixing IAM
AWS throttling / transient errorsDelivery retries with backoff (up to 5 attempts — 1, 3, 9, then 27 minutes between attempts, ~40 minutes end to end); resume is slightly delayed

Scoped keys and key rotation

Connections are created with require_scoped_key: true by default: cargo submitted through the connection (naming it in a callback object) must authenticate with an API key linked to that connection — a general project key is rejected with 403 scoped_key_required. The wizard’s “Also generate a project API key” option mints exactly this linked key.

To rotate it, open the connection in the dashboard and use Rotate key: every active linked key is deactivated and one fresh linked key is minted — shown once. The cutover is immediate, so update your stored secret (e.g. Secrets Manager) right away. Revoking the connection also deactivates its linked keys.


Managing connections

All of these are available from the connection’s page in the dashboard:

List / inspectView your connections and their verified capabilities (the external_id is never shown again)
Re-verifyRe-run the verification probe (e.g. after fixing IAM)
Rotate keyRotate the connection’s linked API key (new key shown once)
RevokeIn-flight cargo referencing the connection fails delivery with connection_revoked; linked keys are deactivated

Next Steps

Last updated on