Skip to main content
INS // Insights

Custom AWS Inbound Webhook Gateway

Updated October 2026 · 8 min read

A custom AWS inbound webhook gateway is the front door we build when a SaaS vendor needs to POST events into an application. The vendor calls Amazon API Gateway. A Lambda function checks the signature on the raw body. A valid call is written to Amazon SQS and answered with success. A consumer reads the queue and writes the database. A retry storm lands on the queue, and the database sees only the consumer.

Rutagon builds this when the alternative is a public route on the application that inserts a row inside the HTTP request. That route works on a quiet day. It fails when the vendor times out, retries, and the database is the thing getting slower. API Gateway patterns cover the edge for APIs you own. This article is the inbound webhook, which is an API you did not design and still have to accept.

A custom AWS inbound webhook gateway, request then queue

API Gateway is the front door. The API Gateway overview describes it as the place that accepts calls and forwards them to Lambda or to another backend, with throttling, authorization, and logging in front of the code. We use an HTTP API for this gateway. The route is a single POST. Mutual TLS or a resource policy can narrow who reaches it. The signature check is still in the function, because the vendor's signature is the proof that this body came from them, and a network allow-list is only the proof that a packet arrived.

The account quotas are the capacity fact we size against. The default throttle is 10,000 requests per second per account per Region, shared across HTTP APIs, REST APIs, and WebSocket APIs, with a burst bucket of 5,000 requests. A list of Regions, including Africa (Cape Town), Europe (Milan), and Asia Pacific (Jakarta), defaults to 2,500 requests per second and a 1,250 burst. A webhook shares that quota with every other API in the account and Region. A vendor replay that lands in the same Region as the product API can consume the burst. We put the webhook in an account or a stage where that sharing is a choice, and we alarm on 429s.

The function reads the signing secret from AWS Secrets Manager at init, or from a cache it refreshes. The secret is not an environment variable checked into the template. The function computes the signature over the raw body, using the scheme the vendor documents, and compares it in constant time to the header they sent. A mismatch returns 401 and does not call SendMessage. A match sends one message and returns 200. The message body is the raw event plus the vendor's event id. We do not parse the payload into a database row in this function.

edge: API Gateway HTTP API, one POST route
check: Lambda, vendor signature over the raw body, constant-time compare
secret: Secrets Manager, read by the function role
reject: 401, no SQS write
accept: SendMessage, then 200
consumer: long poll, skip a vendor event id we already applied, then the database

SQS is the buffer. Every SendMessage is a request, and a batch from the consumer can take up to 10 messages. The consumer uses a wait greater than zero so an idle worker is not in a tight receive loop. A dead-letter queue takes messages that fail the database write past the receive-count we set. The vendor's retry and our retry are different. The vendor retries when we fail the HTTP call. We retry inside the queue when the database fails. Once we have returned 200, the vendor's retry should stop, and our consumer owns the rest.

Idempotency sits on the consumer. Vendors resend events after a 200 if their own bookkeeping hiccups, and they resend after a timeout if our 200 was slow to leave. The consumer records the vendor event id before it commits the row, or in the same transaction as the row. A second delivery of the same id is a delete of the message and no second write. We do not depend on the gateway to dedupe. Two valid, signed deliveries of one event are both legitimate HTTP calls.

What we keep off the database

The failure we are building against is a webhook handler that opens a database transaction before it returns. A lock, a slow query, or a deploy makes the request miss the vendor's timeout. The vendor retries. The first transaction may still commit. The second starts. The table now has two rows, or one row and a pile of errors, and the vendor is still posting. The gateway splits that. The HTTP path does a signature check and a queue send, which are bounded. The database path runs at the consumer's concurrency, which we set. If the database is down, messages pile up in the queue and the vendor still gets 200 for events we accepted. We alarm on queue age. We do not alarm by watching the vendor's retry dashboard.

