Sign in →
1 min read

AWS API Gateway Integration

Lambda function + CloudWatch log subscription for zero-touch metering. Works with any REST v1 API Gateway endpoint.

Updated 2026-11-11Suggest edits

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
api.aforo.ai
Metering Lambda POSTs the usage event with the API key in X-API-Key. Retry on 5xx.
<60ms
aws api gateway → cloudwatch logs → lambda → aforo ingest · sigv4 auth// p95 < 5ms

Prerequisites#

REQUIRED
AWS API Gateway REST v1
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.

One-Click Install from Aforo#

If your AWS account is connected under Governance → Integrations with the CloudFormation role, you can skip the SAM deploy below. Open the connection, turn on metering and deploy. Aforo assumes the role from your stack and, in your account:

#What Aforo doesWhere to see it in AWS
1Creates the metering function (Node.js 20, no dependencies)Lambda function aforo-metering-<connection id>
2Sets its async retry policy: 2 retries, 6 hoursThe function’s Asynchronous invocation settings
3Lets CloudWatch Logs invoke it, for your log group onlyThe function’s resource-based policy
4Subscribes it to your access log groupThe log group’s Subscription filters tab

Deploying again uploads the code only when it changed and always refreshes the settings. Environment variables you added yourself are kept. Removing metering deletes the subscription filter, the permission and the function.

Before you deploy

Update the stack. A stack created before one-click metering gives Aforo read-only access. In CloudFormation, update it with the current template and leave AllowMeteringInstall at true. That adds permission to manage Lambda functions named aforo-metering-* and subscription filters, plus an execution role that can only write the function's own logs. Aforo still cannot create IAM roles or change API Gateway. Set the parameter to false to keep the role read-only and install the Lambda yourself.

Turn on access logging. Aforo does not change your stages. On each stage you bill, enable custom access logging to one log group with this format, on one line:

access log format
{"requestId":"$context.requestId","httpMethod":"$context.httpMethod","path":"$context.path","resourcePath":"$context.resourcePath","status":"$context.status","responseLength":"$context.responseLength","responseLatency":"$context.responseLatency","apiKey":"$context.identity.apiKey","caller":"$context.identity.caller","sourceIp":"$context.identity.sourceIp","userAgent":"$context.identity.userAgent","apiId":"$context.apiId","stage":"$context.stage"}

Then set accessLogGroup on the connection to that log group's name. The default is /aws/apigateway/aforo-access-logs.

Deploy saysCauseFix
AWS did not allow Aforo to … (lambda:CreateFunction)The stack predates one-click metering, or AllowMeteringInstall is false.Update the stack with the current template, then deploy again.
The CloudWatch log group … does not existAccess logging is off, or it writes to a different log group.Enable access logging with the format above and set accessLogGroup.
AWS limit reached (LimitExceededException)The log group already has two subscription filters.Remove one, or log to a group of its own.
One-click metering needs the IAM role connectionThe connection uses access keys, so there is no stack and no execution role.Reconnect with the CloudFormation template, or set meteringExecutionRoleArn.

One-click install does not create the SQS failure queue that the SAM template sets up for batches that fail every retry. If you want that queue, deploy with SAM as described next.

Lambda Function Setup#

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://api.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: 'api.aforo.ai',
      path: '/v1/ingest/batch',
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-API-Key': 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.

CloudWatch Metering#

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.

How Calls Get an Outcome#

Read this if any rate plan behind this gateway uses Outcome-Based pricing — it bills each call at a weight for its executionStatus. Every other pricing model ignores the field. The plugin sets it from the upstream response status:

Upstream statusexecutionStatus
2xx, 3xxSUCCESS
408, 504TIMEOUT
499 (client closed the request, where the gateway reports it)CANCELLED
400, 422VALIDATION_FAILED
401, 403, 429BLOCKED
Every other 4xx and 5xx (404 included)ERROR
Missing, 0, 1xxNot sent — the call bills at full weight
WARNING
A status with no weight on the rate plan bills at full weight (1.0). The rate plan wizard starts BLOCKED and CANCELLED at 0, but plans saved before those two statuses were added to the wizard (September 30, 2026) have no weight for them, so the gateway's BLOCKED and CANCELLED calls on those plans bill in full until you give them one — open the plan in the wizard and enter a weight (the fields show blank), or set it through the rate-plan API (outcomeWeights). See Outcome-Based pricing.

Override the status for specific codes

When the default table is wrong for your API — say a 404 is a normal answer, not a failure — map exact codes with the STATUS_OUTCOMES environment variable, set through the SAM parameter StatusOutcomes. Format: comma-separated code=STATUS pairs:

terminal
sam deploy \
  --parameter-overrides \
    StatusOutcomes="404=SUCCESS,429=ERROR"

Keys are exact HTTP codes from 200 to 599. Values must be one of the 11 statuses: SUCCESS, PARTIAL, TIMEOUT, ERROR, VALIDATION_FAILED, FAILED, FAILURE, CANCELLED, PENDING, BLOCKED, HITL_REQUIRED. Invalid entries are skipped with a warning in the function log. If a code is listed twice, the last valid entry wins. An override can't give a status to a missing, 0 or 1xx code.

Requests that aren't metered

By default, responses with status 401, 403 or 429 and requests to /health, /ready or /metrics produce no event at all — they cost nothing whatever the rate plan says. All five gateway plugins share this default. A path is skipped when it equals an entry or starts with it, so /health also covers /health/live.

SettingDefaultTo meter everything
Environment variable EXCLUDE_STATUS_CODES, set through the SAM parameter ExcludeStatusCodes401,403,429An empty value
Fixed in the function code/health, /ready, /metrics, /favicon.icoNot configurable

Entries that aren't whole numbers from 100 to 599 are ignored. A list you set replaces the default rather than adding to it: a status list of 404,500 skips only those two and starts metering 401, 403 and 429. A status you choose to meter bills as BLOCKED unless you override it above.

IAM Role Configuration#

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.

IAM Policy (aforo-metering-lambda-policy.json)

aforo-metering-lambda-policy.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "CloudWatchLogs",
      "Effect": "Allow",
      "Action": [
        "logs:CreateLogGroup",
        "logs:CreateLogStream",
        "logs:PutLogEvents"
      ],
      "Resource": "arn:aws:logs:*:*:log-group:/aws/lambda/aforo-metering-aws:*"
    },
    {
      "Sid": "SecretsManagerReadAforoKey",
      "Effect": "Allow",
      "Action": [
        "secretsmanager:GetSecretValue"
      ],
      "Resource": "arn:aws:secretsmanager:REGION:ACCOUNT:secret:aforo/api-key-*"
    },
    {
      "Sid": "XRayTracing",
      "Effect": "Allow",
      "Action": [
        "xray:PutTraceSegments",
        "xray:PutTelemetryRecords"
      ],
      "Resource": "*"
    }
  ]
}

Create the Role via AWS CLI

terminal
# 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 & Verify#

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://api.aforo.ai/v1/ingest/batch → 200 OK

# Then confirm it arrived: in Aforo, open Intelligence → Usage Ingestion.
# The event shows up once the plugin has sent it (batching plugins flush every few seconds).
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.
Aforo Documentation — Aforo Docs