Tillping
Menu

Fix guides

PayPal webhook not received

Short answer: First find out where the event stops. Either PayPal never made it, or PayPal made it but could not deliver it, or it arrived and your app did nothing. Each one has a different fix. PayPal's Webhook Events dashboard tells you which: no event at all, a yellow mark (still pending), or a green check (received).

Signs you have this problem

  • A buyer paid, but your app never unlocked their order.
  • Your server logs show no request from PayPal at all.
  • The webhook simulator works, but real sandbox payments send nothing.
  • It works on your laptop with a tunnel, but not on your live domain.

Step 1: Did PayPal make the event?

Open the Webhook Events dashboard for sandbox or live, and pick the app that took the payment. Look for the event in the last few days. If it is not there, PayPal never made it, and nothing is wrong with your server yet. The usual reasons are:

  • Wrong app. PayPal only sends events for the app that made the payment. If your checkout uses the client ID of one app, but your webhook is on another app, you get nothing. Sandbox and live are separate apps too.
  • Not subscribed. Each webhook lists the event types it wants. If PAYMENT.CAPTURE.COMPLETED is not on the list (and the list is not *, which means all), it never comes.
  • The order was never captured. CHECKOUT.ORDER.APPROVED only means the buyer said yes. PAYMENT.CAPTURE.COMPLETED comes after your server captures the payment. If your code never calls capture, that event never happens.
  • The payment is on hold. A capture can be pending first. Then you get PAYMENT.CAPTURE.PENDING, and the completed event comes later, if at all. In sandbox, one developer fixed this by turning off Payment Review on the test account (PayPal support told them to).

Step 2: Did PayPal try to send it?

If the event is in the dashboard with a yellow mark, PayPal made it but could not deliver it. PayPal needs an HTTP 2xx reply. With no reply, no connection, or a 404 or 500, it tries again up to 25 times over 3 days. After that, it marks the send as Failed. You can still resend it by hand from the dashboard.

