Skip to main content

PayPal Webhooks Example: Handle Payments, Refunds & Disputes

PayPal webhooks notify your application when payment events occur — orders are approved, payments captured, refunds processed, or disputes opened. Instead of polling the PayPal API, webhooks push real-time event data to your endpoint.

The problem? Setting up PayPal webhooks requires OAuth2 authentication, postback signature verification, handling exact payload formatting, and processing various event types.

The solution? Deploy a production-ready PayPal webhook handler with Codehooks.io in under 5 minutes.

New to webhooks?

If you're new to webhooks, check out our guide on What are webhooks? to understand the fundamentals before diving into this PayPal-specific implementation.

Prerequisites

Before you begin, sign up for a free Codehooks.io account and install the CLI:

npm install -g codehooks

You'll also need:

Quick Start: Payment Capture Handler

Create a new Codehooks project:

coho create my-paypal-handler
cd my-paypal-handler

Replace index.js with:

import { app, Datastore } from 'codehooks-js';

// Bypass JWT auth for PayPal webhook endpoint
app.auth('/paypal/*', (req, res, next) => {
next();
});

// Get PayPal OAuth access token
async function getPayPalAccessToken() {
const clientId = process.env.PAYPAL_CLIENT_ID;
const clientSecret = process.env.PAYPAL_CLIENT_SECRET;
const baseUrl = process.env.PAYPAL_API_URL || 'https://api-m.sandbox.paypal.com';

const response = await fetch(`${baseUrl}/v1/oauth2/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`,
},
body: 'grant_type=client_credentials',
});

const data = await response.json();
return data.access_token;
}

// Verify PayPal webhook signature using postback verification
async function verifyPayPalWebhook(req) {
const webhookId = process.env.PAYPAL_WEBHOOK_ID;
const baseUrl = process.env.PAYPAL_API_URL || 'https://api-m.sandbox.paypal.com';

const accessToken = await getPayPalAccessToken();

const response = await fetch(`${baseUrl}/v1/notifications/verify-webhook-signature`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
},
body: JSON.stringify({
auth_algo: req.headers['paypal-auth-algo'],
cert_url: req.headers['paypal-cert-url'],
transmission_id: req.headers['paypal-transmission-id'],
transmission_sig: req.headers['paypal-transmission-sig'],
transmission_time: req.headers['paypal-transmission-time'],
webhook_id: webhookId,
webhook_event: req.body,
}),
});

const result = await response.json();
return result.verification_status === 'SUCCESS';
}

// Handle PayPal webhook events
app.post('/paypal/webhooks', async (req, res) => {
// Verify the webhook is from PayPal
try {
const isValid = await verifyPayPalWebhook(req);
if (!isValid) {
console.error('Invalid PayPal webhook signature');
return res.status(403).json({ error: 'Invalid signature' });
}
} catch (error) {
console.error('Webhook verification failed:', error.message);
return res.status(500).json({ error: 'Verification failed' });
}

const event = req.body;
const eventType = event.event_type;
const resource = event.resource;

console.log(`PayPal webhook: ${eventType}`);

// Store the event
const conn = await Datastore.open();
await conn.insertOne('paypal_events', {
eventId: event.id,
eventType,
resourceType: event.resource_type,
resourceId: resource.id,
createTime: event.create_time,
receivedAt: new Date().toISOString(),
});

// Handle specific event types
switch (eventType) {
case 'CHECKOUT.ORDER.APPROVED':
console.log(`Order approved: ${resource.id}`);
// Capture the payment or fulfill the order
break;

case 'PAYMENT.CAPTURE.COMPLETED':
console.log(`Payment captured: ${resource.id}, Amount: ${resource.amount?.value}`);
// Fulfill the order, send confirmation email
break;

case 'PAYMENT.CAPTURE.DENIED':
console.log(`Payment denied: ${resource.id}`);
// Cancel the order, notify customer
break;

case 'PAYMENT.CAPTURE.REFUNDED':
console.log(`Payment refunded: ${resource.id}`);
// Update order status, adjust inventory
break;

case 'CUSTOMER.DISPUTE.CREATED':
console.log(`Dispute created: ${resource.dispute_id}`);
// Alert team, gather evidence
break;

default:
console.log(`Unhandled event type: ${eventType}`);
}

res.status(200).json({ received: true });
});

export default app.init();

Deploy and configure:

coho deploy
coho set-env PAYPAL_CLIENT_ID "your_client_id" --encrypted
coho set-env PAYPAL_CLIENT_SECRET "your_client_secret" --encrypted
coho set-env PAYPAL_WEBHOOK_ID "your_webhook_id" --encrypted
coho set-env PAYPAL_API_URL "https://api-m.sandbox.paypal.com"

Your webhook URL will be: https://my-paypal-handler-xxxx.api.codehooks.io/dev/paypal/webhooks

