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 + receiver | Direct delivery | |
|---|---|---|
| Public endpoint to protect | Yes (Function URL) | None |
| Receiver Lambda to maintain | Yes | None |
| Webhook auth/signing | Your problem | IAM/SigV4 is the auth |
| SSRF / replay surface | Exists | Removed (no URL; a consumed callback ID can’t be resumed twice) |
| Setup | Deploy a receiver | One 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- Your durable workflow calls
wait_for_callback— the SDK hands your submitter a one-time callback ID. - The submitter POSTs to
/cargo/loadwith a structuredcallbackobject naming your connection and the callback ID. Nocallback_url. - Convoy batches and processes the request (minutes to hours).
- 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:
- 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).
- Generate credentials — the wizard registers the connection and hands
you the
connection_id(awsconn_…), the one-timeexternal_id(cnvyext_…), the one-time linked API key, Convoy’s delivery account ID, and a ready-to-pasteterraform.tfvarsblock.
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-workflowTerraform:
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 usualRules:
callbackandcallback_urlare mutually exclusive — pick one (or neither, for mailbox-only retrieval).- The
connection_idmust belong to your project and beactive. - The
callback_idis 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
| Knob | Guidance |
|---|---|
waitForCallback timeout | Always set one. Recommend ≥ 26 hours: Convoy’s completion window is up to 24h, plus delivery-retry headroom. |
| Too-short timeout | If 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 condition | What your workflow sees |
|---|---|
| Cargo completed | waitForCallback returns the envelope |
| Cargo failed (model/provider error) | CallbackError (ConvoyCargoFailed) |
| Callback timed out before delivery | Your own Timeout error (size your durable timeout ≥ 26h) |
| Bad/consumed callback ID | Nothing — check the ID you submitted |
| Role deleted / trust policy changed | Nothing resumes; the connection is auto-flagged failed — re-verify after fixing IAM |
| AWS throttling / transient errors | Delivery 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 / inspect | View your connections and their verified capabilities (the external_id is never shown again) |
| Re-verify | Re-run the verification probe (e.g. after fixing IAM) |
| Rotate key | Rotate the connection’s linked API key (new key shown once) |
| Revoke | In-flight cargo referencing the connection fails delivery with connection_revoked; linked keys are deactivated |
Next Steps
- AWS Lambda Durable Functions — the webhook + receiver variant (works without IAM setup)
- Get Result — the mailbox endpoint oversize pointers link to
- Load Cargo — full request reference