Aforo integrates with AWS API Gateway (REST v1) via two complementary paths: a Lambda authorizer for real-time entitlement enforcement, and a CloudWatch Logs subscription filter for asynchronous usage metering. Both are deployed through standard AWS tooling — no agent, no sidecar, no infrastructure change.
aws-api-gateway-metering-architecture.flowLIVE
CLIENT
API Request
HTTPS to API GW
Client calls API Gateway endpoint with X-Tenant-Id and Authorization headers.
0ms
AUTHORIZER
Entitlement
Lambda authorizer
Aforo Lambda checks Redis edge cache for quota + margin. Returns IAM policy.
<5ms
INTEGRATION
Backend
Lambda / HTTP / VPC
API Gateway proxies request to your integration target.
passthrough
CLOUDWATCH
Log Capture
execution logs
API Gateway writes execution log entry to CloudWatch Logs group.
0ms added
FILTER
Subscription
log filter → Lambda
CloudWatch subscription filter triggers the metering Lambda per request log.
2-5s lag
INGEST
Aforo
ingest.aforo.ai
Metering Lambda POSTs enriched usage event with Bearer token auth. Retry on 5xx.
HTTP v2 (HTTP API) is not yet supported — use REST API (v1). Check your API type in the AWS Console under API Gateway → APIs.
REQUIRED
Execution logging enabled
API Gateway must have CloudWatch execution logging enabled on the stage. Log level: INFO or ERROR+INFO.
REQUIRED
AWS CLI configured
aws configure with credentials that have IAM and Lambda permissions for deployment.
REQUIRED
Aforo API Key
sk_live_* key from Workspace Admin → API Keys. The shipped SAM template injects it as the AFORO_API_KEY env var; Secrets Manager is an optional hardening step.
OPTIONAL
Node.js 20.x runtime
The Aforo Lambda function targets Node.js 20.x. Ensure your account region supports it.
The Aforo metering Lambda receives CloudWatch log events from API Gateway, extracts request metadata, and forwards enriched usage events to the Aforo ingest endpoint. Deploy it from the provided source:
terminal
# Clone the SDK distribution repo and enter the AWS Lambda folder
git clone https://github.com/aforoai/SDKs.git
cd SDKs/aforo-gateway-plugins/aws-lambda
# Build and deploy with SAM (template.yaml is in this folder)
sam build
sam deploy --guided \
--parameter-overrides \
AforoEndpoint=https://ingest.aforo.ai/v1/ingest/batch \
AforoApiKey="$AFORO_API_KEY" \
AforoTenantId="$AFORO_TENANT_ID" \
ApiGatewayLogGroupName=/aws/apigateway/aforo-access-logs
The function is distributed as the SAM template + index.js in that folder — it is not a public npm package. The template creates the function, the lambda:InvokeFunction permission for CloudWatch Logs, and a subscription filter on the named log group. Enable JSON access logging on your API Gateway stage so fields like requestId, status, and identity.apiKey are present.
Lambda Handler (index.js)
The core handler decodes CloudWatch log events and forwards them to Aforo. The full source is in the repository; here is the key extraction logic:
INFO
The shipped index.js reads the API key from the AFORO_API_KEY environment variable, injected by the SAM template's AforoApiKey parameter. The Secrets Manager pattern shown below is an optional hardening step — adopt it if you prefer audited, rotatable secret storage over an env var.
index.js
const https = require('https');
const zlib = require('zlib');
const { SecretsManagerClient, GetSecretValueCommand } = require('@aws-sdk/client-secrets-manager');
const sm = new SecretsManagerClient({ region: process.env.AWS_REGION });
// Cache the Aforo API key for the lifetime of the Lambda execution context
let cachedApiKey = null;
async function getAforoApiKey() {
if (cachedApiKey) return cachedApiKey;
const resp = await sm.send(new GetSecretValueCommand({ SecretId: 'aforo/api-key' }));
cachedApiKey = JSON.parse(resp.SecretString).apiKey;
return cachedApiKey;
}
exports.handler = async (event) => {
// Decode and decompress the CloudWatch Logs payload
const payload = Buffer.from(event.awslogs.data, 'base64');
const decompressed = zlib.gunzipSync(payload).toString('utf8');
const logData = JSON.parse(decompressed);
const apiKey = await getAforoApiKey();
const usageEvents = [];
for (const logEvent of logData.logEvents) {
const parsed = parseApiGatewayLog(logEvent.message);
if (!parsed) continue;
usageEvents.push({
customerId: parsed.customerId, // resolved from the API key / consumer
metricName: 'api_calls', // a billable unit defined in your dashboard
quantity: 1,
occurredAt: new Date(logEvent.timestamp).toISOString(),
metadata: {
method: parsed.method,
path: parsed.path,
statusCode: parsed.status,
latencyMs: parsed.latency,
requestId: parsed.requestId,
},
});
}
if (usageEvents.length === 0) return { statusCode: 200, body: 'no events' };
// Batch POST to Aforo ingest
await ingestEvents(apiKey, usageEvents);
return { statusCode: 200, body: `ingested ${usageEvents.length} events` };
};
function parseApiGatewayLog(message) {
// API Gateway execution log format:
// Method: GET, Resource: /search, Stage: prod, ...
const methodMatch = message.match(/Method: (\w+)/);
const resourceMatch = message.match(/Resource: ([^,]+)/);
const statusMatch = message.match(/Status: (\d+)/);
const latencyMatch = message.match(/Integration latency: (\d+)/);
const customerMatch = message.match(/Api-Key: ([^,\s]+)/);
const requestIdMatch = message.match(/RequestId: ([a-f0-9-]+)/);
if (!methodMatch || !customerMatch) return null;
return {
method: methodMatch[1],
path: resourceMatch?.[1]?.trim() ?? '/',
status: parseInt(statusMatch?.[1] ?? '200'),
latency: parseInt(latencyMatch?.[1] ?? '0'),
customerId: customerMatch[1],
requestId: requestIdMatch?.[1] ?? '',
};
}
async function ingestEvents(apiKey, events) {
return new Promise((resolve, reject) => {
const body = JSON.stringify({ events });
const options = {
hostname: 'ingest.aforo.ai',
path: '/v1/ingest/batch',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
'Content-Length': Buffer.byteLength(body),
'X-Idempotency-Key': require('crypto').randomUUID(),
},
};
const req = https.request(options, (res) => {
res.on('data', () => {});
res.on('end', resolve);
});
req.on('error', reject);
req.write(body);
req.end();
});
}
PRO TIP
The handler caches the API key in the Lambda execution context. Secrets Manager is called only on cold start — warm invocations read from memory. This keeps the hot path under 1ms for secret resolution.
API Gateway writes one log entry per request to a CloudWatch Logs group when execution logging is enabled. A subscription filter watches this group and triggers the Aforo Lambda for every new log batch. This is the zero-touch path — no application changes required.
Enable Execution Logging on Your Stage
terminal
# Enable execution logging on the stage (required if not already set)
aws apigateway update-stage \
--rest-api-id YOUR_API_ID \
--stage-name prod \
--patch-operations \
op=replace,path=/accessLogSettings/destinationArn,value=arn:aws:logs:REGION:ACCOUNT:log-group:API-GW-Execution-Logs_YOUR_API_ID/prod \
op=replace,path=/*/*/logging/loglevel,value=INFO \
op=replace,path=/*/*/logging/dataTrace,value=true
# Note the log group name — it follows this pattern:
echo "Log group: API-Gateway-Execution-Logs_YOUR_API_ID/prod"
Create the CloudWatch Subscription Filter
terminal
# Get the Lambda ARN from the previous deploy step
LAMBDA_ARN=$(aws lambda get-function --function-name aforo-metering-aws --query 'Configuration.FunctionArn' --output text)
# Add permission for CloudWatch to invoke the Lambda
aws lambda add-permission \
--function-name aforo-metering-aws \
--statement-id cloudwatch-subscription \
--action lambda:InvokeFunction \
--principal logs.amazonaws.com \
--source-arn arn:aws:logs:REGION:ACCOUNT:log-group:API-Gateway-Execution-Logs_YOUR_API_ID/prod:*
# Create the subscription filter
aws logs put-subscription-filter \
--log-group-name "API-Gateway-Execution-Logs_YOUR_API_ID/prod" \
--filter-name "aforo-metering" \
--filter-pattern "[method, resource, stage, status, latency, integrationStatus, ...]" \
--destination-arn "$LAMBDA_ARN"
INFO
The subscription filter pattern [method, resource, ...] matches structured API Gateway execution logs. CloudWatch batches log events and delivers them to the Lambda within 2-5 seconds of the original request — usage appears in Aforo with a short lag, not in real time. Billing is unaffected: events are timestamped to the original request time.
The Aforo Lambda function requires a minimal IAM execution role. Apply the principle of least privilege — the role needs only CloudWatch Logs write access and Secrets Manager read access for the Aforo API key.
# Create the trust policy for Lambda
cat > trust-policy.json <<'EOF'
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Service": "lambda.amazonaws.com" },
"Action": "sts:AssumeRole"
}]
}
EOF
# Create the role
aws iam create-role \
--role-name aforo-metering-lambda-role \
--assume-role-policy-document file://trust-policy.json
# Attach the custom policy
aws iam put-role-policy \
--role-name aforo-metering-lambda-role \
--policy-name aforo-metering-permissions \
--policy-document file://aforo-metering-lambda-policy.json
# Store the Aforo API key in Secrets Manager
aws secretsmanager create-secret \
--name "aforo/api-key" \
--secret-string '{"apiKey":"sk_live_your_key_here"}'
WARNING
Never hardcode the Aforo API key in the Lambda source or environment variables. Secrets Manager with the IAM policy above is the correct approach — the key is encrypted at rest, audited on every access, and rotatable without a Lambda redeploy.
Deploy the Lambda function and run a verification request through API Gateway:
Deploy the Lambda
terminal
# Get the role ARN
ROLE_ARN=$(aws iam get-role --role-name aforo-metering-lambda-role --query 'Role.Arn' --output text)
# Create the Lambda function
aws lambda create-function \
--function-name aforo-metering-aws \
--runtime nodejs20.x \
--handler index.handler \
--role "$ROLE_ARN" \
--zip-file fileb://aforo-metering.zip \
--timeout 30 \
--memory-size 256 \
--environment Variables="{AWS_REGION=$(aws configure get region)}" \
--tracing-config Mode=Active
# Verify function is active
aws lambda get-function-configuration \
--function-name aforo-metering-aws \
--query '{State: State, Runtime: Runtime, MemorySize: MemorySize}'
Send a Test Request
terminal
# Make a request through your API Gateway stage
curl -v "https://YOUR_API_ID.execute-api.REGION.amazonaws.com/prod/your-endpoint" \
-H "X-Tenant-Id: test_tenant_123" \
-H "Authorization: Bearer your_customer_api_key"
# Check Lambda logs for successful event forwarding
aws logs tail /aws/lambda/aforo-metering-aws --follow --format short
# Expected log output:
# ingested 1 events
# POST https://ingest.aforo.ai/v1/ingest/batch → 200 OK
# Verify the event arrived in Aforo
curl -s "https://api.aforo.ai/v1/events?tenant_id=test_tenant_123&limit=1" \
-H "Authorization: Bearer sk_live_your_admin_key" | jq .
TROUBLESHOOTING
▸
No Lambda invocations: Verify the CloudWatch subscription filter is attached to the correct log group. Check with: aws logs describe-subscription-filters --log-group-name "..."
▸
Lambda invoked but no Aforo events: Check Lambda logs for parse failures. API Gateway log format varies by logging level — ensure level=INFO and data trace=true.
▸
SecretsManager access denied: Verify the IAM role ARN in the Lambda config matches the role that has the SecretsManager policy attached.
▸
Missing X-Tenant-Id: API Gateway must pass the header through to execution logs. Enable full request logging under Stage → Logs/Tracing → Data Trace.
PRO TIP
CloudWatch subscription filters have a 2-5 second delivery lag. This is expected — usage events appear in Aforo a few seconds after the API request completes. Real-time dashboards in Aforo account for this lag automatically. The event timestamp is set to the original request time, not the delivery time, so billing calculations are always accurate.