PayPal Webhooks Integration 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.
Quick Deploy with Template
The fastest way to get started is using our ready-made PayPal webhook template:
coho create mypaypal --template webhook-paypal-minimal
cd mypaypal
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_MODE "sandbox"
coho deploy
The template includes:
- Token caching — OAuth tokens cached for 8 hours to minimize API calls
- Header validation — All required PayPal headers validated upfront
- Sandbox/Live switching — Simple
PAYPAL_MODEenvironment variable - Health check endpoint — GET
/returns configuration status
Your webhook URL will be: https://mypaypal-xxxx.api.codehooks.io/dev/webhook
Want more control? Continue reading for a step-by-step implementation guide.
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();
// Parse the raw body to ensure exact payload preservation
const webhookEvent = JSON.parse(req.rawBody);
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: webhookEvent,
}),
});
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}`);
const conn = await Datastore.open();
// Check if we already have this event (idempotency)
try {
await conn.getOne('paypal_events', event.id);
return res.status(200).json({ received: true }); // Already exists
} catch (e) {
// Not found, continue to store
}
// Store new event
await conn.insertOne('paypal_events', {
_id: event.id,
eventType,
resourceType: event.resource_type,
resourceId: resource.id,
rawBody: req.rawBody, // Store raw body for replay capability
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();
// Parse the raw body to ensure exact payload preservation
const webhookEvent = JSON.parse(req.rawBody);
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: webhookEvent,
}),
});
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 if we already have this event (idempotency)
try {
await conn.getOne('paypal_events', event.id);
console.log('Duplicate event:', event.id);
return res.status(200).json({ received: true });
} catch (e) {
// Not found, continue to store
}
// Store new event
await conn.insertOne('paypal_events', {
_id: event.id,
eventType,
resourceId: resource.id,
rawBody: req.rawBody, // Store raw body for replay capability
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', 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();
// Parse the raw body to ensure exact payload preservation
const webhookEvent = JSON.parse(req.rawBody);
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: webhookEvent,
}),
});
const result = await response.json();
return result.verification_status === 'SUCCESS';
}
Important: Use req.rawBody (the raw string), not req.body (parsed JSON). The signature is computed over the exact bytes PayPal sent, and middleware parsing could alter key ordering or whitespace.
Best Practices
1. Handle Events Idempotently
PayPal retries failed deliveries up to 25 times. Use the event ID to prevent duplicate processing:
try {
await conn.getOne('paypal_events', { eventId: event.id });
return res.status(200).json({ received: true });
} catch (e) {
// Not found, continue to process
}
2. Respond Quickly
Store the event first, then return 200 immediately. PayPal expects a response within seconds:
const conn = await Datastore.open();
const eventId = req.body.id;
// Check if we already have this event (idempotency)
try {
await conn.getOne('paypal_events', eventId);
return res.status(200).json({ received: true }); // Already exists
} catch (e) {
// Not found, continue to store
}
// Store new event
await conn.insertOne('paypal_events', {
_id: eventId,
eventType: req.body.event_type,
rawBody: req.rawBody, // Store raw body for replay capability
processedAt: null
});
res.status(200).json({ received: true });
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 Webhook Examples - Explore all webhook integration examples
- Quick Deploy Templates - Ready-to-deploy backend templates
PayPal Webhooks Integration FAQ
Common questions about PayPal webhook integration
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?
req.body instead of req.rawBody. Use JSON.parse(req.rawBody) to ensure exact payload preservation. 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.