← All articles
AWSStripeLambdaOpenTofuTerragruntDevOps

Robust Stripe Webhooks on AWS Lambda with OpenTofu and Terragrunt

Joakim Bengtsson

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.updated can 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_uid cookie is client-controlled, so client_reference_id cannot 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.paid and invoice.payment_failed to 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 detail envelope 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.