PayPal's docs give these common reasons:

  • Not https on port 443. The listener must use https on port 443. An address like https://myapp.com:8443/hook or plain http:// will not work.
  • A firewall blocks it. If your firewall only lets in some IP addresses, PayPal's troubleshooting page lists the ranges its webhooks come from.
  • Your domain is flagged. If a URL filtering service marks your domain as risky, PayPal may not connect at all. PayPal's docs point to urlfiltering.paloaltonetworks.com to check. On Stack Overflow, one Next.js developer had a domain that worked with ngrok and webhook.site but got nothing from PayPal. The fix was to get the domain re-rated, or use a new one.
  • Your route says no. A 404 means the path is wrong or not deployed. A 500 means the code crashed. A login wall (like Vercel's Deployment Protection) or a bot check (like a Cloudflare challenge) blocks PayPal before your code runs.

Two more traps to check:

  • A redirect. PayPal's docs don't say it follows redirects. So use the exact address that answers directly, with the right www or no www.
  • Saying no to events you don't use. If your route answers 400 to every event type it does not handle, PayPal treats each one as a failed send and retries for 3 days. After the signature check passes, answer 200 and ignore the event.

Step 3: It arrived, but nothing changed

If the dashboard shows a green check, PayPal got a 2xx from your server. The problem is in your code. Check that you handle the right event type, that the signature check passes (see our PayPal verification guide), and that the work after it does not fail quietly.

Also: don't test with the webhook simulator only. Simulator events are not tied to any app. They don't show up in the dashboard, and you can't resend them. Use a real sandbox payment.

Check your setup with a script

This script only reads. It gets a token for your app, lists the webhooks on that app, and flags a bad address or a missing event type. Then it counts the events PayPal made in the last 3 days. If the count is 0, the problem is Step 1, not your server.

// lib/paypal.ts
// Two small helpers: get an access token, and ask PayPal if an order is paid.

export const API = process.env.PAYPAL_ENV === 'live' ? 'https://api-m.paypal.com' : 'https://api-m.sandbox.paypal.com'

export async function getToken(clientId: string, secret: string): Promise<string> {
  const basic = Buffer.from(`${clientId}:${secret}`).toString('base64')
  const res = await fetch(`${API}/v1/oauth2/token`, {
    method: 'POST',
    headers: { Authorization: `Basic ${basic}`, 'Content-Type': 'application/x-www-form-urlencoded' },
    body: 'grant_type=client_credentials',
  })
  if (!res.ok) throw new Error(`PayPal token call said ${res.status}`)
  return ((await res.json()) as { access_token: string }).access_token
}

type Capture = { id: string; status: string; amount: { value: string; currency_code: string } }
type Order = { id: string; status: string; purchase_units?: { custom_id?: string; payments?: { captures?: Capture[] } }[] }

export type PaidCapture = { captureId: string; customId?: string; amount: string; currency: string }

// Read-only. Returns the first COMPLETED capture, or null if the order is not paid yet.
export async function getPaidCapture(orderId: string, token: string): Promise<PaidCapture | null> {
  const res = await fetch(`${API}/v2/checkout/orders/${encodeURIComponent(orderId)}`, {
    headers: { Authorization: `Bearer ${token}` },
  })
  if (!res.ok) throw new Error(`PayPal order call said ${res.status}`)
  const order = (await res.json()) as Order
  for (const unit of order.purchase_units ?? []) {
    for (const c of unit.payments?.captures ?? []) {
      if (c.status === 'COMPLETED') {
        return { captureId: c.id, customId: unit.custom_id, amount: c.amount.value, currency: c.amount.currency_code }
      }
    }
  }
  return null
}
// scripts/check-paypal-webhooks.ts
// Read-only: it lists your webhooks and recent events. It changes nothing.
// Run: PAYPAL_ENV=sandbox PAYPAL_CLIENT_ID=... PAYPAL_CLIENT_SECRET=... npx tsx scripts/check-paypal-webhooks.ts PAYMENT.CAPTURE.COMPLETED
import { API, getToken } from '../lib/paypal'

type Webhook = { id: string; url: string; event_types: { name: string }[] }
type PayPalEvent = { id: string; event_type: string; create_time: string; summary?: string }
type Link = { href: string; rel: string }

export function urlProblems(raw: string): string[] {
  let url: URL
  try {
    url = new URL(raw)
  } catch {
    return ['not a valid URL']
  }
  const problems: string[] = []
  if (url.protocol !== 'https:') problems.push('not https')
  if (url.port && url.port !== '443') problems.push(`port ${url.port} (PayPal needs port 443)`)
  if (/^(localhost|127\.|10\.|192\.168\.|0\.0\.0\.0)/.test(url.hostname)) problems.push('a local address PayPal cannot reach')
  return problems
}

export function missingEvents(hook: Webhook, wanted: string[]): string[] {
  const names = hook.event_types.map((t) => t.name)
  if (names.includes('*')) return []
  return wanted.filter((w) => !names.includes(w))
}

async function get<T>(url: string, token: string): Promise<T> {
  const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } })
  if (!res.ok) throw new Error(`PayPal said ${res.status} for ${new URL(url).pathname}`)
  return (await res.json()) as T
}

export async function recentEvents(token: string, eventType: string, days = 3, now = new Date()): Promise<PayPalEvent[]> {
  const start = new Date(now.getTime() - days * 24 * 60 * 60 * 1000)
  const query = new URLSearchParams({ start_time: start.toISOString(), end_time: now.toISOString(), event_type: eventType })
  let url: string | undefined = `${API}/v1/notifications/webhooks-events?${query}`
  const found: PayPalEvent[] = []
  for (let page = 0; url && page < 10; page++) {
    const data: { events?: PayPalEvent[]; links?: Link[] } = await get(url, token)
    found.push(...(data.events ?? []))
    url = data.links?.find((l) => l.rel === 'next')?.href
  }
  return found
}

