How webhooks work
A webhook is just a POST request
You already know what a POST request looks like. A webhook is exactly that, but in reverse. Instead of your server sending a POST to an external service, the external service sends a POST to your server.
When a payment succeeds on Stripe, Stripe sends a POST request to the webhook URL you registered. The request body contains all the details about what happened: who paid, how much, what plan they're on.
Your server receives it, reads the data, and acts on it (update the database, send a confirmation email, unlock a feature). From Stripe's point of view, it's just sending a request. From your server's point of view, it's receiving one.
Anatomy of a Stripe webhook
Let's break down what a real webhook looks like when Stripe sends it to your server.
Notice what's familiar:
- Method: POST, just like creating a resource. The external service is "creating" an event notification on your server.
- URL: your webhook endpoint, the URL you registered with Stripe.
- Headers: include
Content-Type(it's JSON) and aStripe-Signatureheader (more on this below). - Body: a JSON payload with the event details. The
typefield tells you what happened, anddata.objectcontains the specifics.
This is the same request structure you learned in Chapter 2. The only difference: you're on the receiving end.
Event types
External services don't send one generic "something happened" webhook. They send specific event types that tell you exactly what occurred. Every service names its events differently. Here's what Stripe's look like:
| Event type | When it fires | What you'd do with it |
|---|---|---|
| payment_intent.succeeded | A payment goes through | Activate the customer's subscription |
| customer.subscription.deleted | A subscription is canceled | Downgrade the account to the free plan |
| invoice.payment_failed | A payment attempt fails | Show a warning banner, send a retry email |
And here's how Shopify names its webhook events:
| Event type | When it fires | What you'd do with it |
|---|---|---|
| orders/create | A customer places an order | Start the fulfillment process |
| products/update | A product is edited | Sync the change to your catalog |
| customers/delete | A customer account is removed | Clean up related data |
Different naming conventions (Stripe uses dots, Shopify uses slashes), but the same idea. When you register your webhook URL, you choose which event types to subscribe to. You don't have to listen to everything. If you only care about new orders, you only subscribe to orders/create.
Your engineering team will use the event type in the webhook payload to route it to the right handler: "if the type is payment_intent.succeeded, update the user's plan; if it's customer.subscription.deleted, downgrade them."
See it in action
Imagine your app has a Pro plan that customers pay for through Stripe. When a customer upgrades or cancels on your app, Stripe processes the payment and sends a webhook to your server so it can update the database.
Click a button below to simulate a customer action on your app. Watch the webhook flow from Stripe to your server.
Incoming webhook
Database
| Name | Plan |
|---|---|
| Alice | free |
| Bob | free |
| Clara | pro |
This is exactly what happens in production. A customer does something on your app, Stripe processes it, and your server finds out through a webhook. The only difference is that in real life, the POST travels over the internet instead of happening inside your browser.
Security: how do you know it's really Stripe?
Here's a problem. Your webhook URL is just a URL. Anyone who knows it could send a fake POST request to it, pretending to be Stripe. Imagine someone sending a fake "payment succeeded" event for a customer who never paid. Your server would upgrade them for free.
That's why webhook providers include a signature in every request. Stripe adds a Stripe-Signature header that your server can verify. The signature is generated using a secret key that only you and Stripe know. When your server receives a webhook, it recalculates the signature using the same secret. If the signatures match, the request is legitimate. If they don't, someone is trying to spoof it.
You don't need to implement this yourself. But when your team is setting up a webhook integration, you might hear them mention "verifying the webhook signature" or "checking the signing secret." Now you know what that means: making sure the request actually came from Stripe and not from an impersonator.
Retries: what if your server is down?
What happens if Stripe sends a webhook and your server is temporarily unavailable? The request fails. Does Stripe just give up?
No. Webhook providers retry failed deliveries. Stripe will try again after a few minutes, then again after a longer delay, and keep retrying with increasing intervals (this is called exponential backoff). If your server is down for a few minutes, you won't miss any events. They'll arrive once your server is back up.
This also means your server might receive the same event more than once. If Stripe sends a webhook but your server is slow to respond, Stripe might assume it failed and send it again. Good webhook handlers are built to handle duplicates gracefully (this is called idempotency). Again, not something you build yourself, but a term you might hear your team use.
Key takeaways
- A webhook is a POST request sent by an external service to your server. Same structure as any API request.
- Each webhook has an event type (like
payment_intent.succeeded) that tells your server what happened. - The JSON payload contains all the details your server needs to act on the event.
- Providers include a signature for security, so your server can verify the request is genuine.
- If delivery fails, providers retry with increasing delays. Your server won't miss events.
- Webhooks complete the picture: APIs let you ask for data, webhooks let data come to you.