Overview
Payment processor webhooks are the primary mechanism for asynchronous event delivery — charge confirmations, payout status updates, refund completions, and dispute notifications. Because these endpoints are publicly accessible and directly affect financial records, CommunityPay applies layered security controls at every webhook entry point.
All provider events enter through one endpoint and pass the same checks, in the same order, before any handler runs. Events for connected accounts are routed by domain to the handler for that kind of event: vendor payments, onboarding, resident payments, bank changes, and payouts.
The Sequence
Before the endpoint runs, the rate-limit middleware applies the per-provider limit described below. The endpoint then applies these steps in order:
- Payload size. A request whose
Content-Lengthis over 512 KB is rejected with HTTP 413 before the body is read. - Provider. The provider must be one of the four the endpoint serves. Anything else is rejected with HTTP 400.
- Endpoint rate limit. A flat limit of 100 requests a minute per provider and IP applies here, in addition to the middleware limit. A request over it receives HTTP 429.
- Signature present. A request with no signature header is rejected with HTTP 401.
- Secret configured. If the provider's signing secret is not configured, the request is rejected.
- Signature verified. The signature is checked against the raw body before any processing. A failure is rejected with HTTP 401.
- Idempotency. The event id is looked up. A repeat of an event already processing or processed is acknowledged and skipped, so a replay does not process twice. A new event is recorded with its type and signature.
- Handler. The provider's gateway handler runs. The event's record is marked processed or failed.
Rate Limiting
The WebhookRateLimitMiddleware applies per-provider, per-IP rate limits to all webhook endpoints:
| Provider | Rate Limit | Window |
|---|---|---|
| Stripe | 100 requests/minute | 60 seconds |
| Dwolla | 60 requests/minute | 60 seconds |
| Plaid | 50 requests/minute | 60 seconds |
| SES (SNS bounce, complaint, and invoice inbox events) | 300 requests/minute | 60 seconds |
| Unknown | 30 requests/minute | 60 seconds |
The middleware:
- Detects webhook endpoints by URL path matching (
/webhookin path) - Extracts the provider name from the URL structure
- Constructs a per-provider, per-IP cache key
- Counts requests in a cache entry that expires 60 seconds after the last request it counted
- Returns HTTP 429 (Too Many Requests) when limits are exceeded
Rate limit violations are logged with the offending IP address for security monitoring.
Signature Verification
Each provider has its own scheme.
Stripe Verification
Stripe webhooks use a composite signature header (Stripe-Signature) containing a timestamp and one or more HMAC-SHA256 signatures:
- Parse the signature header to extract timestamp (
t=) and signature values (v1=) - Validate timestamp: Reject if the timestamp differs from the server clock by more than 300 seconds (5-minute replay window)
- Compute expected signature: HMAC-SHA256 of
{timestamp}.{payload}over the exact raw bytes received, using the webhook signing secret - Compare: Use
hmac.compare_digestfor constant-time comparison, preventing timing-based attacks
Events from connected accounts are normally signed with a separate signing secret from platform events. The endpoint tries the secret that matches the event's kind first and then the other, so an event signed with either secret verifies. Neither the signatures, the secrets, nor the payload are written to the logs, because anyone reading the logs could otherwise replay a forged event inside the tolerance window.
Dwolla Verification
Dwolla uses a simpler HMAC-SHA256 signature:
- Compute HMAC-SHA256 of the raw payload using the webhook secret
- Compare against the
X-Request-Signature-SHA-256header usinghmac.compare_digest
Plaid Verification
Plaid delivers a JWT in the Plaid-Verification header. Verification of that JWT is not implemented, so the endpoint rejects every Plaid webhook and fails closed. Nothing from Plaid is processed.
Payload Size Limits
- Maximum: 512 KB, read from the
Content-Lengthheader before the body is processed - Oversized requests are rejected with HTTP 413
- A request that sends no
Content-Lengthheader is not checked at this step - This prevents memory exhaustion attacks on webhook endpoints
Replay Attack Prevention
Two controls work together:
- A signed timestamp. For Stripe, the signed payload includes the timestamp, so a webhook cannot be replayed with a forged timestamp, and a genuine one older than 300 seconds is rejected.
- A record of every event id. A repeat of an event id already processing or processed is acknowledged and skipped. A replay inside the window does not process twice.
What a Webhook May and May Not Do
A webhook stamps the record it concerns. It does not post a ledger entry for a vendor payment: the posting happens inside the journal engine, under the enforcement guards, and the processor's event only stamps the payment record with what happened — a payout paid or failed, for example. A failed vendor payout reverses its entry and reopens the bill.
Bank and payout events also trigger a check of the association's pinned payout bank against the processor's default bank, and the same check runs hourly. If they disagree, automatic payouts pause until a person releases them.
Security Properties
| Property | Mechanism |
|---|---|
| Authentication | HMAC-SHA256 signature per provider; Plaid rejected until verification is implemented |
| Integrity | Signature covers the full raw payload |
| Freshness | Timestamp validation (300s window) |
| Idempotency | Every event id is recorded; a repeat is skipped |
| Availability | Per-provider rate limiting |
| Size bounds | 512 KB limit before processing |
| Timing safety | hmac.compare_digest constant-time comparison |
How CommunityPay Enforces This
- Per-provider rate limits enforced via middleware (Stripe: 100/min, Dwolla: 60/min, Plaid: 50/min)
- HMAC-SHA256 signature verification with constant-time comparison (hmac.compare_digest), before any processing
- Replay attack prevention via 300-second timestamp validation window, and every event id recorded so a repeat is skipped
- Payload size limit: a request over 512 KB is rejected before processing
- Plaid webhooks are rejected until verification is implemented; the endpoint fails closed