The function's IAM role can read one secret and send to one queue. It cannot connect to the database. The consumer role can receive from the queue and write to the database. It cannot read the signing secret. That split is the proof we show: a stolen function role can enqueue a message only after it already passed the signature check in code, and it still cannot write the table. A stolen consumer role can write the table and cannot forge a vendor call. We keep the two roles in the template so a later "quick fix" does not merge them.

Logging is the other half of the proof. API Gateway access logs record the request id, the status, and the latency. They do not record the body. The function logs the vendor event id and the verify result, not the secret and not the full payload when the payload has customer data. SQS records the message id. The consumer logs the event id and the database result. A trace of one webhook is those four ids, joined. Event-driven integration is the same shape for events that start inside AWS. The webhook is the case where the event starts outside, so the signature is the extra step.

We version the route. Vendors change signature headers and add new event types. The function rejects an unknown version with 400 after the signature check, so a forged body still fails closed, and a real new event type parks on a metric instead of landing in the database half-parsed. The consumer has a handler per event type. An unknown type goes to the dead-letter queue with the body intact. We add the handler, then we redrive. We do not drop the message to keep the queue green.

What we shipped, and what we left on the vendor

The resources are an HTTP API, a function, a secret, a queue, a dead-letter queue, and a consumer. The behavior we test is small. A bad signature returns 401 and the queue depth does not move. A good signature returns 200, one message appears, and a second delivery of the same event id does not create a second row. A consumer crash leaves the message, and it reappears after the visibility timeout. A poison message reaches the dead-letter queue and the vendor is not involved.

We leave the vendor's retry policy alone. We answer quickly so their retry is the exception. We do not ask the vendor to slow down as the primary control. The account throttle is the backstop, and 429 is a signal we page on, because a throttled webhook is an event the vendor will retry later. If the product API and the webhook share a Regional quota, we split them before a product launch shares a burst of 5,000 with a replay of a year's events.

Rutagon runs this as an architecture diagnostic when the webhook already hits the database, and as a build when it does not exist yet. We name the vendor header, the secret, the queue, the idempotency key, and the role split. The API Gateway quota page is the source for the 10,000 requests per second default and the shared burst. The queue is the source of the buffer. The database sees a consumer that already decided the event was new.

Frequently Asked Questions

What happens when the webhook writes the database directly?

The vendor retries when the HTTP call fails or times out, and a handler that holds a database transaction ties that retry to query latency. The gateway checks the signature, enqueues the body, and returns 200. The consumer writes the database at a concurrency we choose. A slow database grows the queue while the vendor has already been answered.

Where does the signature check run?

In the Lambda function, over the raw body, with the secret from Secrets Manager. A mismatch returns 401 and does not send to SQS. API Gateway can still throttle, authorize with IAM, or require mutual TLS, and those checks sit beside the vendor signature. The signature is what ties this body to that vendor.

What happens when the same event arrives twice?

Both calls can be valid and signed. The consumer stores the vendor event id, skips a second write when the id is already applied, and deletes the duplicate message. The unique id belongs on the database transaction. Deduping only at the gateway would hide a second delivery that we had already acknowledged.

How does this share API Gateway capacity?

The default account throttle is 10,000 requests per second per Region, shared by HTTP, REST, and WebSocket APIs, with a burst of 5,000. Some Regions default to 2,500 per second and a 1,250 burst. A webhook replay uses that same quota. We alarm on throttles and we separate the webhook from the product API when they would steal each other's burst.

What proves the build works?

A bad signature leaves the queue depth unchanged. A good signature enqueues one message, and a repeated event id does not insert a second row. A message that fails the consumer moves to the dead-letter queue after the receive count, with no further call to the vendor. The function role cannot reach the database, and the consumer role cannot read the signing secret.

Ask for an architecture diagnostic of the webhook path, or a build of the gateway in front of the database. Contact Rutagon at rutagon.com/contact, 907-841-8407, or contact@rutagon.com.