export async function main(argv: string[], env: Record<string, string | undefined>): Promise<number> {
  if (!env.PAYPAL_CLIENT_ID || !env.PAYPAL_CLIENT_SECRET) {
    console.error('Set PAYPAL_CLIENT_ID and PAYPAL_CLIENT_SECRET of the app that takes the payments.')
    return 1
  }
  const wanted = argv.length ? argv : ['PAYMENT.CAPTURE.COMPLETED']
  const token = await getToken(env.PAYPAL_CLIENT_ID, env.PAYPAL_CLIENT_SECRET)
  console.log(`App on ${API}`)

  // 1. Is a webhook set up on THIS app, at a good address, for these events?
  const { webhooks = [] } = await get<{ webhooks?: Webhook[] }>(`${API}/v1/notifications/webhooks`, token)
  if (!webhooks.length) console.log('No webhooks on this app. Add your URL in this app, not another one.')
  for (const hook of webhooks) {
    const issues = [...urlProblems(hook.url), ...missingEvents(hook, wanted).map((e) => `not subscribed to ${e}`)]
    console.log(`${hook.id}  ${hook.url}  ${issues.length ? 'PROBLEM: ' + issues.join('; ') : 'ok'}`)
  }

  // 2. Did PayPal make the event at all in the last 3 days?
  for (const type of wanted) {
    const events = await recentEvents(token, type)
    console.log(`${events.length} ${type} event(s) in the last 3 days`)
    for (const e of events) console.log(`  ${e.id}  ${e.create_time}  ${e.summary ?? ''}`)
  }
  return 0
}

if (process.argv[1]?.endsWith('check-paypal-webhooks.ts')) {
  main(process.argv.slice(2), process.env).then((code) => process.exit(code))
}

Run it with the client ID and secret of the app that takes the payments. Add PAYPAL_ENV=live for live. The API list shows the events, but not whether each one was delivered. For that, use the dashboard: a green check means received, a yellow mark means pending.

Don't wait only for the webhook

Webhooks can be late, and a send can fail. So check the order yourself too. When the buyer comes back from PayPal and your server captures the order, call getPaidCapture(orderId, token) from the file above. If it returns a capture, unlock the order right away. Keep the webhook as a backup for anything you missed.

Use the capture id as a unique key in your database. Then the return page and the webhook can both run, and the order still unlocks only once. You can also run getPaidCapture later for orders that are still waiting.

Step by step

  1. Find the event in the Webhook Events dashboard, for the right app (sandbox or live).
  2. No event? Check the app, the event types, and that your code captures the payment.
  3. Yellow mark? Check https on port 443, firewall rules, your domain's rating, and what your route answers.
  4. Fix it, then resend the event from the dashboard. Don't make a new payment for each try.
  5. Green check but no unlock? Look at your own code and logs.
  6. Add the backup: check the order with getPaidCapture when the buyer comes back.

Check it with a free scan

Paste your webhook address into the free Tillping scan: https://tillping.online/#scan. There is no PayPal choice yet, so pick Other provider. No account is needed. Only scan an address you own or are allowed to test.

The scan sends a few safe test requests from our server, with no real payment. It flags a missing domain, a refused connection, no reply within about 8 seconds, a bad or soon-to-expire certificate, an address that is not https, a redirect, a 404, a 405, a 5xx, a Vercel login wall and a Cloudflare challenge. It also flags a slow reply, and a route that says OK (a 2xx) to a request with no signature or a fake one. A good PayPal route should answer those test requests with 400, 401 or 403. The scan only checks ports 80, 443 and 8080.

What it can't do: it can't see your PayPal apps, your event types or the Webhook Events dashboard. So it can't tell if PayPal made the event at all. It tests from our network, not PayPal's. So it can't see a firewall rule that blocks only PayPal, or how a URL filtering service rates your domain. Use the steps and the script above for those.

Sources

Check your webhook now

Paste your webhook address to see what it answers. Free, no account, no keys.

Check my webhook

Get an email when Deep Scans open

One email to confirm, then one when we launch. Leave any time.

We send one email to confirm. You can leave at any time.