Configure PayPal Developer Dashboard

  1. Go to PayPal Developer Dashboard
  2. Select your app (or create one)
  3. Scroll to Webhooks and click Add Webhook
  4. Enter your Codehooks URL: https://your-app.api.codehooks.io/dev/paypal/webhooks
  5. Select the events you want to receive
  6. Click Save
  7. Copy the Webhook ID and add it to your environment:
coho set-env PAYPAL_WEBHOOK_ID "WH-XXXXXXXXX" --encrypted

PayPal Webhook Event Types

Checkout Events

EventDescriptionWhen to Use
CHECKOUT.ORDER.APPROVEDBuyer approved the orderCapture the payment
CHECKOUT.ORDER.COMPLETEDOrder completedConfirm fulfillment
CHECKOUT.PAYMENT-APPROVAL.REVERSEDPayment approval reversedCancel order

Payment Capture Events

EventDescriptionWhen to Use
PAYMENT.CAPTURE.COMPLETEDPayment successfully capturedFulfill order
PAYMENT.CAPTURE.DENIEDPayment capture deniedCancel order
PAYMENT.CAPTURE.PENDINGPayment capture pendingWait for completion
PAYMENT.CAPTURE.REFUNDEDCaptured payment refundedUpdate records
PAYMENT.CAPTURE.REVERSEDCaptured payment reversedUpdate records

Authorization Events

EventDescriptionWhen to Use
PAYMENT.AUTHORIZATION.CREATEDAuthorization createdTrack authorization
PAYMENT.AUTHORIZATION.VOIDEDAuthorization voidedUpdate records

Subscription Events

EventDescriptionWhen to Use
BILLING.SUBSCRIPTION.CREATEDSubscription createdProvision access
BILLING.SUBSCRIPTION.ACTIVATEDSubscription activatedStart service
BILLING.SUBSCRIPTION.CANCELLEDSubscription cancelledRevoke access
BILLING.SUBSCRIPTION.EXPIREDSubscription expiredRevoke access
PAYMENT.SALE.COMPLETEDSubscription payment receivedExtend access

Dispute Events

EventDescriptionWhen to Use
CUSTOMER.DISPUTE.CREATEDDispute openedAlert team
CUSTOMER.DISPUTE.RESOLVEDDispute resolvedUpdate records
CUSTOMER.DISPUTE.UPDATEDDispute status changedTrack progress

Event Payload Examples

PAYMENT.CAPTURE.COMPLETED

{
"id": "WH-XXXXXXXXXX",
"event_version": "1.0",
"create_time": "2024-01-15T12:00:00.000Z",
"resource_type": "capture",
"event_type": "PAYMENT.CAPTURE.COMPLETED",
"summary": "Payment completed for $50.00 USD",
"resource": {
"id": "5O190127TN364715T",
"amount": {
"currency_code": "USD",
"value": "50.00"
},
"final_capture": true,
"seller_protection": {
"status": "ELIGIBLE"
},
"status": "COMPLETED",
"create_time": "2024-01-15T12:00:00Z",
"update_time": "2024-01-15T12:00:00Z"
},
"links": [
{
"href": "https://api.paypal.com/v2/payments/captures/5O190127TN364715T",
"rel": "self",
"method": "GET"
}
]
}

CHECKOUT.ORDER.APPROVED

{
"id": "WH-YYYYYYYYYY",
"event_version": "1.0",
"create_time": "2024-01-15T11:55:00.000Z",
"resource_type": "checkout-order",
"event_type": "CHECKOUT.ORDER.APPROVED",
"summary": "An order has been approved by buyer",
"resource": {
"id": "5O190127TN364715T",
"intent": "CAPTURE",
"status": "APPROVED",
"purchase_units": [
{
"reference_id": "default",
"amount": {
"currency_code": "USD",
"value": "50.00"
}
}
],
"payer": {
"email_address": "[email protected]",
"payer_id": "BUYERID123"
}
}
}

Complete Payment Flow Handler

Handle the full checkout flow from approval to capture:

import { app, Datastore } from 'codehooks-js';

app.auth('/paypal/*', (req, res, next) => {
next();
});

async function getPayPalAccessToken() {
const clientId = process.env.PAYPAL_CLIENT_ID;
const clientSecret = process.env.PAYPAL_CLIENT_SECRET;
const baseUrl = process.env.PAYPAL_API_URL || 'https://api-m.sandbox.paypal.com';

const response = await fetch(`${baseUrl}/v1/oauth2/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`,
},
body: 'grant_type=client_credentials',
});

const data = await response.json();
return data.access_token;
}

