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.
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:
- A PayPal Developer account
- A PayPal app with REST API credentials (Client ID and Secret)
- Your Webhook ID from the PayPal Developer Dashboard
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
- Go to PayPal Developer Dashboard
- Select your app (or create one)
- Scroll to Webhooks and click Add Webhook
- Enter your Codehooks URL:
https://your-app.api.codehooks.io/dev/paypal/webhooks - Select the events you want to receive
- Click Save
- 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
| Event | Description | When to Use |
|---|---|---|
CHECKOUT.ORDER.APPROVED | Buyer approved the order | Capture the payment |
CHECKOUT.ORDER.COMPLETED | Order completed | Confirm fulfillment |
CHECKOUT.PAYMENT-APPROVAL.REVERSED | Payment approval reversed | Cancel order |
Payment Capture Events
| Event | Description | When to Use |
|---|---|---|
PAYMENT.CAPTURE.COMPLETED | Payment successfully captured | Fulfill order |
PAYMENT.CAPTURE.DENIED | Payment capture denied | Cancel order |
PAYMENT.CAPTURE.PENDING | Payment capture pending | Wait for completion |
PAYMENT.CAPTURE.REFUNDED | Captured payment refunded | Update records |
PAYMENT.CAPTURE.REVERSED | Captured payment reversed | Update records |
Authorization Events
| Event | Description | When to Use |
|---|---|---|
PAYMENT.AUTHORIZATION.CREATED | Authorization created | Track authorization |
PAYMENT.AUTHORIZATION.VOIDED | Authorization voided | Update records |
Subscription Events
| Event | Description | When to Use |
|---|---|---|
BILLING.SUBSCRIPTION.CREATED | Subscription created | Provision access |
BILLING.SUBSCRIPTION.ACTIVATED | Subscription activated | Start service |
BILLING.SUBSCRIPTION.CANCELLED | Subscription cancelled | Revoke access |
BILLING.SUBSCRIPTION.EXPIRED | Subscription expired | Revoke access |
PAYMENT.SALE.COMPLETED | Subscription payment received | Extend access |
Dispute Events
| Event | Description | When to Use |
|---|---|---|
CUSTOMER.DISPUTE.CREATED | Dispute opened | Alert team |
CUSTOMER.DISPUTE.RESOLVED | Dispute resolved | Update records |
CUSTOMER.DISPUTE.UPDATED | Dispute status changed | Track 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:
- OAuth2 Access Token — Obtained using your Client ID and Secret
- Webhook Headers — Five headers from the incoming request
- Webhook ID — Your registered webhook's ID from the PayPal Dashboard
- 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
| Environment | API Base URL | Dashboard |
|---|---|---|
| Sandbox | https://api-m.sandbox.paypal.com | sandbox.paypal.com |
| Production | https://api-m.paypal.com | paypal.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?
| Feature | DIY Setup | Codehooks.io |
|---|---|---|
| Setup time | Hours | 5 minutes |
| SSL/HTTPS | Configure certificates | Built-in |
| OAuth2 token management | Manual implementation | Simple fetch |
| Database for events | Set up separately | Built-in |
| Idempotency handling | Build key-value store | Built-in |
| Async processing | Configure queues | Built-in workers |
| Monitoring | Set up logging | Built-in logs |
| Cost | Server costs | Free tier available |
Related Resources
- PayPal Webhooks Documentation
- PayPal Webhook Event Names
- PayPal Webhook Integration Guide
- Codehooks.io Quick Start
- Webhook Delivery System - Build your own outgoing webhook system
- What are Webhooks? - Learn webhook fundamentals
- Stripe Webhooks Example - Alternative payment provider
- Shopify Webhooks Example - E-commerce webhooks
- Browse All Examples - Explore more templates and integrations
PayPal Webhooks FAQ
Common questions about handling PayPal webhooks
How do I verify PayPal webhook signatures?
/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?
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?
event.id to prevent processing the same event multiple times.What's the difference between CHECKOUT.ORDER.APPROVED and PAYMENT.CAPTURE.COMPLETED?
How do I switch from sandbox to production?
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?
How do I test PayPal webhooks?
What PayPal headers should I look for?
PAYPAL-AUTH-ALGO, PAYPAL-CERT-URL, PAYPAL-TRANSMISSION-ID, PAYPAL-TRANSMISSION-SIG, and PAYPAL-TRANSMISSION-TIME. All five are required for postback verification.