API + SQS + Lambda: ingesta de notificaciones con fire-and-forget
Construye un API serverless que acepta peticiones, las encola en SQS y procesa de forma asíncrona con Lambda — sin Lambda intermediaria en la ruta de ingesta.
Costo estimado: free under AWS free tier (~$0 si destruyes los recursos al terminar)
Qué vas a construir
Un sistema de ingesta de notificaciones (email, SMS, push) donde:
- El cliente hace
POST /notificationscon un payload JSON. - API Gateway encola el mensaje en SQS de forma directa, sin pasar por una Lambda en la ruta de ingesta.
- Una Lambda consumidora procesa los mensajes en batch desde la cola.
- El cliente recibe un
202 Acceptedcon unmessageIdapenas el mensaje queda en la cola, sin esperar al procesamiento.
Este patrón se llama fire-and-forget y es la pieza fundamental de cualquier ingesta asíncrona en AWS serverless.
Por qué este patrón
Imagina un endpoint que envía emails llamando directamente a un proveedor (SES, SendGrid, etc.):
Cliente → API Gateway → Lambda → SES → Respuesta al cliente
Problemas:
- Latencia acoplada al proveedor: si SES está lento, el cliente espera.
- Picos de tráfico: un spike de 1000 req/s abre 1000 conexiones simultáneas a SES y puede agotar quotas.
- Errores transitorios: si SES falla momentáneamente, el cliente recibe el error y debe reintentar.
Con el patrón fire-and-forget:
Cliente → API Gateway → SQS (respuesta inmediata: 202 Accepted)
↓
Lambda → SES (procesamiento desacoplado)
Beneficios:
- El cliente recibe respuesta en milisegundos, sin importar la latencia del proveedor.
- SQS absorbe picos: si llegan 1000 mensajes/s pero SES solo procesa 100/s, los 900 restantes esperan en la cola sin perderse.
- Si SES falla, los mensajes quedan en la cola y SQS reintenta automáticamente cuando la Lambda esté disponible.
- El endpoint público y el procesamiento son piezas independientes que escalan, fallan y se reintentan por separado.
El cliente recibe un
messageIdque confirma aceptación en la cola, no que la notificación se haya enviado. Si necesitas confirmación end-to-end, eso requiere otro patrón (callback, WebSocket, polling) que sale del scope de este lab.
Arquitectura
┌──────────────┐
│ Cliente │
└──────┬───────┘
│ POST /v1/notifications
│ {"userId":"...","channel":"email","template":"...","data":{...}}
▼
┌──────────────┐ IAM role:
│ API Gateway │◄──── apigw_to_sqs (sqs:SendMessage)
│ REST API │
└──────┬───────┘
│ Action=SendMessage&MessageBody=<json>
▼
┌──────────────┐
│ SQS │ Standard queue
│ │ - long polling 10s
└──────┬───────┘ - visibility timeout 30s
│
│ Event Source Mapping (batch_size=5)
▼
┌──────────────┐ IAM role:
│ Lambda │◄──── lambda_exec (sqs:Receive/Delete + logs)
│ (nodejs24.x) │
└──────┬───────┘
│
▼
CloudWatch Logs
Lo importante de este diagrama:
- API Gateway → SQS es una integración directa AWS service. No hay Lambda en medio. Esto reduce latencia y costo, y elimina un punto de falla.
- Dos roles IAM distintos: API Gateway solo puede
SendMessage, la Lambda solo puedeReceiveMessageyDeleteMessage. Este es el principio de least privilege aplicado: si una de las dos identidades se compromete, no puede hacer el trabajo de la otra.
Estructura del proyecto
aws-poc-api-sqs-lambda/
├── lambda/
│ ├── package.json
│ └── src/
│ └── index.mjs ← código del consumidor
└── terraform/
├── versions.tf ← Terraform y providers
├── main.tf ← provider + locals
├── variables.tf ← variables de entrada
├── sqs.tf ← cola SQS
├── iam.tf ← roles y políticas
├── lambda.tf ← Lambda + event source mapping
├── api_gateway.tf ← REST API + integración con SQS
└── outputs.tf ← outputs
Vamos a construir cada archivo paso a paso.
Prerrequisitos
# Verifica versiones
aws --version # AWS CLI v2 o superior
terraform --version # 1.6.0 o superior
node --version # opcional para correr local; el deploy no lo necesita
Asegúrate de tener credenciales AWS activas:
aws sts get-caller-identity
Si te devuelve tu cuenta y user/role, estás listo.
Paso 1 — Código del Lambda consumer
Crea la estructura base del proyecto:
mkdir -p aws-poc-api-sqs-lambda/{lambda/src,terraform}
cd aws-poc-api-sqs-lambda
Crea lambda/package.json:
{
"name": "notifications-consumer",
"version": "1.0.0",
"description": "Lambda consumer for notifications SQS queue",
"main": "src/index.mjs",
"type": "module",
"engines": {
"node": ">=24.0.0"
},
"private": true
}
Crea lambda/src/index.mjs:
export const handler = async (event) => {
for (const record of event.Records) {
const { userId, channel, template, data } = JSON.parse(record.body);
console.log(
JSON.stringify({
level: "info",
message: "processing notification",
messageId: record.messageId,
userId,
channel,
template,
}),
);
await deliver({ channel, userId, template, data });
console.log(
JSON.stringify({
level: "info",
message: "notification sent",
messageId: record.messageId,
userId,
channel,
}),
);
}
};
// Placeholder for the real provider call (SES, SNS, FCM, Twilio, etc).
// The 50ms sleep simulates network latency so the logs reflect realistic timing.
const deliver = async ({ channel, userId, template, data }) => {
await new Promise((resolve) => setTimeout(resolve, 50));
console.log(
JSON.stringify({
level: "info",
message: `simulated ${channel} delivery`,
userId,
template,
data,
}),
);
};
Notas sobre este código:
event.Records: cuando SQS dispara la Lambda, el evento incluye un array de records (un record por mensaje). Por eso elfor. Aún si el batch_size es 1, el código debe manejar arrays.- Structured logging: usamos
JSON.stringifycon campos consistentes (level,message,messageId, etc.) en vez deconsole.log("processing", id). Esto permite filtrar logs en CloudWatch Insights con queries tipofields @timestamp, message | filter level = "info". deliveres un placeholder: en un sistema real aquí va la llamada a SES, SNS, FCM o Twilio. Mantenerlo aislado en una función facilita testing y un futuro switch de proveedor.
Paso 2 — Configuración de Terraform
Crea terraform/versions.tf:
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
# >= 6.21 required for nodejs24.x runtime validation in aws_lambda_function.
version = ">= 6.21, < 7.0"
}
archive = {
source = "hashicorp/archive"
version = "~> 2.4"
}
}
backend "local" {
path = "terraform.tfstate"
}
}
Estamos usando
backend "local"para que el state quede en disco. Para producción mover a S3 + DynamoDB lock.
Crea terraform/main.tf:
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Project = var.project_name
Environment = var.environment
ManagedBy = "terraform"
}
}
}
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
locals {
name_prefix = "${var.project_name}-${var.environment}"
account_id = data.aws_caller_identity.current.account_id
region = data.aws_region.current.region
}
Crea terraform/variables.tf:
variable "aws_region" {
description = "AWS region where resources will be deployed"
type = string
default = "us-east-1"
}
variable "project_name" {
description = "Project identifier used as prefix for resource names"
type = string
default = "notifications-poc"
}
variable "environment" {
description = "Deployment environment (dev, staging, prod)"
type = string
default = "dev"
}
variable "api_stage_name" {
description = "API Gateway deployment stage name"
type = string
default = "v1"
}
local.name_prefix da notifications-poc-dev y se usa como prefijo en todos los recursos. Cambiar project_name o environment te da entornos paralelos sin colisión de nombres.
Paso 3 — La cola SQS
Crea terraform/sqs.tf:
resource "aws_sqs_queue" "notifications" {
name = "${local.name_prefix}-notifications"
# Must be >= lambda timeout (10s). 30s leaves headroom so a slow batch
# is not redelivered while still being processed.
visibility_timeout_seconds = 30
# 4 days retention. Standard SQS default, enough for an outage window.
message_retention_seconds = 345600
# Long polling: reduces empty receives and SQS API cost.
receive_wait_time_seconds = 10
}
Tres parámetros importantes:
visibility_timeout_seconds = 30: cuando la Lambda recibe un mensaje, SQS lo oculta a otros consumers durante este tiempo. Si la Lambda no termina de procesar antes, el mensaje vuelve a aparecer y se procesa de nuevo (riesgo de duplicado). Regla: visibility timeout >= Lambda timeout. La Lambda tiene timeout 10s, así que 30s da margen.message_retention_seconds = 345600(4 días): si la Lambda está caída, los mensajes esperan en cola hasta este límite. Es el default de SQS.receive_wait_time_seconds = 10: long polling. El consumer espera hasta 10s a que llegue un mensaje en vez de retornar vacío inmediatamente. Reduce costo (menos llamadas a la API de SQS) y latencia (responde apenas llega el mensaje).
Paso 4 — IAM con least privilege
Aquí está el corazón del diseño. Crea terraform/iam.tf:
# Two distinct roles by design (least privilege):
# - lambda_exec: only Receive/Delete from the queue + write logs
# - apigw_to_sqs: only SendMessage to the queue
# Splitting them prevents either side from doing the other's job if compromised.
data "aws_iam_policy_document" "lambda_assume_role" {
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["lambda.amazonaws.com"]
}
}
}
resource "aws_iam_role" "lambda_exec" {
name = "${local.name_prefix}-lambda-exec"
assume_role_policy = data.aws_iam_policy_document.lambda_assume_role.json
}
resource "aws_iam_role_policy_attachment" "lambda_basic_logs" {
role = aws_iam_role.lambda_exec.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
data "aws_iam_policy_document" "lambda_sqs_consume" {
statement {
effect = "Allow"
actions = [
"sqs:ReceiveMessage",
"sqs:DeleteMessage",
"sqs:GetQueueAttributes",
]
resources = [aws_sqs_queue.notifications.arn]
}
}
resource "aws_iam_role_policy" "lambda_sqs_consume" {
name = "${local.name_prefix}-lambda-sqs-consume"
role = aws_iam_role.lambda_exec.id
policy = data.aws_iam_policy_document.lambda_sqs_consume.json
}
data "aws_iam_policy_document" "apigw_assume_role" {
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["apigateway.amazonaws.com"]
}
}
}
resource "aws_iam_role" "apigw_to_sqs" {
name = "${local.name_prefix}-apigw-to-sqs"
assume_role_policy = data.aws_iam_policy_document.apigw_assume_role.json
}
data "aws_iam_policy_document" "apigw_sqs_send" {
statement {
effect = "Allow"
actions = ["sqs:SendMessage"]
resources = [aws_sqs_queue.notifications.arn]
}
}
resource "aws_iam_role_policy" "apigw_sqs_send" {
name = "${local.name_prefix}-apigw-sqs-send"
role = aws_iam_role.apigw_to_sqs.id
policy = data.aws_iam_policy_document.apigw_sqs_send.json
}
Por qué dos roles separados:
lambda_execasumelambda.amazonaws.com(la principal del servicio Lambda). Solo puedeReceiveMessage,DeleteMessagey escribir logs (vía la managed policyAWSLambdaBasicExecutionRole).apigw_to_sqsasumeapigateway.amazonaws.com. Solo puedeSendMessage.
Si alguien compromete el rol de la Lambda, no puede inyectar mensajes nuevos a la cola. Si compromete el rol del API Gateway, no puede leer ni borrar mensajes. Cada identidad solo puede hacer su trabajo.
Anti-pattern común: usar un solo rol con
sqs:*. Funciona, pero rompe el principio de least privilege y abre la puerta a privilege escalation lateral.
Paso 5 — Lambda + event source mapping
Crea terraform/lambda.tf:
data "archive_file" "lambda_zip" {
type = "zip"
source_dir = "${path.module}/../lambda/src"
output_path = "${path.module}/build/notifications-consumer.zip"
}
# Declared explicitly so the log group is managed by Terraform and gets a
# bounded retention. If we let Lambda auto-create it, retention defaults to
# "Never expire" and the group survives `terraform destroy`.
resource "aws_cloudwatch_log_group" "lambda" {
name = "/aws/lambda/${local.name_prefix}-notifications-consumer"
retention_in_days = 7
}
resource "aws_lambda_function" "notifications_consumer" {
function_name = "${local.name_prefix}-notifications-consumer"
role = aws_iam_role.lambda_exec.arn
filename = data.archive_file.lambda_zip.output_path
source_code_hash = data.archive_file.lambda_zip.output_base64sha256
runtime = "nodejs24.x"
handler = "index.handler"
timeout = 10
depends_on = [
aws_iam_role_policy_attachment.lambda_basic_logs,
aws_cloudwatch_log_group.lambda,
]
}
resource "aws_lambda_event_source_mapping" "sqs_to_lambda" {
event_source_arn = aws_sqs_queue.notifications.arn
function_name = aws_lambda_function.notifications_consumer.arn
# Small batch keeps per-invocation latency low for a notifications use case.
# Increase for higher throughput / cost optimization.
batch_size = 5
enabled = true
}
Tres detalles que evitan dolor:
source_code_hash: si solo cambiafilenameTerraform no detecta el cambio del contenido del ZIP. Consource_code_hashapuntando al hash del archivo, cualquier modificación del código dispara redeploy del Lambda.- Log group declarado explícitamente: si dejas que Lambda lo cree solo, queda con retención “Never expire” y
terraform destroyno lo borra. Declararlo aquí garantiza retención bounded (7 días) y limpieza completa. event_source_mapping: Terraform conecta SQS con la Lambda. AWS se encarga del polling — tú no tienes que escribir código que llame aReceiveMessage. Lambda recibe los mensajes vía eleventque pasa al handler.
Paso 6 — API Gateway con integración directa a SQS
Aquí está la parte más interesante: API Gateway llama a SQS sin Lambda en el medio. Crea terraform/api_gateway.tf:
resource "aws_api_gateway_rest_api" "this" {
name = "${local.name_prefix}-api"
description = "Notifications ingestion API"
endpoint_configuration {
types = ["REGIONAL"]
}
}
resource "aws_api_gateway_resource" "notifications" {
rest_api_id = aws_api_gateway_rest_api.this.id
parent_id = aws_api_gateway_rest_api.this.root_resource_id
path_part = "notifications"
}
resource "aws_api_gateway_method" "post_notifications" {
rest_api_id = aws_api_gateway_rest_api.this.id
resource_id = aws_api_gateway_resource.notifications.id
http_method = "POST"
authorization = "NONE"
}
# Direct AWS service integration: API Gateway calls SQS without a Lambda proxy.
# SQS expects form-urlencoded with Action=SendMessage&MessageBody=...; the
# request template rewrites the incoming JSON into that format. The URL-encoded
# body becomes the message body the Lambda will read.
resource "aws_api_gateway_integration" "sqs" {
rest_api_id = aws_api_gateway_rest_api.this.id
resource_id = aws_api_gateway_resource.notifications.id
http_method = aws_api_gateway_method.post_notifications.http_method
integration_http_method = "POST"
type = "AWS"
uri = "arn:aws:apigateway:${local.region}:sqs:path/${local.account_id}/${aws_sqs_queue.notifications.name}"
credentials = aws_iam_role.apigw_to_sqs.arn
passthrough_behavior = "NEVER"
request_parameters = {
"integration.request.header.Content-Type" = "'application/x-www-form-urlencoded'"
}
request_templates = {
"application/json" = "Action=SendMessage&MessageBody=$util.urlEncode($input.body)"
}
}
resource "aws_api_gateway_method_response" "accepted" {
rest_api_id = aws_api_gateway_rest_api.this.id
resource_id = aws_api_gateway_resource.notifications.id
http_method = aws_api_gateway_method.post_notifications.http_method
status_code = "202"
response_models = {
"application/json" = "Empty"
}
}
# SQS replies with XML (SendMessageResponse → SendMessageResult → MessageId).
# This template extracts the MessageId and returns it as JSON so callers don't
# see SQS internals.
resource "aws_api_gateway_integration_response" "accepted" {
rest_api_id = aws_api_gateway_rest_api.this.id
resource_id = aws_api_gateway_resource.notifications.id
http_method = aws_api_gateway_method.post_notifications.http_method
status_code = aws_api_gateway_method_response.accepted.status_code
response_templates = {
"application/json" = <<EOT
#set($messageId = $input.path('$.SendMessageResponse.SendMessageResult.MessageId'))
{"messageId":"$messageId"}
EOT
}
depends_on = [aws_api_gateway_integration.sqs]
}
resource "aws_api_gateway_deployment" "this" {
rest_api_id = aws_api_gateway_rest_api.this.id
triggers = {
redeploy = sha1(jsonencode([
aws_api_gateway_resource.notifications.id,
aws_api_gateway_method.post_notifications.id,
aws_api_gateway_integration.sqs.id,
aws_api_gateway_integration_response.accepted.id,
]))
}
lifecycle {
create_before_destroy = true
}
depends_on = [
aws_api_gateway_integration.sqs,
aws_api_gateway_integration_response.accepted,
]
}
resource "aws_api_gateway_stage" "this" {
rest_api_id = aws_api_gateway_rest_api.this.id
deployment_id = aws_api_gateway_deployment.this.id
stage_name = var.api_stage_name
}
Lo más importante de este archivo:
aws_api_gateway_integration con type = "AWS" y URI apuntando directamente al servicio SQS. No hay aws_lambda_function intermedia. La autenticación con SQS la hace API Gateway asumiendo el rol apigw_to_sqs (credentials = aws_iam_role.apigw_to_sqs.arn).
request_templates transforma el body entrante. SQS no acepta JSON crudo en SendMessage — espera form-urlencoded con Action=SendMessage&MessageBody=<body>. La línea:
Action=SendMessage&MessageBody=$util.urlEncode($input.body)
toma el body JSON original ($input.body) y lo URL-encodea para meterlo dentro del campo MessageBody. Esa cadena URL-encodeada es exactamente lo que la Lambda recibirá como record.body (después de que SQS lo decodifique).
response_templates hace el camino inverso. SQS responde XML así:
<SendMessageResponse>
<SendMessageResult>
<MessageId>abc-123-def</MessageId>
</SendMessageResult>
</SendMessageResponse>
El template extrae el MessageId y lo devuelve como {"messageId":"abc-123-def"} para que el cliente no vea las tripas de SQS.
triggers.redeploy: API Gateway cachea el deployment. Si cambias la integración o la response y no haces redeploy, los cambios no se aplican. El hash sobre los IDs de los recursos clave fuerza redeploy automático en Terraform cuando algo cambia.
Por qué evitar la Lambda en la ingesta: cada Lambda invocation suma latencia (cold start si no está caliente) y costo (request count). API Gateway directo a SQS es 1 hop menos, ~50ms menos de latencia, y casi cero costo extra del API. La Lambda solo aporta valor cuando necesitas validación compleja del payload o transformación que VTL no maneja.
Paso 7 — Outputs
Crea terraform/outputs.tf:
output "api_invoke_url" {
description = "Full URL to POST notifications"
value = "${aws_api_gateway_stage.this.invoke_url}/notifications"
}
output "sqs_queue_url" {
description = "URL of the notifications SQS queue"
value = aws_sqs_queue.notifications.url
}
output "sqs_queue_name" {
description = "Name of the notifications SQS queue"
value = aws_sqs_queue.notifications.name
}
output "lambda_function_name" {
description = "Name of the Lambda consumer function"
value = aws_lambda_function.notifications_consumer.function_name
}
output "lambda_log_group" {
description = "CloudWatch log group for the Lambda"
value = aws_cloudwatch_log_group.lambda.name
}
Estos outputs los vamos a usar para invocar el endpoint y leer los logs sin tener que ir a la consola de AWS.
Paso 8 — Desplegar
Desde la carpeta terraform/:
cd terraform
terraform init
terraform plan
terraform apply
Cuando termina, vas a ver los outputs:
api_invoke_url = "https://abcd1234ef.execute-api.us-east-1.amazonaws.com/v1/notifications"
sqs_queue_name = "notifications-poc-dev-notifications"
lambda_function_name = "notifications-poc-dev-notifications-consumer"
lambda_log_group = "/aws/lambda/notifications-poc-dev-notifications-consumer"
Si usas
terraform init -upgradedespués de modificarversions.tf, considera borrar.terraform.lock.hclpara regenerar el lock con las versiones nuevas. Un init simple respeta el lock previo y falla si el constraint cambió.
Paso 9 — Probar el flujo
Dispara una petición:
curl -X POST "$(terraform output -raw api_invoke_url)" \
-H "Content-Type: application/json" \
-d '{"userId":"u-123","channel":"email","template":"welcome","data":{"name":"Luis"}}'
Respuesta esperada (HTTP 202):
{"messageId":"a1b2c3d4-e5f6-7890-abcd-ef1234567890"}
Ese messageId es el ID que SQS asignó al mensaje. Confirmación de que está en la cola.
Ahora abre los logs de la Lambda:
aws logs tail "$(terraform output -raw lambda_log_group)" --follow
Deberías ver tres líneas en JSON estructurado:
{"level":"info","message":"processing notification","messageId":"...","userId":"u-123","channel":"email","template":"welcome"}
{"level":"info","message":"simulated email delivery","userId":"u-123","template":"welcome","data":{"name":"Luis"}}
{"level":"info","message":"notification sent","messageId":"...","userId":"u-123","channel":"email"}
Eso confirma:
- La Lambda recibió el mensaje del event source mapping.
- El payload original llegó intacto a través del mapping de API Gateway → SQS → Lambda.
- Toda la cadena funciona end-to-end.
Anatomía del flujo (qué pasa internamente)
Vamos paso a paso después de que el cliente hace POST:
- API Gateway recibe el request y aplica el
request_template. El JSON del body se URL-encodea y se inserta enMessageBody=.... - API Gateway asume el rol
apigw_to_sqsy llama asqs.amazonaws.com/<account>/<queue>conAction=SendMessage. SQS confirma con un XML que contiene elMessageId. - API Gateway aplica el
response_templatesobre el XML. ExtraeMessageIdy devuelve{"messageId":"..."}con status202. - El cliente recibe la respuesta en milisegundos (típicamente ~30-80ms incluyendo round-trip de SQS).
- Mientras tanto, el event source mapping está haciendo polling de la cola. Cuando ve un mensaje (o hasta
batch_size=5), invoca la Lambda pasando los records. - La Lambda procesa cada record, llama al placeholder
deliver(), y termina. - Si la Lambda completa sin error, el event source mapping borra los mensajes de la cola (
DeleteMessageen bulk). - Si la Lambda lanza una excepción, los mensajes quedan en la cola, el visibility timeout expira, y vuelven a estar disponibles para la próxima invocación.
Este último punto es crucial: el “DeleteMessage” no lo haces tú, lo hace AWS si y solo si la Lambda terminó exitosamente. Es parte de la garantía at-least-once de SQS.
Limitaciones de esta POC
Para que sea producción-ready, te falta:
- DLQ (Dead Letter Queue): si un mensaje falla N veces, debe ir a una cola separada para inspección manual. Sin DLQ, los mensajes problemáticos pueden ciclarse indefinidamente.
- Validación de schema en API Gateway: hoy aceptamos cualquier JSON. Producción debería rechazar payloads inválidos en la capa de API (con
aws_api_gateway_request_validatory un model JSON Schema) antes de encolar. - Idempotencia: SQS garantiza at-least-once delivery. Eso significa que un mismo mensaje puede procesarse más de una vez (ej: si la Lambda termina justo después de enviar la notificación pero antes de retornar). El consumer debería ser idempotente (ej: tracking de
messageIden una tabla de DynamoDB con TTL). - Throttling y autenticación en el API: hoy
authorization = "NONE". Producción usa API keys, IAM auth, Cognito, o un Lambda authorizer. - Observabilidad real: structured logs son un buen inicio, pero falta métricas custom (CloudWatch Metrics o Embedded Metric Format), alarmas, y trazas (X-Ray).
- CI/CD: hoy aplicas Terraform desde tu máquina. Producción usa GitHub Actions con OIDC + role assumption.
Limpieza
terraform destroy
Confirma con yes. Esto borra: API Gateway, SQS queue, Lambda, log group, ambos roles IAM y todas sus policies. El costo en AWS vuelve a $0.
Verifica con:
aws sqs list-queues --queue-name-prefix notifications-poc
aws lambda list-functions --query "Functions[?starts_with(FunctionName, 'notifications-poc')]"
Ambos comandos deberían devolver listas vacías.
Próximos pasos
Cuando quieras llevarlo más allá:
- Agrega DLQ + redrive policy.
- Reemplaza el placeholder
deliver()con SES real para emails. - Agrega validación de schema en API Gateway con
aws_api_gateway_model+request_validator. - Migra el state a S3 + DynamoDB lock para trabajo en equipo.
- Agrega CI/CD con GitHub Actions OIDC.
- Implementa idempotencia en el consumer.
Referencia rápida
# Setup
cd terraform
terraform init
terraform plan
terraform apply
# Probar
curl -X POST "$(terraform output -raw api_invoke_url)" \
-H "Content-Type: application/json" \
-d '{"userId":"u-123","channel":"email","template":"welcome","data":{"name":"Luis"}}'
# Ver logs
aws logs tail "$(terraform output -raw lambda_log_group)" --follow
# Ver mensajes pendientes en la cola
aws sqs get-queue-attributes \
--queue-url "$(terraform output -raw sqs_queue_url)" \
--attribute-names ApproximateNumberOfMessages
# Limpiar
terraform destroy