Receiving webhooks without double charges
webhook-handlerFREEwebhookspayments
Signature, retries and idempotency — the four rules that stop one payment extending access twice.
What it does
- Triggers on “add a callback endpoint”, “integrate payments”, “it was processed twice”.
- Starts from the fact that the sender will deliver the same event more than once, out of order, and sometimes much later.
- Requires verifying the signature over raw bytes rather than reparsed and reserialised JSON: reserialising changes key order, and the signature stops matching for a reason nobody finds quickly.
- Stores the provider's event id under a unique constraint and does the check and the work in one transaction — two statements race, and under retries they will.
- Answers 200 as early as possible and works afterwards: work before the response eats the sender's timeout, and a timeout is another retry.
- Requires 200 even for event types you ignore: a 4xx makes the sender retry forever and eventually disable the endpoint.
- On a failed signature check, logs the event id and nothing else — not the body, not the header, not the computed digest.
Why you'd want it
A provider sends a notification on every status change and retries until it sees success. Without a key on the event id, one payment extends a subscription twice and nobody notices — until accounting does, a month later. Verifying the signature against reserialised JSON, and working before answering, are the two other mistakes almost everyone makes.
Where to put it
- 1Create the folder .claude/skills/webhook-handler in your project
- 2Put a SKILL.md file in it with the text below
- 3That's it. Claude Code loads the skill itself when a task matches the description
To make the skill available in every project rather than one, put it in ~/.claude/skills instead of the project folder.
SKILL.md file
--- name: webhook-handler description: Receive webhooks from a payment provider or external service correctly. Use when the user adds a callback endpoint, integrates payments, or reports that something was processed twice. --- # Receiving a webhook The sender will deliver the same event more than once, out of order, and sometimes months late. It will retry until you answer 200. Everything below follows from that. ## The four rules **1. Verify the signature before reading the body.** Compute it over the raw bytes, not over the parsed and re-serialised JSON — reserialising changes key order and whitespace, and the signature stops matching for reasons nobody finds quickly. Compare in constant time. An unsigned endpoint that grants access is a free access endpoint for anyone who reads your docs. **2. Be idempotent.** Store the provider's event id, unique-constrained, and check it before acting. Do the check and the work in one transaction — two separate statements race, and under retries they will race, because retries arrive in bursts. This is what stops one payment extending a subscription twice. **3. Answer fast, work after.** Return 200 as soon as the event is stored. Work done before the response counts against the sender's timeout, and a timeout means a retry, which means the same work again. **4. Answer 200 for events you ignore.** A 4xx to an event type you do not handle makes the sender retry it forever and eventually disable the endpoint. ## What to log and what never to log Log the event id, the type and the outcome. On a failed signature check, log the event id and nothing else — not the body, not the header, not the computed digest. Those end up in a log aggregator that more people can read than you think. Never log the secret. Never accept it from a query parameter. ## Test it - Send the same event twice and check the effect happened once. - Send it with a wrong signature and check nothing happened. - Send an unknown event type and check the answer is 200. - Send an event for an object that does not exist locally and check the handler does not crash the endpoint for every subsequent event. ## Before finishing State plainly what happens if the endpoint is down for an hour. If the answer is "those events are lost", say it — most providers retry for days, but only if you answered with an error rather than a 200.