Handling Webhook Payloads That Exceed Server Limits (Stripe, GitHub & Beyond)
Handling Webhook Payloads That Exceed Server Limits (Stripe, GitHub & Beyond) Webhooks are the backbone of event-driven architecture.

Handling Webhook Payloads That Exceed Server Limits (Stripe, GitHub & Beyond)
Webhooks are the backbone of event-driven architecture. They let platforms like GitHub, Stripe, Shopify, and Slack push real-time updates straight into your backend.
But as an integration scales, developers eventually hit the same wall: HTTP 413 Payload Too Large.
A big GitHub push touching thousands of files, or a heavy Stripe batch event, can balloon a request from a few kilobytes into tens of megabytes. When that hits a server or serverless function configured with default limits, the ingress layer drops the request before your application code ever sees it.
This guide covers why webhook payloads get rejected, how to raise the relevant limits (Nginx, Express, API Gateway), the architectural patterns that solve the problem properly, and where a managed webhook-delivery service fits in.
The Anatomy of a Large Webhook Payload
Most webhooks are compact — a typical charge.succeeded or issue_comment.created event is well under 20 KB. Payloads grow large in a few predictable situations:
GitHub push events and repository syncs. A push containing many commits, large diffs, or submodule updates can produce a sizeable JSON body. GitHub caps webhook payloads at 25 MB; if an event would generate something larger, GitHub simply does not deliver it at all rather than sending a truncated payload.
Stripe batch and subscription events. Deeply nested invoice line items or account-migration events can produce heavy JSON. The bigger operational risk with Stripe, though, isn't payload size — it's response time. Stripe expects your endpoint to return a 2xx quickly, and if processing takes too long, the delivery is marked failed and retried with exponential backoff for up to three days in live mode before Stripe gives up on that event.
Why Default Server Configurations Reject Large Bodies
Web servers and frameworks cap request body size by default, mainly as a guard against denial-of-service abuse. Here's where those defaults actually sit today:
| Infrastructure Layer | Default Payload Limit | Behavior on Limit Exceeded |
|---|---|---|
Express.js (express.json()) | 100 KB | Returns 413 Payload Too Large |
Nginx (client_max_body_size) | 1 MB | Returns 413 Request Entity Too Large |
| AWS API Gateway (REST / HTTP) | 10 MB, hard cap for buffered requests | Returns 413 Payload Too Large |
| AWS Lambda (synchronous invoke) | 6 MB | Returns 413 or invocation error |
| AWS Lambda (asynchronous invoke) | 1 MB (raised from 256 KB in Oct 2025) | Rejected before invocation |
| Cloudflare Workers | 100 MB (Free/Pro) · 200 MB (Business) · 500 MB (Enterprise default) | Returns 413 Request Entity Too Large |
Apache (LimitRequestBody) | 0 (unlimited) by directive default, though most distro configs cap it well below 1 GB | Returns 413 Request Entity Too Large |
A few things worth calling out, since this table shifts more often than people expect:
- AWS Lambda's async limit doubled recently. Asynchronous invocations (SNS, EventBridge, S3 notifications, direct async
Invokecalls) moved from 256 KB to 1 MB in October 2025, specifically to reduce the need for chunking or offloading LLM-style payloads and telemetry data. Synchronous invocations — the path most webhook receivers built on Lambda + API Gateway actually use — are still capped at 6 MB. - API Gateway's 10 MB ceiling is no longer absolute for responses. As of November 2025, API Gateway REST APIs support response streaming for backends that support it (Lambda, HTTP proxy, private integrations), which removes the 10 MB ceiling on the response side and extends integration timeouts to 15 minutes. The 10 MB limit on incoming request payloads is unchanged — this update helps you stream large data back out, not accept larger webhooks in.
- Cloudflare's request body limit is tied to your zone's plan, not a fixed number across all users. It's meaningfully higher than the 100 KB–50 MB range often quoted online — even the free tier allows up to 100 MB.
Step 1: Reconfiguring Server Ingress for Large Webhooks
If you control the server or reverse proxy, raising the body-size ceiling at the ingress layer is the first move.
Nginx
Nginx enforces a default client_max_body_size of 1 MB. A 5 MB GitHub push event gets rejected before it ever reaches your application process.
# /etc/nginx/sites-available/webhook-service.conf
server {
listen 443 ssl http2;
server_name webhooks.yourdomain.com;
# Expand body limit specifically for webhook endpoints
client_max_body_size 50M;
location /api/v1/webhooks/ {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Ensure long-running payloads don't drop the proxy connection
proxy_read_timeout 60s;
proxy_connect_timeout 60s;
}
}
Test and reload: sudo nginx -t && sudo systemctl reload nginx.
Node.js and Express
express.json() defaults to a 100 KB limit. You'll want to raise it — but the more important detail is preserving the raw, unparsed body buffer, since HMAC signature verification for GitHub and Stripe depends on hashing the exact bytes that were sent, not a re-serialized version of the parsed object.
import express, { Request, Response } from 'express';
const app = express();
interface AuthenticatedRequest extends Request {
rawBody?: Buffer;
}
// Support larger JSON payloads while capturing raw bytes for HMAC verification
app.use(
express.json({
limit: '50mb',
verify: (req: AuthenticatedRequest, res: Response, buf: Buffer) => {
req.rawBody = buf;
},
})
);
app.post('/webhooks/github', (req: AuthenticatedRequest, res: Response) => {
const signature = req.headers['x-hub-signature-256'];
if (!verifyGitHubSignature(req.rawBody, signature)) {
return res.status(401).send('Invalid signature');
}
// Acknowledge receipt immediately to avoid sender-side timeouts
res.status(200).send({ received: true });
// Process the payload asynchronously from here
});
app.listen(3000, () => console.log('Webhook receiver running on port 3000'));
Note: if you're handling Stripe webhooks specifically, Stripe's official guidance is to use express.raw() (not express.json()) on that route, since the SDK's signature-verification helper needs the raw body directly — running a JSON body-parser globally ahead of it will break verification.
Python (FastAPI)
In FastAPI or Flask behind Gunicorn/Uvicorn, check Content-Length before reading the full body into memory, and offload processing to a background task so you're not holding the connection open while you work.
from fastapi import FastAPI, Request, HTTPException, BackgroundTasks
app = FastAPI()
MAX_PAYLOAD_SIZE = 50 * 1024 * 1024 # 50 MB
@app.post("/webhooks/stripe")
async def handle_stripe_webhook(request: Request, background_tasks: BackgroundTasks):
content_length = request.headers.get("content-length")
if content_length and int(content_length) > MAX_PAYLOAD_SIZE:
raise HTTPException(status_code=413, detail="Payload exceeds 50MB limit")
body = await request.body()
if len(body) > MAX_PAYLOAD_SIZE:
raise HTTPException(status_code=413, detail="Payload exceeds 50MB limit")
background_tasks.add_task(process_heavy_payload, body)
return {"status": "accepted"}
Architectural Patterns for Handling Big Webhooks
Bumping ingress limits is a short-term patch. Parsing a 25 MB JSON blob synchronously inside your main application process creates real problems:
- CPU/event-loop starvation — parsing that much JSON blocks Node's single-threaded event loop or burns CPU cycles in Python.
- Provider timeouts — Stripe and most other providers expect a fast 2xx; slow processing gets marked as a failed delivery and retried, which can multiply your load during an incident.
- Hard cloud ceilings — API Gateway's 10 MB request limit is not configurable. You can't raise it, full stop; you have to route around it.
Two patterns solve this properly.
Pattern 1: The Claim-Check Pattern
Instead of pushing the full payload through your event bus or API layer, store it in object storage (S3, GCS) and pass a lightweight reference through your pipeline instead.
- A lightweight edge receiver accepts the large payload.
- It writes the full, unparsed JSON to S3/blob storage.
- It publishes a small reference event —
{ "event_id": "evt_123", "s3_key": "webhooks/2026/08/15/evt_123.json" }— to SQS, RabbitMQ, or Kafka. - It immediately responds
200 OKto the sender. - A worker fetches the full JSON from storage, processes it, and archives or deletes it per your retention policy.
Pattern 2: Thin Payloads and API Polling
Some providers support sending a minimal notification containing just an event type and a resource ID, rather than the full object:
{
"id": "evt_3MvL2e2eZvKYlo2C",
"type": "invoice.payment_succeeded",
"data": {
"object": {
"id": "in_1MvL2e2eZvKYlo2C"
}
}
}
Your handler validates the signature, enqueues the resource ID, returns 200 OK immediately, and a background worker calls the provider's REST API to pull the full object on demand.
- Advantage: keeps webhook payloads under a couple of KB, sidestepping size limits entirely.
- Trade-off: more outbound API calls, which means watching provider rate limits (Stripe, for example, allows 100 requests/second in live mode).
Where a Managed Webhook Layer Fits
Custom Nginx tuning, S3 buckets for the claim-check pattern, memory-safe streaming, and dead-letter queues add real engineering overhead — enough that a number of teams choose to put a managed layer in front of their own infrastructure instead of building all of this themselves.
This is a genuine product category, not just a hypothetical: Svix (which also maintains the open Standard Webhooks spec) and Hookdeck are established, independently verifiable options that handle intake, retries, and signature verification as a hosted service. Newer, smaller entrants like InstaWebhook offer a similar shape of product — durable intake, encrypted payload storage, retry/replay with visible delivery states, and either hosted or "bring your own database" storage modes.
Whichever you evaluate, the pitch is the same: a buffer sits between the provider and your backend, accepts the payload immediately (so you never return a 413 or a timeout to GitHub or Stripe), stores it durably, and hands your application a smaller, already-validated payload — or lets you pull it on your own schedule.
Worth being clear-eyed about: this shifts where the payload-size problem is handled, not whether one exists — you're trading in-house infrastructure work for a vendor dependency and, in hosted mode, having your payloads pass through a third party's storage. For sensitive data, check whether a given provider offers a self-hosted or BYO-database storage mode before committing.
Technical Checklist for Handling Webhook Payloads
- Ingress limit verification. Confirm Nginx, HAProxy, or Cloudflare rules allow body sizes matching the largest payload you actually expect (25 MB+ for GitHub headroom, for example).
- Preserve raw bytes. Don't run
JSON.parse()or a body-parser before validating the HMAC signature — re-serialization changes whitespace and key order, which breaks signature validation. - Respond fast, process async. Return 200/202 within a couple of seconds of receipt; hand parsing and database writes to a background worker (Celery, BullMQ, Sidekiq).
- Idempotency. Log delivery IDs (
X-GitHub-Delivery, Stripe'sevt_...) with a TTL so retried deliveries don't get processed twice. - Claim-check for anything near a hard cloud ceiling. If you're on API Gateway (10 MB) or a similarly fixed limit, route large payloads through S3 rather than trying to raise a limit that can't be raised.
Conclusion
A 413 error is close to a rite of passage for a growing integration surface. Raising ingress limits in Nginx or Express buys you immediate relief, but durable stability comes from decoupling ingestion from processing — via the claim-check pattern, thin payloads plus polling, or a managed intake layer that takes the buffering problem off your plate entirely.
Sources
- GitHub Docs — Webhook events and payloads (25 MB payload cap)
- Stripe Docs — Receive Stripe events in your webhook endpoint (retry window, response-time expectations)
- AWS — API Gateway response streaming for REST APIs (Nov 2025)
- AWS — Lambda asynchronous payload increase to 1 MB (Oct 2025)
- AWS Lambda quotas
- Cloudflare Workers — Platform limits (request body size by plan)
- Express.js — body-parser middleware docs (100 KB default)