Tillping
Menu

Fix guides

PayPal webhook verification failed

Short answer: PayPal signs each webhook with its own private key. To check it, you build a short message from three headers, your webhook ID and a CRC32 of the raw body. Then you check the signature with PayPal's public certificate. If one byte of the body changed, or the webhook ID is not the right one, the check fails. If you ask PayPal to check it for you, you get back "verification_status": "FAILURE".

Signs you have this problem

  • PayPal's verify call always returns {"verification_status":"FAILURE"}, even for a real payment.
  • Your own check returns false for every event.
  • It works in sandbox but not in live, or the other way round.
  • It fails only for events from the webhook simulator.

How PayPal signs a webhook

Each webhook comes with these headers:

  • paypal-transmission-id: a unique ID for this send
  • paypal-transmission-time: when it was sent
  • paypal-transmission-sig: the signature, in base64
  • paypal-cert-url: where to get PayPal's public certificate
  • paypal-auth-algo: the method, like SHA256withRSA

PayPal's docs say the signed message is:

transmissionId|timeStamp|webhookId|crc32

The crc32 is the CRC32 of the raw body, as a decimal number. The webhookId is not in the request at all. It is the ID PayPal gave the webhook when you added your URL to your app.

You can check the signature yourself with the certificate, or post it all back to PayPal's verify-webhook-signature API. PayPal's docs call checking it yourself the preferred way. It is faster, and it does not need an extra API call.

The 5 causes of FAILURE

1. The body was parsed and turned back into JSON. This is the most common one. If you call req.json() and then JSON.stringify() it, the spaces or the key order can change. The CRC32 then no longer matches. PayPal's docs say the event must be sent back "exactly as it was received". One developer on Stack Overflow found that even a different key order gives FAILURE. Another was sending only body.resource, not the whole body. Read the body once, as raw text or bytes, before anything else.

2. The wrong webhook ID. It must be the ID of the webhook that sent this event. It is not the event ID (WH-...), not your client ID, and not a secret. You find it in your app's settings in the PayPal developer dashboard, in the Webhooks section. Sandbox and live are separate apps, so the same URL has a different webhook ID in each. A sandbox ID will never pass a live event.

3. A simulator event. Events from PayPal's webhook simulator are mock events. They don't belong to any app. PayPal's docs say the postback check does not work for them. For your own check, their webhook ID is the literal text WEBHOOK_ID. So a simulator event fails with your real webhook ID, and that is expected. To test the real check, make a real sandbox payment, or resend a real event (see below).

4. You used HMAC with a secret. PayPal does not give you a webhook secret. A check like createHmac('sha256', webhookId) will never match. The signature is made with PayPal's private key, so you check it with the public certificate from paypal-cert-url.

5. Sandbox and live mixed up in the postback. The verify call must go to the same place as the event: https://api-m.sandbox.paypal.com for sandbox, https://api-m.paypal.com for live. The access token must also come from the same place, made with that app's client ID and secret.

Check it yourself (Next.js App Router)

This route checks the signature with PayPal's certificate. It reads the raw bytes first, only trusts certificates from paypal.com, keeps the certificate in memory, and skips repeat events.

// app/api/paypal/webhook/route.ts
import { createVerify } from 'node:crypto'
import { crc32 } from 'node:zlib' // Node 20.15+ or 22.2+

export const runtime = 'nodejs'

type PayPalEvent = { id: string; event_type: string; resource: unknown }

// Swap this Set for a database table with a UNIQUE event id column.
// The Set is only for the example: it is wiped on every deploy.
const seen = new Set<string>()
const certs = new Map<string, string>()

async function getPayPalCert(certUrl: string): Promise<string> {
  const url = new URL(certUrl)
  // Only trust certificates from PayPal. If you fetch any URL the request
  // gives you, anyone can sign a fake event with their own certificate.
  const fromPayPal = url.hostname === 'paypal.com' || url.hostname.endsWith('.paypal.com')
  if (url.protocol !== 'https:' || !fromPayPal) throw new Error(`Not a PayPal cert URL: ${url.hostname}`)

  const cached = certs.get(certUrl)
  if (cached) return cached
  const res = await fetch(certUrl)
  if (!res.ok) throw new Error(`Could not download the PayPal cert: ${res.status}`)
  const pem = await res.text()
  certs.set(certUrl, pem)
  return pem
}

async function isFromPayPal(rawBody: Buffer, headers: Headers, webhookId: string): Promise<boolean> {
  const transmissionId = headers.get('paypal-transmission-id')
  const transmissionTime = headers.get('paypal-transmission-time')
  const certUrl = headers.get('paypal-cert-url')
  const signature = headers.get('paypal-transmission-sig')
  if (!transmissionId || !transmissionTime || !certUrl || !signature) return false

  // The CRC32 of the raw bytes, as a plain (unsigned) decimal number.
  const message = `${transmissionId}|${transmissionTime}|${webhookId}|${crc32(rawBody)}`
  try {
    const cert = await getPayPalCert(certUrl)
    return createVerify('SHA256').update(message).verify(cert, signature, 'base64')
  } catch (err) {
    console.error('PayPal signature check could not run', err)
    return false
  }
}

