Help us improve
Share bugs, ideas, or general feedback.
From pagokit
Reference for cryptographic verification of payment webhooks. Documents per-provider signature algorithms, timestamp tolerances, replay-protection strategies, and raw-body capture per stack.
npx claudepluginhub hainrixz/agente-pagokitHow this skill is triggered — by the user, by Claude, or both
Slash command
/pagokit:webhook-verifierWhen to use
- integration-specialist is about to write or edit a webhook handler - /pagokit:doctor audits the signature pattern of an existing handler - The user asks "is my Stripe webhook signature check correct?" or equivalent
This skill is limited to the following tools:
The summary Claude sees in its skill listing — used to decide when to auto-load this skill
You are the single source of truth for "how a webhook is verified for provider X on stack Y". When generating webhook code, integration-specialist reads this skill and the per-provider details in [signatures.md](./signatures.md), and produces a handler that:
Runs a multi-phase verification pipeline for Laravel projects: environment checks, Composer validation, linting (Pint), static analysis (PHPStan/Psalm), tests with coverage, security audit (Composer audit), migration review, build readiness (config/route/view cache), and queue/scheduler checks.
Share bugs, ideas, or general feedback.
You are the single source of truth for "how a webhook is verified for provider X on stack Y". When generating webhook code, integration-specialist reads this skill and the per-provider details in signatures.md, and produces a handler that:
providers.json).event.id, event.type, event.created — never the full payload.A correct webhook handler has this shape (language-neutral):
1. Read Content-Length header → if > 256 KB, return 413.
2. Read raw body (bytes, NOT parsed JSON).
3. Read signature header (provider-specific).
4. Verify signature → if invalid, return 400 with no leak about why.
5. Parse JSON from raw body now that signature is verified.
6. Apply replay protection:
a. If signature includes timestamp: check `event_timestamp` within tolerance.
b. Otherwise: check event.id against webhook_events_processed table.
7. If duplicate (already processed): return 200 OK (idempotent), do nothing.
8. Dispatch to handler by event.type.
9. Mark event as processed.
10. Return 200 OK.
Next.js App Router — the most common mistake:
// app/api/webhook/<provider>/route.ts
export const runtime = 'nodejs'; // NOT 'edge'
export async function POST(request: Request) {
const rawBody = await request.text(); // raw string
const signature = request.headers.get('<signature-header>');
// pass rawBody (string) to provider's verifier
}
Next.js Pages Router:
// pages/api/webhook/<provider>.ts
export const config = { api: { bodyParser: false } };
import { buffer } from 'micro';
export default async function handler(req, res) {
const rawBody = await buffer(req); // Buffer
// pass rawBody.toString() to verifier
}
Express:
// Critical: register raw BEFORE express.json() globally
app.post('/api/webhook/<provider>',
express.raw({ type: 'application/json', limit: '256kb' }),
async (req, res) => {
const rawBody = req.body; // Buffer because of express.raw
const signature = req.headers['<signature-header>'];
// ...
}
);
FastAPI:
from fastapi import Request, HTTPException
@app.post("/api/webhook/<provider>")
async def webhook(request: Request):
if int(request.headers.get("content-length", 0)) > 262_144:
raise HTTPException(413)
raw_body = await request.body() # bytes; never .json() before verify
signature = request.headers.get("<signature-header>")
# ...
Laravel:
$rawBody = $request->getContent(); // string
$signature = $request->header('<signature-header>');
Rails:
raw_body = request.raw_post
signature = request.headers['<signature-header>']
See signatures.md for the full table:
timestamp-window | event-id-dedup | both)| Situation | Response | Why |
|---|---|---|
| Signature invalid | 400 Bad Request, body: empty or { "error": "invalid_signature" } | Don't leak why it failed; force attacker to guess. |
| Body > 256 KB | 413 Payload Too Large | DoS guard. |
| Replay (old timestamp) | 400 Bad Request | Same as bad signature from the attacker's POV. |
| Duplicate event.id | 200 OK, no-op | Idempotency: provider may legitimately retry. |
| Handler threw an exception | 500 Internal Server Error | Provider will retry; check webhook_events_processed to dedup the retry. |
| Event type not handled | 200 OK, log TODO | Avoid being unsubscribed for non-200 responses. |
// @pagokit:signature-verified tagThe webhook-has-signature.js validator detects standard calls (stripe.webhooks.constructEvent, Wompi.verifyEventChecksum, etc.). If you generate code that wraps verification in a helper from lib/auth/, place this tag on the handler function so the validator knows it's covered:
// @pagokit:signature-verified -- uses lib/auth/verifyStripeWebhook
export async function POST(request: Request) { … }
Bypassing the rule entirely (rare) uses the different // pagokit-ignore: syntax — see SECURITY_RULES.md Rule 3.
await request.json() before signature verification — breaks the HMAC.event.type from the parsed JSON before verifying.process.env without checking .env is gitignored.STRIPE_SECRET_KEY (the API key) instead of STRIPE_WEBHOOK_SECRET (a different secret with whsec_ prefix).