Skip to Content
IntegrationsAWS Lambda

AWS Lambda

Deploy serverless functions that submit prompts to Convoy and receive results via callbacks — no servers to manage, scales to zero when idle, and handles bursty workloads automatically.

Testing tip — Build and verify this integration end-to-end with convoy-mock first. It’s a free, synthetic model that returns a callback in ~60 seconds and is never billed. Once your wiring works, swap "model": "convoy-mock" for a production model like claude-3-haiku.

This guide covers two Lambda functions: one to submit prompts to Convoy, and one to receive callback results. Together they form a complete serverless AI pipeline.


Architecture

┌─────────────┐ ┌──────────────────┐ ┌─────────────┐ │ Trigger │────▶│ Submit Lambda │────▶│ Convoy API │ │ (API GW, │ │ POST /cargo/load│ │ │ │ SQS, S3) │ └──────────────────┘ └──────┬──────┘ └─────────────┘ │ │ callback ┌──────────────────┐ │ │ Receiver Lambda │◀─────────────┘ │ (Function URL │ │ or API GW) │ └───────┬──────────┘ ┌───────▼──────────┐ │ Destination │ │ (DynamoDB, S3, │ │ SQS, SNS) │ └──────────────────┘

Prerequisites

  • AWS account with Lambda access
  • Node.js 20+ or Python 3.12+ runtime
  • Convoy API key (convoy_sk_...)
  • API key stored in AWS Secrets Manager or SSM Parameter Store

Option 1: Node.js (TypeScript)

Submit Lambda

This function submits a prompt to Convoy and returns the cargo_id:

// submit-to-convoy/index.ts import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager'; const secrets = new SecretsManagerClient({}); interface ConvoyEvent { prompt: string; model?: string; max_tokens?: number; system?: string; temperature?: number; metadata?: Record<string, string>; } interface ConvoyLoadResponse { cargo_id: string; status: string; } let cachedApiKey: string | null = null; async function getApiKey(): Promise<string> { if (cachedApiKey) return cachedApiKey; const result = await secrets.send( new GetSecretValueCommand({ SecretId: 'convoy/api-key' }) ); cachedApiKey = result.SecretString!; return cachedApiKey; } export const handler = async (event: ConvoyEvent): Promise<ConvoyLoadResponse> => { const apiKey = await getApiKey(); const callbackUrl = process.env.CALLBACK_URL!; const response = await fetch('https://api.cnvy.ai/cargo/load', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey, }, body: JSON.stringify({ params: { model: event.model ?? 'claude-3-haiku', max_tokens: event.max_tokens ?? 1024, messages: [{ role: 'user', content: event.prompt }], ...(event.system && { system: event.system }), ...(event.temperature !== undefined && { temperature: event.temperature }), }, callback_url: callbackUrl, ...(event.metadata && { metadata: event.metadata }), }), }); if (!response.ok) { const error = await response.text(); throw new Error(`Convoy API error ${response.status}: ${error}`); } const data = await response.json() as ConvoyLoadResponse; console.log(`Submitted cargo: ${data.cargo_id}`); return data; };

Receiver Lambda

This function receives Convoy’s callback and stores the result:

// receive-convoy-result/index.ts import { DynamoDBClient, PutItemCommand } from '@aws-sdk/client-dynamodb'; const dynamo = new DynamoDBClient({}); const TABLE_NAME = process.env.RESULTS_TABLE!; interface ConvoyCallback { cargo_id: string; status: string; success: boolean; response?: { content: Array<{ type: string; text: string }>; model: string; usage: { input_tokens: number; output_tokens: number }; }; error?: string; metadata?: Record<string, string>; } export const handler = async (event: { body: string; headers: Record<string, string>; }): Promise<{ statusCode: number; body: string }> => { let payload: ConvoyCallback; try { payload = JSON.parse(event.body); } catch { return { statusCode: 400, body: 'Invalid JSON' }; } console.log(`Received callback for cargo: ${payload.cargo_id}, success: ${payload.success}`); // Store result in DynamoDB await dynamo.send( new PutItemCommand({ TableName: TABLE_NAME, Item: { cargo_id: { S: payload.cargo_id }, status: { S: payload.status }, success: { BOOL: payload.success }, result_text: { S: payload.success ? payload.response!.content[0].text : payload.error ?? 'Unknown error', }, model: { S: payload.response?.model ?? 'unknown' }, input_tokens: { N: String(payload.response?.usage?.input_tokens ?? 0) }, output_tokens: { N: String(payload.response?.usage?.output_tokens ?? 0) }, received_at: { S: new Date().toISOString() }, ...(payload.metadata && { metadata: { S: JSON.stringify(payload.metadata) }, }), }, }) ); return { statusCode: 200, body: JSON.stringify({ received: true }) }; };