export async function POST(req: Request) {
  // The ID of THIS webhook in THIS PayPal app (sandbox and live differ).
  const webhookId = process.env.PAYPAL_WEBHOOK_ID
  if (!webhookId) {
    console.error('PAYPAL_WEBHOOK_ID is not set on this server')
    return new Response('Server not set up', { status: 500 })
  }

  // 1. Read the raw bytes. Do not call req.json() first.
  const rawBody = Buffer.from(await req.arrayBuffer())
  if (!(await isFromPayPal(rawBody, req.headers, webhookId))) {
    return new Response('Invalid signature', { status: 400 })
  }

  // 2. Parse only after the check passed.
  let event: PayPalEvent
  try {
    event = JSON.parse(rawBody.toString('utf8'))
  } catch {
    return new Response('Bad JSON', { status: 400 })
  }

  // 3. The same event can come more than once. Skip repeats by event id.
  if (seen.has(event.id)) return new Response('ok', { status: 200 })
  seen.add(event.id)

  if (event.event_type === 'PAYMENT.CAPTURE.COMPLETED') {
    // Unlock the order here. Keep it quick.
    console.log('paid', event.id)
  }

  return new Response('ok', { status: 200 })
}

Three things to know before you copy it:

  • Check the certificate URL. The code only downloads a certificate from an https address on paypal.com. Without this, anyone could send a fake event with a link to their own certificate, and it would pass.
  • Use a real table. Save each event id in a table with a UNIQUE column. PayPal can send the same event more than once. A Set in memory is wiped on every deploy.
  • Node version. crc32 from node:zlib needs Node 20.15 or newer (or 22.2 or newer). It gives the plain, unsigned number PayPal wants. Some other CRC32 tools give a negative number or hex, which will not match.

If the check fails, the route sends 400. PayPal treats anything that is not a 2xx as a failed send and tries again, up to 25 times over 3 days. So a wrong webhook ID shows up as many retries in PayPal, not as an error you see.

Or ask PayPal to check it (postback)

If you'd rather let PayPal do the check, this helper gets an access token and calls verify-webhook-signature. The key part is the last line of buildVerifyBody: it pastes the raw body into the request as it came, instead of parsing it.

// lib/paypal-verify-postback.ts
// Use this only if you ask PayPal to check the signature for you.
// It does not work for events from the webhook simulator.

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

export function buildVerifyBody(rawBody: string, headers: Headers, webhookId: string): string | null {
  const fields = {
    auth_algo: headers.get('paypal-auth-algo'),
    cert_url: headers.get('paypal-cert-url'),
    transmission_id: headers.get('paypal-transmission-id'),
    transmission_sig: headers.get('paypal-transmission-sig'),
    transmission_time: headers.get('paypal-transmission-time'),
    webhook_id: webhookId,
  }
  if (Object.values(fields).some((v) => !v)) return null
  // Paste the raw body in as it came. Never JSON.parse it and stringify it again.
  return JSON.stringify(fields).slice(0, -1) + `,"webhook_event":${rawBody}}`
}

export async function verifyWithPayPal(rawBody: string, headers: Headers, webhookId: string): Promise<boolean> {
  const body = buildVerifyBody(rawBody, headers, webhookId)
  if (!body) return false

  const basic = Buffer.from(`${process.env.PAYPAL_CLIENT_ID}:${process.env.PAYPAL_CLIENT_SECRET}`).toString('base64')
  const tokenRes = 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 (!tokenRes.ok) throw new Error(`PayPal token call said ${tokenRes.status}`)
  const { access_token } = (await tokenRes.json()) as { access_token: string }

  const res = await fetch(`${API}/v1/notifications/verify-webhook-signature`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${access_token}`, 'Content-Type': 'application/json' },
    body,
  })
  if (!res.ok) throw new Error(`PayPal verify call said ${res.status}`)
  const data = (await res.json()) as { verification_status?: string }
  return data.verification_status === 'SUCCESS'
}

Set PAYPAL_ENV=live only on your live server, with your live client ID and secret. In your route, read the body with await req.text() and pass that string in. This way costs one extra round trip to PayPal on each event, and it can't check simulator events.

Step by step

  1. Log what you got. Print the four paypal-* headers and the first 200 characters of the raw body. Check none of them are empty.
  2. Check the webhook ID. Copy it again from the Webhooks section of the right app (sandbox or live). Make sure the server uses that value.
  3. Check you read the raw body. No req.json(), no body parser, nothing that changes the bytes before the check.
  4. Stop using simulator events to test the check. Make a real sandbox payment instead.
  5. Deploy, then resend a real event. PayPal's webhooks API has a resend call: POST /v1/notifications/webhooks-events/{event_id}/resend. It sends a real, signed event again, so you can test without a new payment.
  6. Skip repeats. Save the event id, so a resend or a retry does not unlock twice.

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. No account is needed. Only scan an address you own or are allowed to test.

The scan sends a few safe test requests, with no real payment. One has no signature, and one has a made-up signature header. Neither has PayPal's headers, so a good PayPal route should answer 400, 401 or 403. The scan flags an endpoint that says OK (a 2xx) to these, which means it does not check signatures at all. It also flags a 5xx, a 404, a 405, a redirect, a Vercel login wall, a Cloudflare challenge, a bad certificate, and a slow reply (a warning at 3 seconds, serious at 8 seconds or no reply).

What it can't do: it never sees a real PayPal event, your webhook ID, or your PayPal dashboard. So it can't tell if your check would pass a real PayPal event, or why PayPal says FAILURE. Use the steps above and a real sandbox event for that.

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.