async function verifyPayPalWebhook(req) {
const webhookId = process.env.PAYPAL_WEBHOOK_ID;
const baseUrl = process.env.PAYPAL_API_URL || 'https://api-m.sandbox.paypal.com';
const accessToken = await getPayPalAccessToken();

const response = await fetch(`${baseUrl}/v1/notifications/verify-webhook-signature`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
},
body: JSON.stringify({
auth_algo: req.headers['paypal-auth-algo'],
cert_url: req.headers['paypal-cert-url'],
transmission_id: req.headers['paypal-transmission-id'],
transmission_sig: req.headers['paypal-transmission-sig'],
transmission_time: req.headers['paypal-transmission-time'],
webhook_id: webhookId,
webhook_event: req.body,
}),
});

const result = await response.json();
return result.verification_status === 'SUCCESS';
}

// Capture payment after order approval
async function capturePayment(orderId) {
const baseUrl = process.env.PAYPAL_API_URL || 'https://api-m.sandbox.paypal.com';
const accessToken = await getPayPalAccessToken();

const response = await fetch(`${baseUrl}/v2/checkout/orders/${orderId}/capture`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
},
});

return response.json();
}

app.post('/paypal/webhooks', async (req, res) => {
try {
const isValid = await verifyPayPalWebhook(req);
if (!isValid) {
return res.status(403).json({ error: 'Invalid signature' });
}
} catch (error) {
console.error('Verification failed:', error.message);
return res.status(500).json({ error: 'Verification failed' });
}

const event = req.body;
const eventType = event.event_type;
const resource = event.resource;
const conn = await Datastore.open();

// Check for duplicate events
const existing = await conn.getOne('paypal_events', { eventId: event.id });
if (existing) {
console.log('Duplicate event:', event.id);
return res.status(200).json({ received: true, duplicate: true });
}

// Store event
await conn.insertOne('paypal_events', {
eventId: event.id,
eventType,
resourceId: resource.id,
processed: false,
receivedAt: new Date().toISOString(),
});

switch (eventType) {
case 'CHECKOUT.ORDER.APPROVED': {
console.log(`Order approved: ${resource.id}`);

// Auto-capture the payment
try {
const captureResult = await capturePayment(resource.id);
console.log('Payment captured:', captureResult.id);

await conn.updateOne('orders', { paypalOrderId: resource.id }, {
$set: {
status: 'paid',
captureId: captureResult.purchase_units?.[0]?.payments?.captures?.[0]?.id,
paidAt: new Date().toISOString(),
}
});
} catch (error) {
console.error('Capture failed:', error.message);
}
break;
}

case 'PAYMENT.CAPTURE.COMPLETED': {
console.log(`Payment completed: ${resource.id}`);

await conn.updateOne('orders', { captureId: resource.id }, {
$set: {
status: 'fulfilled',
amount: resource.amount?.value,
currency: resource.amount?.currency_code,
fulfilledAt: new Date().toISOString(),
}
});
break;
}

case 'PAYMENT.CAPTURE.DENIED': {
console.log(`Payment denied: ${resource.id}`);

await conn.updateOne('orders', { captureId: resource.id }, {
$set: {
status: 'denied',
deniedAt: new Date().toISOString(),
}
});
break;
}

case 'PAYMENT.CAPTURE.REFUNDED': {
console.log(`Payment refunded: ${resource.id}`);

await conn.updateOne('orders', { captureId: resource.id }, {
$set: {
status: 'refunded',
refundedAt: new Date().toISOString(),
}
});
break;
}

case 'CUSTOMER.DISPUTE.CREATED': {
console.log(`Dispute created: ${resource.dispute_id}`);

await conn.insertOne('disputes', {
disputeId: resource.dispute_id,
reason: resource.reason,
status: resource.status,
amount: resource.dispute_amount,
createdAt: new Date().toISOString(),
});
break;
}
}

// Mark event as processed
await conn.updateOne('paypal_events', { eventId: event.id }, {
$set: { processed: true, processedAt: new Date().toISOString() }
});

res.status(200).json({ received: true });
});

export default app.init();

PayPal Signature Verification

PayPal uses postback verification — you send the webhook data back to PayPal's API to verify it's authentic. This requires:

  1. OAuth2 Access Token — Obtained using your Client ID and Secret
  2. Webhook Headers — Five headers from the incoming request
  3. Webhook ID — Your registered webhook's ID from the PayPal Dashboard
  4. Original Payload — The exact webhook body as received
async function verifyPayPalWebhook(req) {
const accessToken = await getPayPalAccessToken();

const response = await fetch(`${baseUrl}/v1/notifications/verify-webhook-signature`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
},
body: JSON.stringify({
auth_algo: req.headers['paypal-auth-algo'],
cert_url: req.headers['paypal-cert-url'],
transmission_id: req.headers['paypal-transmission-id'],
transmission_sig: req.headers['paypal-transmission-sig'],
transmission_time: req.headers['paypal-transmission-time'],
webhook_id: process.env.PAYPAL_WEBHOOK_ID,
webhook_event: req.body, // Must be exact payload
}),
});