Option 2: Python

Submit Lambda

# submit_to_convoy/handler.py import json import os import urllib.request import boto3 secrets_client = boto3.client('secretsmanager') _cached_key = None def get_api_key(): global _cached_key if _cached_key: return _cached_key response = secrets_client.get_secret_value(SecretId='convoy/api-key') _cached_key = response['SecretString'] return _cached_key def handler(event, context): api_key = get_api_key() callback_url = os.environ['CALLBACK_URL'] body = { 'params': { 'model': event.get('model', 'claude-3-haiku'), 'max_tokens': event.get('max_tokens', 1024), 'messages': [{'role': 'user', 'content': event['prompt']}], }, 'callback_url': callback_url, } if 'system' in event: body['params']['system'] = event['system'] if 'temperature' in event: body['params']['temperature'] = event['temperature'] if 'metadata' in event: body['metadata'] = event['metadata'] req = urllib.request.Request( 'https://api.cnvy.ai/cargo/load', data=json.dumps(body).encode(), headers={ 'Content-Type': 'application/json', 'X-API-Key': api_key, }, method='POST', ) with urllib.request.urlopen(req) as resp: data = json.loads(resp.read()) print(f"Submitted cargo: {data['cargo_id']}") return data

Receiver Lambda

# receive_convoy_result/handler.py import json import os from datetime import datetime, timezone import boto3 dynamodb = boto3.resource('dynamodb') table = dynamodb.Table(os.environ['RESULTS_TABLE']) def handler(event, context): try: payload = json.loads(event['body']) except (KeyError, json.JSONDecodeError): return {'statusCode': 400, 'body': 'Invalid JSON'} cargo_id = payload['cargo_id'] success = payload.get('success', False) print(f"Received callback for cargo: {cargo_id}, success: {success}") item = { 'cargo_id': cargo_id, 'status': payload.get('status', 'unknown'), 'success': success, 'received_at': datetime.now(timezone.utc).isoformat(), } if success and payload.get('response'): item['result_text'] = payload['response']['content'][0]['text'] item['model'] = payload['response'].get('model', 'unknown') item['input_tokens'] = payload['response']['usage']['input_tokens'] item['output_tokens'] = payload['response']['usage']['output_tokens'] else: item['result_text'] = payload.get('error', 'Unknown error') if payload.get('metadata'): item['metadata'] = json.dumps(payload['metadata']) table.put_item(Item=item) return {'statusCode': 200, 'body': json.dumps({'received': True})}

Infrastructure as Code (CDK)

Deploy both functions with AWS CDK:

// lib/convoy-pipeline-stack.ts import * as cdk from 'aws-cdk-lib'; import * as lambda from 'aws-cdk-lib/aws-lambda'; import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; import * as apigateway from 'aws-cdk-lib/aws-apigateway'; import { Construct } from 'constructs'; export class ConvoyPipelineStack extends cdk.Stack { constructor(scope: Construct, id: string, props?: cdk.StackProps) { super(scope, id, props); // DynamoDB table for results const resultsTable = new dynamodb.Table(this, 'ConvoyResults', { partitionKey: { name: 'cargo_id', type: dynamodb.AttributeType.STRING }, billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, timeToLiveAttribute: 'ttl', removalPolicy: cdk.RemovalPolicy.DESTROY, }); // Reference existing secret const apiKeySecret = secretsmanager.Secret.fromSecretNameV2( this, 'ConvoyApiKey', 'convoy/api-key' ); // Receiver Lambda (needs to be created first for the callback URL) const receiverFn = new lambda.Function(this, 'ConvoyReceiver', { runtime: lambda.Runtime.NODEJS_20_X, handler: 'index.handler', code: lambda.Code.fromAsset('lambda/receive-convoy-result'), environment: { RESULTS_TABLE: resultsTable.tableName, }, timeout: cdk.Duration.seconds(10), memorySize: 256, }); resultsTable.grantWriteData(receiverFn); // API Gateway for the receiver (callback endpoint) const api = new apigateway.RestApi(this, 'ConvoyCallbackApi', { restApiName: 'Convoy Callback', description: 'Receives Convoy AI result callbacks', }); const webhookResource = api.root.addResource('webhook').addResource('convoy'); webhookResource.addMethod('POST', new apigateway.LambdaIntegration(receiverFn)); // Submit Lambda const submitFn = new lambda.Function(this, 'ConvoySubmit', { runtime: lambda.Runtime.NODEJS_20_X, handler: 'index.handler', code: lambda.Code.fromAsset('lambda/submit-to-convoy'), environment: { CALLBACK_URL: `${api.url}webhook/convoy`, }, timeout: cdk.Duration.seconds(30), memorySize: 256, }); apiKeySecret.grantRead(submitFn); // Outputs new cdk.CfnOutput(this, 'CallbackUrl', { value: `${api.url}webhook/convoy`, description: 'URL to use as Convoy callback_url', }); new cdk.CfnOutput(this, 'SubmitFunctionName', { value: submitFn.functionName, description: 'Lambda function to invoke for submitting prompts', }); } }

