Robust Stripe Webhooks on AWS Lambda with OpenTofu and Terragrunt
A customer clicks Subscribe, pays, and lands back on your app expecting to see their new plan. Behind that click, Stripe fires a webhook to tell your system what happened. If your endpoint is slow, does too much work inline, or trips over a duplicate delivery, that paid signup can stall or apply twice. Stripe retries endpoints that respond slowly or fail, so a heavy handler turns one event into several, sometimes out of order.
This article walks through a working demo that handles Stripe subscription webhooks the way a production system should, kept small on purpose so the pipeline is the whole story. The code is trimmed straight from a real, production-shaped example, so it shows the core configuration and leaves the glue out. You get a single-page pricing table, a public webhook endpoint, an SQS buffer with a dead-letter queue, a processor, and DynamoDB for billing state, all on AWS Lambda and provisioned with OpenTofu modules driven by Terragrunt explicit stacks.
The technical challenge
Handling a Stripe webhook looks simple: receive a POST, update a row. Doing it reliably means keeping several things true at once.
- Respond fast. Stripe expects a quick
2xx. Slow responses get retried, which multiplies load and creates duplicates. Any real work has to happen after you answer. - Apply each event once. Delivery is at-least-once. The same event can arrive twice, so the effect on your data has to be idempotent.
- Survive out-of-order delivery. Stripe does not guarantee ordering. A stale
subscription.updatedcan show up after a newer one, so trusting the payload’s mutable fields will overwrite good state with old state. - Keep secrets out of your state file. The signing secret and API key are sensitive. They should never sit in Terraform state or a committed file.
- Keep infrastructure state honest. Reserved concurrency, queue policies, and IAM should all be declared in code so the next apply matches reality instead of fighting it.
The demo handles all five. Here is how.
The solution
The shape of the pipeline
The front door is a small web Lambda that serves Stripe’s managed pricing table and mints an anonymous id for the visitor. It passes that id to Checkout as client_reference_id, which is how a payment ties back to a user when the webhook lands.
The webhook Lambda itself does the minimum: verify the Stripe signature, drop the event on a queue, and return 200. Everything expensive runs downstream in a processor Lambda that reads from SQS. That split is what keeps the public endpoint fast and lets retries and buffering absorb spikes.
flowchart TD
User["Browser (demo_uid cookie)"] -->|"GET / , /status"| Web["web Lambda<br/>Function URL"]
User -->|"pick a plan"| Checkout["Stripe-hosted Checkout<br/>(managed pricing table)"]
Checkout -->|"success_url ?checkout=success"| Web
Stripe["Stripe"] -->|"webhook POST (signed)"| WH["webhook Lambda<br/>verify + enqueue, 200 fast"]
WH -->|"read secret"| S1[("SSM SecureString<br/>webhook_secret")]
WH -->|"SendMessage"| Q(["SQS billing-events"])
Q -->|"maxReceiveCount"| DLQ(["SQS dead-letter queue"])
Q -->|"event source mapping<br/>ReportBatchItemFailures"| PR["processor Lambda"]
PR -->|"read secret"| S2[("SSM SecureString<br/>api_key")]
PR -->|"fetch-on-webhook<br/>Subscription.retrieve"| Stripe
PR -->|"idempotent write"| DDB[("DynamoDB users")]
Web -->|"GetItem USER#uid"| DDB
classDef lam fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
classDef async fill:#fff3e0,stroke:#e65100,stroke-width:2px
class Web,WH,PR lam
class Q,DLQ async
The flow for a new subscription looks like this end to end:
sequenceDiagram
participant B as Browser
participant W as web Lambda
participant S as Stripe
participant H as webhook Lambda
participant Q as SQS (+DLQ)
participant X as processor Lambda
participant D as DynamoDB
B->>W: GET / (sets demo_uid cookie)
W-->>B: pricing table (client-reference-id = uid)
B->>S: pick plan, pay on hosted Checkout
S->>H: checkout.session.completed (signed)
H->>H: construct_event (verify signature)
H->>Q: SendMessage(event)
H-->>S: 200 (fast)
Q->>X: trigger (batch, ReportBatchItemFailures)
X->>X: claim EVENT#id (idempotency)
X->>S: Subscription.retrieve(sub_id)
X->>D: upsert USER#uid + SUB#sub pointer
B->>W: GET /status (poll)
W->>D: GetItem USER#uid
W-->>B: {plan, status, hasActiveSubscription}
The webhook: verify, then enqueue
The endpoint is a Lambda Function URL. Stripe calls it server-to-server, so there is no browser and no CORS to configure. Authenticity comes from the signature check. The handler verifies against the raw request bytes, wraps the event in an envelope, sends it to SQS, and answers Stripe.
def lambda_handler(event: dict, context) -> dict:
signature = _signature_header(event)
if not signature:
return _response(400, {"error": "Missing Stripe-Signature header"})
payload = _raw_body(event) # exact bytes; Stripe verifies against the raw body
if not payload:
return _response(400, {"error": "Missing request body"})
try:
stripe_event = stripe.Webhook.construct_event(
payload, signature, _get_webhook_secret(), tolerance=WEBHOOK_TOLERANCE_SECONDS
)
except ValueError:
return _response(400, {"error": "Invalid payload"})
except stripe.SignatureVerificationError:
return _response(400, {"error": "Invalid signature"})
# EventBridge-style envelope so the processor reads body["detail"].
message = {"detail-type": event_type, "source": "stripe.com/webhook", "detail": event_dict}
try:
_sqs_client().send_message(QueueUrl=SQS_QUEUE_URL, MessageBody=json.dumps(message, default=str))
except Exception: # return 500 so Stripe retries delivery
return _response(500, {"error": "Failed to queue event"})
return _response(200, {"status": "queued", "event_id": event_id})
(Trimmed for clarity.) Two details matter. construct_event runs against the raw body with a tolerance of 300 seconds, Stripe’s replay window; 0 would disable the check. And if the enqueue fails, the handler returns 500 so Stripe retries rather than losing the event.
The processor: idempotent and fetch-on-webhook
The processor is triggered by the queue. SQS delivers at-least-once, so each event first claims an idempotency key in DynamoDB with a conditional write. The claim carries a short lease, so an attempt that crashes mid-flight is reclaimed on the next delivery instead of stuck “in progress” forever.
def _claim_event(event_id: str) -> bool:
"""Claim an event id with an in-progress lease. Returns False if it is already DONE."""
now = int(time.time())
try:
_get_table().put_item(
Item={"pk": f"EVENT#{event_id}", "status": "IN_PROGRESS",
"lease_until": now + CLAIM_LEASE_SECONDS, "expires_at": now + EVENT_TTL_SECONDS},
ConditionExpression="attribute_not_exists(pk) OR (#s = :inprog AND #lu < :now)",
ExpressionAttributeNames={"#s": "status", "#lu": "lease_until"},
ExpressionAttributeValues={":inprog": "IN_PROGRESS", ":now": now},
)
return True
except ClientError as exc:
if exc.response["Error"]["Code"] != "ConditionalCheckFailedException":
raise
# already DONE -> settled duplicate; still IN_PROGRESS with a live lease -> retry later
...
The claim gates the whole flow. Only after claiming does the processor pull authoritative state from Stripe and write it, releasing the claim if anything throws so the message can retry or land in the dead-letter queue.
def _process_event(stripe_event: dict) -> None:
handler = HANDLERS.get(stripe_event.get("type"))
if handler is None:
return # unhandled type, no claim left behind
if not _claim_event(event_id):
return # already processed, skip
try:
_configure_stripe()
handler(stripe_event) # e.g. Subscription.retrieve + DynamoDB upsert
except Exception:
_unclaim_event(event_id) # release so the retry sees a fresh event
raise
_finalize_event(event_id) # mark DONE
The “fetch-on-webhook” part is what makes ordering safe. The handler never trusts the payload’s mutable fields; it re-reads the subscription from Stripe, so a late-arriving event cannot overwrite newer data.
def _handle_subscription_change(subscription: dict) -> None:
uid = _uid_for_subscription(subscription.get("id"))
if not uid:
return # no pointer for this subscription yet; skip
# Authoritative state comes from the API, not the event payload.
subscription = _to_plain_dict(stripe.Subscription.retrieve(subscription["id"]))
_upsert_user(uid, status=_map_status(subscription.get("status")),
plan=_resolve_plan(subscription), period_end=_period_end(subscription))
Subscription events do not carry your user id. The demo solves that with a SUB#<sub_id> pointer item written during checkout, so later customer.subscription.* events map back to the right user.
All of this lives in one on-demand DynamoDB table keyed by pk. A USER#<uid> item holds the plan, status, and period end; a SUB#<sub_id> item points back to the owning user; and an EVENT#<id> item is the idempotency claim, expired by a TTL once Stripe’s retry window passes. One table, no secondary index, since the pointer item does the lookup a GSI would handle.
Wiring it with Terragrunt explicit stacks
The whole app is one explicit stack that stamps out each unit and wires them together. Each Lambda unit declares the dependencies it needs, and the unit builds a least-privilege IAM policy from only the ARNs that function touches.
unit "webhook" {
source = "${values.catalog_root}//units/lambda${try(values.version, "")}"
path = "webhook"
values = {
context = values.context
component_name = "billing-webhook"
src_path = "../../src/webhook"
handler = "handler.lambda_handler"
create_function_url = true
# Reads only the webhook secret; sends to the queue (SQS_QUEUE_URL injected by the unit).
secrets_dependency_path = "../secrets"
secret_keys = ["webhook_secret"]
queue_dependency_path = "../queue"
queue_role = "producer"
}
}
unit "processor" {
source = "${values.catalog_root}//units/lambda${try(values.version, "")}"
path = "processor"
values = {
context = values.context
component_name = "stripe-billing-processor"
src_path = "../../src/processor"
handler = "handler.lambda_handler"
# Read/write the users table, read the api key, consume the queue.
table_dependency_path = "../users"
table_access = "write"
secrets_dependency_path = "../secrets"
secret_keys = ["api_key"]
queue_dependency_path = "../queue"
queue_role = "consumer"
batch_size = 10
sqs_maximum_concurrency = 5
}
}
(Trimmed for clarity.) The queue_role value drives the plumbing. A producer gets sqs:SendMessage and the queue URL as an environment variable. A consumer gets the event-source mapping, consume permissions, and partial-batch-failure reporting. That reporting is set in the OpenTofu module so only failed messages return to the queue:
resource "aws_lambda_event_source_mapping" "sqs" {
event_source_arn = var.sqs_event_source_arn
function_name = aws_lambda_function.this.arn
batch_size = var.batch_size
function_response_types = ["ReportBatchItemFailures"]
scaling_config {
maximum_concurrency = var.sqs_maximum_concurrency
}
}
Secrets are created as SSM SecureString placeholders. The real values are set out-of-band after apply, and the module ignores value changes so Terraform never reverts your secret back to the placeholder.
resource "aws_ssm_parameter" "managed" {
for_each = local.managed_params
name = each.value.name
type = each.value.type
value = each.value.value # placeholder only
lifecycle {
ignore_changes = [value] # real secret injected out-of-band after apply
}
}
After apply, you set the real values once with the CLI, and nothing sensitive touches state or git:
aws ssm put-parameter --overwrite --type SecureString \
--name /demo/stripe/webhook_secret --value 'whsec_...'
Running it
The prerequisites are OpenTofu 1.12 or newer, Terragrunt 1.0+ with stack support, the AWS provider ~> 6.0, and the python3.14 Lambda runtime. The Lambda packages pin the Stripe Python SDK to stripe>=15,<16. A small Makefile drives Terragrunt:
make apply ENVIRONMENT=demo STACK=app
Mapping to Stripe’s webhook best practices
Stripe publishes a checklist of webhook best practices. Here is where the demo handles each one.
| Best practice | Where it lives |
|---|---|
| Verify signatures with the official library on the raw body | construct_event over the exact request bytes |
Replay protection with a timestamp tolerance, never 0 |
tolerance of 300 seconds; stale events get a 400 |
Return 2xx quickly, handle events asynchronously |
200 right after the enqueue to SQS; the processor consumes |
| Handle duplicates and out-of-order delivery | EVENT#<id> conditional-write claim with a lease, plus fetch-on-webhook (Subscription.retrieve) and the SUB#<id> pointer |
| Subscribe to only the event types you need | configured on the Stripe endpoint |
| Require HTTPS and TLS 1.2+ | the Lambda Function URL terminates managed TLS |
| Retry and dead-letter | non-2xx triggers Stripe retries; SQS moves poison events to the DLQ after maxReceiveCount |
| Monitor delivery failures | a CloudWatch alarm on DLQ depth |
| Rotate the signing secret | stored in SSM; rotate in the dashboard and update the parameter |
Common pitfalls
These are the mistakes that turn a clean webhook pipeline into a flaky one. Each has a direct fix.
1. Doing database work before you answer Stripe. If your handler updates DynamoDB, calls the Stripe API, and only then returns, every slow request gets retried and duplicated. Verify the signature, enqueue, and return 200 first. All heavy work moves to the processor reading from SQS.
2. Assuming exactly-once delivery. SQS is at-least-once, and Stripe itself can send the same event more than once. Without a guard, a duplicate applies twice. The EVENT#<id> conditional-write claim makes processing idempotent, and the in-progress lease means an attempt killed by a timeout is reclaimed on redelivery rather than dropped for good.
3. Trusting the webhook payload’s fields and order. Stripe does not promise ordered delivery, so a late event carries stale data. Re-fetch the object from the API (Subscription.retrieve) and write that. Keep a SUB#<sub_id> pointer so subscription events, which lack your user id, still map back to the user.
4. Managing secrets in Terraform state. Putting the signing secret or API key in a variable or a committed file leaks them into state and version control. Create SSM SecureString parameters as placeholders, set the real values out-of-band, and use ignore_changes = [value] so applies leave them alone.
5. Reserving concurrency without quota headroom. A concurrency cap on a public, auth-less endpoint bounds blast radius and spend, but AWS keeps a floor of unreserved concurrency per account, so reserving N needs the Concurrent executions quota to stay above that floor. On an un-raised account (quota 10) any reservation is rejected, so raise the Service Quota first. For the SQS consumer, the right lever is the event-source mapping’s maximum_concurrency, since reserved concurrency there causes throttle-requeue-DLQ churn.
6. Non-deterministic packaging. A plain zip is sensitive to file order and timestamps, so the archive bytes change even when the code did not. That shifts source_code_hash and triggers a no-op Lambda redeploy on every apply. Use deterministic packaging so the hash stays stable when nothing changed.
One more habit worth keeping: watch the dead-letter queue. A message that fails maxReceiveCount times lands there instead of retrying forever, and a CloudWatch alarm on DLQ depth turns a silent failure into a signal. The queue’s visibility timeout is sized to six times the processor’s timeout, following AWS guidance, so a message is not redelivered while an attempt is still working on it. Skip an in-app IP allowlist as well, since signature verification already authenticates the call and Stripe’s published ranges change; do that filtering at the edge if you want defense in depth.
Notes for a real application
The demo skips the account layer on purpose so the webhook pipeline is the whole story. Promoting it to a real product changes a few things.
- Authenticate users before Checkout. The anonymous
demo_uidcookie is client-controlled, soclient_reference_idcannot be trusted as an identity, and since it is client-visible you should never put secrets in it. A real flow logs the user in, looks up or creates one Stripe Customer per user server-side, and passes both the user id and a Stripe customer session to Checkout, so entitlement checks run against the session rather than a cookie. - Move the public surface behind CloudFront. Serve the UI as a static SPA from S3, and put the webhook behind CloudFront, AWS WAF, and API Gateway with an origin-verify header. API Gateway stage/method throttling becomes the primary ingress rate-limit lever; reserved concurrency remains a downstream protection cap, not the main webhook rate limiter, and edge IP filtering against Stripe’s published ranges lives here if you want it.
- Cache the signing secret with a TTL. A rotation keeps the old and new secret valid together for up to 24 hours, so cache with a TTL rather than for the whole execution-environment lifetime, and the new secret is picked up promptly.
- Reach for a library for idempotency. The lease-based
EVENT#<id>claim is a hand-rolled version of AWS Lambda Powertools’ idempotency utility, which a real system can adopt instead. - Handle invoice events. Production also listens for
invoice.paidandinvoice.payment_failedto track payment state and drive dunning. - Or skip the public endpoint. Stripe event destinations can deliver to Amazon EventBridge, routing to the same SQS queue with IAM end to end and no signature verification; the processor reads the
detailenvelope the same way.
Wrapping up
Reliable Stripe webhooks on AWS come down to a few durable ideas. Answer fast by verifying and enqueuing, then process asynchronously. Make every write idempotent with a claim, and re-fetch authoritative state so ordering stops mattering. Keep secrets in SSM and out of state. Declare the queue, the concurrency caps, and the IAM in code so your infrastructure stays honest across applies. Those building blocks carry from this demo up to the production setup sketched above without changing shape.
Next step
Taking a system like this to production?
This demo keeps one reliable pipeline in focus, but the building blocks carry further — idempotent processing, least-privilege IAM, secrets kept out of state, and the whole thing declared in code. That's the work I do: cloud architecture, infrastructure as code, and the CI/CD around it. If you're building, migrating, or hardening systems on AWS, I can help.