Stripe or Mercado Pago webhook not received
The customer paid, saw «payment approved», and the order is still «pending» in your system. It's almost always a webhook that never arrived, or one that arrived and failed. Here's how to tell which, the most common causes, code that answers it correctly, and how to find out before the customer writes to you.
1. What it looks like
The charge happens on Stripe or Mercado Pago. Your system learns about it through a webhook: a request they send to your server when the payment changes. If that request never arrives or your server answers with an error, this happens:
- In the Stripe or Mercado Pago dashboard, the payment is approved.
- In your system, the order is still pending: nobody ships it, no confirmation email goes out.
- Nothing crashes. No error on screen or in your logs, unless your code failed while receiving it.
You find out when the customer writes «I already paid, where's my order?». The rest of this guide is about finding out sooner.
2. Was it sent? Check the dashboard
First, find out whether the provider sent it and what your server answered. Both keep a history.
Stripe
- In Workbench, open the Webhooks tab, pick your endpoint and go to Events. Each delivery shows the status code your server returned.
- In live mode, Stripe retries for up to three days with exponential backoff. You can see when the next attempt is due right there.
- You can resend an event with the «Resend» button up to 15 days after it was created, or with the CLI up to 30 days:
stripe events resend <event_id> --webhook-endpoint=<endpoint_id>.
Mercado Pago
- Go to Your integrations («Tus integraciones»), pick your application and open Webhooks.
- The notifications panel lists each delivery with its result (successful or failed), action, event and time. You can filter by status and date, and open each one to see the JSON that was sent.
- Mercado Pago waits 22 seconds for a
200or201. If it doesn't get one, it retries every 15 minutes; after the third attempt it spaces them out, but keeps going.
3. The most common causes
What the dashboard says points almost straight at the cause.
| What you see | What it usually is | How to fix it |
|---|---|---|
| No deliveries at all | The URL isn't configured, the event isn't selected, or you're mixing test and live mode. In Mercado Pago, a notification_url on the preference sends it somewhere else. | Check the URL and events for the right mode (test and live have different URLs and keys). |
400 or 401 | Your server rejected the signature. On Stripe, almost always because the body was parsed as JSON before verification: «No signatures found matching the expected signature for payload». | Verify against the untouched body, with that endpoint's secret for that mode. On Mercado Pago, the secret key of that same application. |
| Timed out | You do everything (save, send emails, invoice) before answering. | Answer first, process afterwards (code below). |
500 | Your code crashes: the order wasn't found, the API call used the other mode's token, a column changed. | Search your logs for the event id. While you fix it, the provider keeps retrying. |
301, 302 or 404 | The URL changed: http to https, with or without www, or a route that no longer exists. Stripe treats any redirect as a failure. | Use the exact final URL, the one that answers without redirecting. |
200, but the order didn't change | You answered 200 and then processing failed; a different event arrived than the one you expected; or it arrived twice, or out of order. | Record every event that arrives with its id, and don't assume order: check the payment's real status. |
| OXXO payments on Stripe | checkout.session.completed fires when the customer gets their voucher, with payment_status: "unpaid". The money arrives hours or days later. | Fulfill on checkout.session.async_payment_succeeded, not on completed. |
data.id). To know whether it was approved, fetch it from /v1/payments/{id} with the access token from the same account and the same mode.4. Answering it correctly
The rule is the same for both: verify the signature, answer fast, process afterwards. And log that it arrived, so you can look it up.
Stripe, with Express
// the untouched body ONLY on this route; express.json() goes after
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.body, req.headers['stripe-signature'], process.env.STRIPE_WEBHOOK_SECRET);
} catch (err) {
console.error('stripe webhook: bad signature', err.message);
return res.sendStatus(400);
}
res.sendStatus(200); // answer first
handle(event).catch((err) => console.error('stripe webhook', event.id, err));
});
Mercado Pago, with Express
app.post('/webhooks/mercadopago', express.json(), (req, res) => {
// check x-signature with your application's secret key (the official SDKs do it)
if (!validSignature(req)) return res.sendStatus(401);
res.sendStatus(200); // within 22 seconds
if (req.body.type !== 'payment') return;
checkPayment(req.body.data.id).catch((err) => console.error('mp webhook', req.body.data.id, err));
});
async function checkPayment(id) {
const r = await fetch(`https://api.mercadopago.com/v1/payments/${id}`, {
headers: { Authorization: `Bearer ${process.env.MP_ACCESS_TOKEN}` },
});
const payment = await r.json();
// payment.status: approved, pending, rejected… payment.external_reference: your order
if (payment.status === 'approved') await markPaid(payment.external_reference);
}
Answering before processing has a cost: if handle fails, the provider won't retry, because you told it everything was fine. That's why the next step matters.
5. Don't rely on the webhook alone
The webhook is the fast path, not the only one. Two cheap backups:
- When the customer lands on your «thank you» page (
success_urlon Stripe,back_urlson Mercado Pago), check the payment yourself. If it's already approved, mark the order: don't wait for the webhook. - A job every 15 minutes that finds orders «pending» for more than half an hour and asks for their status. On Stripe, the Checkout Session or PaymentIntent; on Mercado Pago, by
external_reference.
6. Get alerted automatically
Everything above fixes the problem once you know it exists. What's missing is finding out the same day, not when the customer writes. The idea: record two moments and have something watch that the second one arrives.
- Open when the customer comes back with the payment approved: you know they paid.
- Close when your webhook has processed it.
- If 30 minutes pass without a close, something got lost in between.
With Estiaje it's two lines in your code:
// on your «thank you» page, when the payment comes back approved
estiaje.event('payment.approved_by_customer', { order_id, amount });
// in your webhook, once the order is marked paid
estiaje.outcome('payment.confirmed', { order_id });
And in the dashboard you declare what you expect, without touching any more code:
{
"name": "Payment approved → webhook processed",
"when": "payment.approved_by_customer",
"expects": "payment.confirmed",
"match_by": "attrs.order_id",
"window_min": 30,
"valor": { "tipo": "atributo", "atributo": "amount" }
}
If an order is left without a close, your phone gets something like «$1,280 at risk: Payment approved → webhook processed», with the order that got stuck and, if you add a link to your system, a button to open it. If your webhook crashes, the error arrives grouped, with its stack trace and the order it left halfway.
Frequently asked questions
How often does Stripe retry a failed webhook?
In live mode, for up to three days with exponential backoff. You can resend an event by hand from Workbench up to 15 days after it was created, or with the Stripe CLI (stripe events resend) up to 30 days.
How often does Mercado Pago retry a webhook?
Mercado Pago waits 22 seconds for a 200 or 201. If it doesn't get one, it retries every 15 minutes; after the third attempt it spaces retries out, but keeps trying.
What does «No signatures found matching the expected signature for payload» mean?
The signature doesn't match the body you received. It's almost always because your framework parsed the body as JSON before verification (for example, express.json() before the webhook route), or because you're using the secret from another endpoint or from test mode. In Express, use express.raw({ type: 'application/json' }) on that route.
Does the Mercado Pago webhook include the payment status?
No. It carries the type, the action and the resource id (data.id). To know whether the payment was approved, fetch it from /v1/payments/{id} with the access token from the same account and the same mode (test or production).
Sources: the webhook documentation from Stripe (docs.stripe.com/webhooks) and Mercado Pago (mercadopago.com.mx/developers), checked October 12, 2026.