Deploy:

npx cdk deploy ConvoyPipelineStack

Trigger Patterns

API Gateway Trigger (HTTP endpoint)

Expose the submit function as an HTTP API:

// Add to CDK stack const httpApi = new apigateway.RestApi(this, 'ConvoySubmitApi'); const submitResource = httpApi.root.addResource('submit'); submitResource.addMethod('POST', new apigateway.LambdaIntegration(submitFn));

SQS Trigger (Queue-based)

Process prompts from a queue for decoupled architectures:

// submit-from-sqs/index.ts import type { SQSEvent } from 'aws-lambda'; export const handler = async (event: SQSEvent) => { const apiKey = await getApiKey(); const callbackUrl = process.env.CALLBACK_URL!; const results = await Promise.allSettled( event.Records.map(async (record) => { const { prompt, model, system } = JSON.parse(record.body); const response = await fetch('https://api.cnvy.ai/cargo/load', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey, }, body: JSON.stringify({ params: { model: model ?? 'claude-3-haiku', max_tokens: 1024, messages: [{ role: 'user', content: prompt }], ...(system && { system }), }, callback_url: callbackUrl, metadata: { sqs_message_id: record.messageId }, }), }); if (!response.ok) { throw new Error(`Convoy API error: ${response.status}`); } return response.json(); }) ); // Log failures (messages will be retried or sent to DLQ) const failures = results.filter((r) => r.status === 'rejected'); if (failures.length > 0) { console.error(`${failures.length}/${event.Records.length} submissions failed`); } };

S3 Trigger (File upload)

Process uploaded files (e.g., summarize documents):

// process-s3-upload/index.ts import type { S3Event } from 'aws-lambda'; import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3'; const s3 = new S3Client({}); export const handler = async (event: S3Event) => { const apiKey = await getApiKey(); const callbackUrl = process.env.CALLBACK_URL!; for (const record of event.Records) { const bucket = record.s3.bucket.name; const key = decodeURIComponent(record.s3.object.key.replace(/\+/g, ' ')); // Read the uploaded file const obj = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key })); const content = await obj.Body!.transformToString(); // Submit to Convoy for summarization const response = await fetch('https://api.cnvy.ai/cargo/load', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey, }, body: JSON.stringify({ params: { model: 'claude-3-sonnet', max_tokens: 2048, messages: [{ role: 'user', content: `Summarize this document:\n\n${content}` }], system: 'You are a document summarizer. Provide a concise summary with key points.', }, callback_url: callbackUrl, metadata: { source_bucket: bucket, source_key: key }, }), }); if (!response.ok) { console.error(`Failed to submit ${key}: ${response.status}`); } else { const { cargo_id } = await response.json(); console.log(`Submitted ${key} → cargo: ${cargo_id}`); } } };

EventBridge Schedule (Cron)

Run AI tasks on a schedule:

// In CDK stack: import * as events from 'aws-cdk-lib/aws-events'; import * as targets from 'aws-cdk-lib/aws-events-targets'; // Run every day at 9 AM UTC new events.Rule(this, 'DailyDigest', { schedule: events.Schedule.cron({ hour: '9', minute: '0' }), targets: [new targets.LambdaFunction(submitFn, { event: events.RuleTargetInput.fromObject({ prompt: 'Generate today\'s AI industry news digest based on recent developments.', model: 'claude-3-sonnet', system: 'You are a tech journalist. Write a brief daily digest of AI news.', }), })], });

Environment Variables

VariableDescriptionExample
CALLBACK_URLURL where Convoy sends resultshttps://abc123.execute-api.us-east-1.amazonaws.com/prod/webhook/convoy
RESULTS_TABLEDynamoDB table name for storing resultsConvoyPipelineStack-ConvoyResults

Store your Convoy API key in AWS Secrets Manager or SSM Parameter Store — never in environment variables or source code. Lambda environment variables are visible in the AWS Console.


Using Lambda Function URLs (Simpler Alternative)

If you don’t need API Gateway features, use Lambda Function URLs for the callback:

// In CDK stack const receiverFn = new lambda.Function(this, 'ConvoyReceiver', { // ... same as above }); const fnUrl = receiverFn.addFunctionUrl({ authType: lambda.FunctionUrlAuthType.NONE, // Convoy needs public access cors: { allowedOrigins: ['*'], allowedMethods: [lambda.HttpMethod.POST], }, }); new cdk.CfnOutput(this, 'CallbackUrl', { value: fnUrl.url, });