const result = await response.json();
return result.verification_status === 'SUCCESS';
}

Important: The webhook_event must be the exact payload PayPal sent. Any modification (even JSON re-serialization) can cause verification to fail.

Best Practices

1. Handle Events Idempotently

PayPal retries failed deliveries up to 25 times. Use the event ID to prevent duplicate processing:

const existing = await conn.getOne('paypal_events', { eventId: event.id });
if (existing) {
return res.status(200).json({ received: true });
}

2. Respond Quickly

Return 200 immediately. PayPal expects a response within seconds:

// Store and acknowledge
await conn.insertOne('paypal_events', event);
res.status(200).json({ received: true });

// Process asynchronously if needed
await conn.enqueue('processPayPalEvent', event);

3. Use Environment Variables

# Sandbox
coho set-env PAYPAL_API_URL "https://api-m.sandbox.paypal.com"

# Production
coho set-env PAYPAL_API_URL "https://api-m.paypal.com"

4. Fetch Fresh Data

Events can arrive out of order. For critical operations, fetch the latest state:

case 'PAYMENT.CAPTURE.COMPLETED':
// Fetch the latest capture details from PayPal API
const capture = await getCapture(resource.id);
// Use capture.status, capture.amount, etc.
break;

Sandbox vs Production

EnvironmentAPI Base URLDashboard
Sandboxhttps://api-m.sandbox.paypal.comsandbox.paypal.com
Productionhttps://api-m.paypal.compaypal.com

Switch environments by updating:

coho set-env PAYPAL_API_URL "https://api-m.paypal.com"
coho set-env PAYPAL_CLIENT_ID "your_live_client_id" --encrypted
coho set-env PAYPAL_CLIENT_SECRET "your_live_secret" --encrypted
coho set-env PAYPAL_WEBHOOK_ID "your_live_webhook_id" --encrypted

Why Use Codehooks.io for PayPal Webhooks?

FeatureDIY SetupCodehooks.io
Setup timeHours5 minutes
SSL/HTTPSConfigure certificatesBuilt-in
OAuth2 token managementManual implementationSimple fetch
Database for eventsSet up separatelyBuilt-in
Idempotency handlingBuild key-value storeBuilt-in
Async processingConfigure queuesBuilt-in workers
MonitoringSet up loggingBuilt-in logs
CostServer costsFree tier available

PayPal Webhooks FAQ

Common questions about handling PayPal webhooks

How do I verify PayPal webhook signatures?
PayPal uses postback verification. Send a POST request to PayPal's /v1/notifications/verify-webhook-signature endpoint with the original payload and headers. PayPal will return verification_status: 'SUCCESS' if the webhook is authentic. You need an OAuth2 access token and your Webhook ID.
Why is my PayPal webhook verification failing?
The most common cause is modifying the webhook payload before verification. The webhook_event must be exactly as PayPal sent it — don't parse and re-stringify JSON. Also verify your Webhook ID matches the one in the PayPal Dashboard and you're using the correct environment (sandbox vs production).
Does PayPal retry failed webhooks?
Yes, PayPal retries webhook deliveries up to 25 times over 3 days if your endpoint doesn't return a 2xx status code. Implement idempotency using the event.id to prevent processing the same event multiple times.
What's the difference between CHECKOUT.ORDER.APPROVED and PAYMENT.CAPTURE.COMPLETED?
CHECKOUT.ORDER.APPROVED means the buyer approved the payment but funds haven't been captured yet. You should capture the payment when you receive this event. PAYMENT.CAPTURE.COMPLETED means funds have been successfully captured and are in your account — this is when you should fulfill the order.
How do I switch from sandbox to production?
Update your environment variables: change PAYPAL_API_URL from api-m.sandbox.paypal.com to api-m.paypal.com, and update your Client ID, Client Secret, and Webhook ID to your production credentials from the PayPal Developer Dashboard.
Can PayPal events arrive out of order?
Yes, PayPal can deliver events out of order. For critical operations where accuracy matters, don't rely solely on webhook data — fetch the latest state from PayPal's REST API using the resource ID from the webhook.
How do I test PayPal webhooks?
Use PayPal's Sandbox environment. Create sandbox buyer and seller accounts in the PayPal Developer Dashboard, then perform test transactions. Webhooks will be sent to your configured endpoint. You can also use PayPal's 'Simulate event' feature in the Dashboard to send test webhooks.
What PayPal headers should I look for?
PayPal sends five signature-related headers: PAYPAL-AUTH-ALGO, PAYPAL-CERT-URL, PAYPAL-TRANSMISSION-ID, PAYPAL-TRANSMISSION-SIG, and PAYPAL-TRANSMISSION-TIME. All five are required for postback verification.