Function URLs are free and simpler than API Gateway. Use them when you don’t need custom domains, request validation, or usage plans.


Error Handling & Retries

Dead Letter Queue for Failed Submissions

// In CDK stack import * as sqs from 'aws-cdk-lib/aws-sqs'; const dlq = new sqs.Queue(this, 'ConvoySubmitDLQ', { retentionPeriod: cdk.Duration.days(14), }); const submitFn = new lambda.Function(this, 'ConvoySubmit', { // ... config deadLetterQueue: dlq, retryAttempts: 2, });

Idempotent Receiver

Convoy may retry callbacks. Make your receiver idempotent:

// Use DynamoDB conditional writes to prevent duplicates await dynamo.send( new PutItemCommand({ TableName: TABLE_NAME, Item: { /* ... */ }, ConditionExpression: 'attribute_not_exists(cargo_id)', }) ).catch((err) => { if (err.name === 'ConditionalCheckFailedException') { console.log(`Duplicate callback for ${payload.cargo_id}, skipping`); return; } throw err; });

Monitoring

CloudWatch Alarms

// In CDK stack import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch'; // Alert on submit failures new cloudwatch.Alarm(this, 'SubmitErrors', { metric: submitFn.metricErrors(), threshold: 5, evaluationPeriods: 1, alarmDescription: 'Convoy submit Lambda is failing', }); // Alert on receiver failures new cloudwatch.Alarm(this, 'ReceiverErrors', { metric: receiverFn.metricErrors(), threshold: 3, evaluationPeriods: 1, alarmDescription: 'Convoy callback receiver is failing', });

Structured Logging

// Use structured logs for CloudWatch Insights queries console.log(JSON.stringify({ level: 'info', event: 'cargo_submitted', cargo_id: data.cargo_id, model: event.model, prompt_length: event.prompt.length, }));

Cost Optimization

ComponentCostNotes
Lambda (submit)~$0.20/million invocations256 MB, under 1s execution
Lambda (receiver)~$0.20/million invocations256 MB, under 1s execution
API Gateway$3.50/million requestsOr free with Function URLs
DynamoDB$1.25/million writesOn-demand pricing
Secrets Manager$0.40/secret/monthOne secret for API key

A typical workload of 10,000 prompts/month costs less than $0.10/month in Lambda infrastructure — the Convoy API usage is the primary cost.


Terraform Alternative

If you prefer Terraform over CDK:

# main.tf resource "aws_lambda_function" "convoy_submit" { filename = "submit.zip" function_name = "convoy-submit" role = aws_iam_role.lambda_role.arn handler = "index.handler" runtime = "nodejs20.x" timeout = 30 memory_size = 256 environment { variables = { CALLBACK_URL = aws_lambda_function_url.receiver.function_url } } } resource "aws_lambda_function" "convoy_receiver" { filename = "receiver.zip" function_name = "convoy-receiver" role = aws_iam_role.lambda_role.arn handler = "index.handler" runtime = "nodejs20.x" timeout = 10 memory_size = 256 environment { variables = { RESULTS_TABLE = aws_dynamodb_table.results.name } } } resource "aws_lambda_function_url" "receiver" { function_name = aws_lambda_function.convoy_receiver.function_name authorization_type = "NONE" } resource "aws_dynamodb_table" "results" { name = "convoy-results" billing_mode = "PAY_PER_REQUEST" hash_key = "cargo_id" attribute { name = "cargo_id" type = "S" } ttl { attribute_name = "ttl" enabled = true } } output "callback_url" { value = aws_lambda_function_url.receiver.function_url }

Troubleshooting

Lambda times out submitting to Convoy

  • Increase timeout to 30 seconds (Convoy API responds within seconds)
  • Check that the Lambda has internet access (needs NAT Gateway if in a VPC)
  • Verify the API key is correct in Secrets Manager

Callback never arrives

  • Verify the callback URL is publicly accessible
  • Check Lambda Function URL or API Gateway is deployed correctly
  • Look at Convoy’s tracking endpoint: GET /cargo/{cargo_id}/tracking
  • Convoy retries callbacks with exponential backoff — check CloudWatch Logs for delayed deliveries

DynamoDB write fails

  • Check IAM permissions on the Lambda execution role
  • Verify the table name in environment variables matches the actual table
  • For conditional write failures, the item already exists (duplicate callback)

Rate limiting (429 errors)

  • Convoy allows 60 requests/minute per project
  • For SQS triggers, set reservedConcurrency to limit parallel executions
  • Add exponential backoff in the submit function

Next Steps

Last updated on