On this page
No Headings
Last updated: May 20, 2026
Estimated time: 15 minutes
The Invoicing API triggers webhooks for all invoice state transitions and important actions. Use webhooks to keep your system in sync with invoice activity without repeatedly querying the API.
The following events are triggered by the Invoicing API.
| Event | Description |
|---|---|
INVOICING.INVOICE.CREATED | Draft invoice created. |
INVOICING.INVOICE.SENT | Invoice sent to recipient. |
INVOICING.INVOICE.CANCELLED | Invoice cancelled. |
INVOICING.INVOICE.PAID | Payment received. |
INVOICING.INVOICE.REFUNDED | Refund issued. |
| Event | Description |
|---|---|
INVOICING.INVOICE.UPDATED | Invoice modified before sending. |
| Event | Description |
|---|---|
INVOICING.INVOICE.SCHEDULED | Invoice scheduled for future delivery. |
Every webhook event contains the following structure.
{
"id": "WH-EVENT_ID",
"create_time": "2024-01-15T10:30:45Z",
"event_type": "INVOICING.INVOICE.PAID",
"resource": {
"id": "INV2-XXXXX-XXXXX",
"status": "PAID",
"invoice_number": "#1001",
"amount": {
"currency_code": "USD",
"value": "250.00"
},
"payer_email": "[email protected]",
"payments": [
{
"payment_id": "TXNID123",
"amount": {
"currency_code": "USD",
"value": "250.00"
}
}
]
},
"links": [
{
"href": "https://api-m.paypal.com/v2/invoicing/invoices/INV2-XXXXX-XXXXX",
"rel": "self",
"method": "GET"
}
]
}| Field | Description |
|---|---|
id | Unique webhook event identifier. Use this for deduplication. |
create_time | When the event occurred, in ISO 8601 format. |
event_type | The invoice event that triggered the webhook. |
resource | Invoice details and transaction information. |
Your endpoint should meet the following requirements.
| Requirement | Description |
|---|---|
| Listen on HTTPS | PayPal requires webhook endpoints to use HTTPS for secure delivery. |
| Accept POST requests | PayPal delivers webhooks as POST requests. |
| Return 200 OK within 30 seconds | Respond quickly to avoid timeout. |
| Validate signatures | Verify PayPal authenticity before processing. |
| Handle retries | The same event may arrive multiple times. |
The following Node.js example shows a basic webhook endpoint.
const express = require('express');
const app = express();
app.use(express.json());
app.post('/webhooks/invoicing', (req, res) => {
// Return 200 immediately
res.status(200).json({ status: 'received' });
// Process webhook asynchronously
handleInvoicingWebhook(req.body);
});
async function handleInvoicingWebhook(event) {
// Validate signature (see Webhook Signature Validation section)
if (!validateSignature(event)) {
console.error('Invalid webhook signature');
return;
}
// Process event idempotently (see Idempotency section)
const eventId = event.id;
const existing = await db.webhookEvents.findOne({ eventId });
if (existing) {
console.log('Event already processed:', eventId);
return;
}
// Process based on event type
switch(event.event_type) {
case 'INVOICING.INVOICE.PAID':
await handleInvoicePaid(event.resource);
break;
case 'INVOICING.INVOICE.SENT':
await handleInvoiceSent(event.resource);
break;
case 'INVOICING.INVOICE.REFUNDED':
await handleInvoiceRefunded(event.resource);
break;
}
// Mark as processed
await db.webhookEvents.insertOne({
eventId: eventId,
processed_at: new Date(),
event_type: event.event_type,
invoice_id: event.resource.id
});
}
app.listen(3000, () => {
console.log('Webhook server listening on port 3000');
});To register your webhook endpoint with PayPal, use the webhooks registration endpoint. For details on the endpoint, parameters, and request and response examples, see the Webhooks API reference.
PayPal signs all webhook payloads. Validate the signature before processing each webhook to ensure authenticity and prevent unauthorized access.
PayPal provides the following information with each webhook.
| Field | Description |
|---|---|
webhook_id | Your registered webhook ID. |
transmission_id | Unique ID for this transmission. |
transmission_time | When PayPal sent the webhook. |
cert_url | URL to the certificate for verifying the signature. |
auth_algo | Algorithm used, typically SHA256withRSA. |
webhook_signature | The signature to verify. |
To validate a webhook:
cert_url provided.transmission_id|transmission_time|webhook_id|webhook_body_hash.import json
import hmac
import hashlib
from urllib.request import urlopen
from datetime import datetime, timedelta
from cryptography import x509
from cryptography.hazmat.backends import default_backend
def validate_webhook_signature(headers, body, webhook_id):
"""
Validate PayPal webhook signature.
Args:
headers: HTTP headers from webhook request
body: Raw webhook body (string/bytes)
webhook_id: Your webhook ID
Returns:
bool: True if signature is valid, False otherwise
"""
transmission_id = headers.get('PAYPAL-TRANSMISSION-ID')
transmission_time = headers.get('PAYPAL-TRANSMISSION-TIME')
cert_url = headers.get('PAYPAL-CERT-URL')
auth_algo = headers.get('PAYPAL-AUTH-ALGO')
webhook_signature = headers.get('PAYPAL-TRANSMISSION-SIG')
# Step 1: Verify timestamp is recent (within 5 minutes)
try:
event_time = datetime.fromisoformat(transmission_time.replace('Z', '+00:00'))
now = datetime.now(event_time.tzinfo)
if abs((now - event_time).total_seconds()) > 300:
print("Webhook timestamp too old")
return False
except Exception as e:
print(f"Timestamp validation failed: {e}")
return False
# Step 2: Calculate body hash
if isinstance(body, str):
body = body.encode('utf-8')
body_hash = hashlib.sha256(body).hexdigest()
# Step 3: Construct verification string
verification_string = f"{transmission_id}|{transmission_time}|{webhook_id}|{body_hash}"
# Step 4: Get certificate from URL
try:
with urlopen(cert_url) as response:
cert_data = response.read()
cert = x509.load_pem_x509_certificate(cert_data, default_backend())
public_key = cert.public_key()
except Exception as e:
print(f"Certificate retrieval failed: {e}")
return False
# Step 5: Verify signature
try:
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives import hashes
public_key.verify(
bytes.fromhex(webhook_signature),
verification_string.encode('utf-8'),
padding.PKCS1v15(),
hashes.SHA256()
)
return True
except Exception as e:
print(f"Signature verification failed: {e}")
return Falseconst crypto = require('crypto');
const https = require('https');
async function validateWebhookSignature(
transmissionId,
transmissionTime,
certUrl,
webhookId,
webhook_signature,
webhookBody
) {
// Step 1: Verify timestamp
const eventTime = new Date(transmissionTime);
const now = new Date();
const diffSeconds = Math.abs((now - eventTime) / 1000);
if (diffSeconds > 300) {
console.error('Webhook timestamp too old');
return false;
}
// Step 2: Calculate body hash
const bodyHash = crypto
.createHash('sha256')
.update(JSON.stringify(webhookBody))
.digest('hex');
// Step 3: Construct verification string
const verificationString =
`${transmissionId}|${transmissionTime}|${webhookId}|${bodyHash}`;
// Step 4: Get certificate
try {
const cert = await fetchCertificate(certUrl);
// Step 5: Verify signature
const verifier = crypto.createVerify('RSA-SHA256');
verifier.update(verificationString);
return verifier.verify(cert, webhook_signature, 'hex');
} catch (err) {
console.error('Certificate validation failed:', err);
return false;
}
}
function fetchCertificate(url) {
return new Promise((resolve, reject) => {
https.get(url, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => resolve(data));
}).on('error', reject);
});
}PayPal uses at-least-once delivery, meaning the same webhook event may be delivered multiple times. Your system must handle duplicates safely.
Duplicates can occur for several reasons:
The same event with the same id and create_time can arrive multiple times over several minutes.
Test your webhook integration in sandbox before going live.
To test your webhook integration in sandbox:
Use the following example to log incoming webhooks and confirm events are being delivered.
app.post('/webhooks/invoicing', (req, res) => {
console.log('Webhook received:', {
event_id: req.body.id,
event_type: req.body.event_type,
invoice_id: req.body.resource?.id,
timestamp: new Date().toISOString()
});
res.status(200).json({ status: 'received' });
});When you create and send an invoice, then mark it as paid, PayPal fires events in this order:
INVOICING.INVOICE.CREATED: when the invoice is createdINVOICING.INVOICE.SENT: when you send the invoiceINVOICING.INVOICE.PAID: when you mark it as paidThe following examples show how to handle common invoice events in your webhook listener.
When an invoice is paid, update your accounting system and generate a payment receipt.
To trigger this scenario in sandbox:
# Step 1: Create invoice
curl -X POST https://api-m.sandbox.paypal.com/v2/invoicing/invoices \
-H "Authorization: Bearer ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"invoicer": {
"email_address": "[email protected]"
},
"primary_recipients": [{
"billing_info": {
"email_address": "[email protected]"
}
}],
"items": [{
"name": "Test Invoice",
"description": "Test item",
"unit_amount": {
"currency_code": "USD",
"value": "100.00"
},
"quantity": 1
}]
}'
# Save the invoice ID from response: INV2-XXXXX-XXXXX
# Step 2: Send invoice (triggers INVOICING.INVOICE.SENT webhook)
curl -X POST https://api-m.sandbox.paypal.com/v2/invoicing/invoices/{INVOICE_ID}/send \
-H "Authorization: Bearer ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"send_to_invoicer": true}'
# Step 3: Mark as paid (triggers INVOICING.INVOICE.PAID webhook)
curl -X POST https://api-m.sandbox.paypal.com/v2/invoicing/invoices/{INVOICE_ID}/payments \
-H "Authorization: Bearer ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"method": "BANK_TRANSFER",
"note": "Payment received"
}'When INVOICING.INVOICE.PAID fires, handle it with the following logic.
async function handleInvoicePaid(invoiceResource) {
const { id: invoiceId, amount, payer_email, payments } = invoiceResource;
// Update local invoice record
await db.invoices.updateOne(
{ invoice_id: invoiceId },
{
status: 'PAID',
paid_at: new Date(),
payment_method: payments[0].payment_id
}
);
// Record accounting entry
await accountingSystem.createJournalEntry({
transaction_type: 'PAYMENT_RECEIVED',
amount: parseFloat(amount.value),
currency: amount.currency_code,
invoice_id: invoiceId,
payer_email: payer_email,
timestamp: new Date()
});
// Generate receipt and send to payer
const receipt = await generatePaymentReceipt(invoiceId);
await emailService.sendReceipt(payer_email, receipt);
}When an invoice is sent to the customer, initiate fulfillment to prepare for payment.
async function handleInvoiceSent(invoiceResource) {
const { id: invoiceId, items } = invoiceResource;
// Fetch full invoice details from API
const invoice = await invoicingApi.getInvoice(invoiceId);
// Start fulfillment workflow
for (const item of items) {
await fulfillmentSystem.createOrder({
order_id: invoiceId,
item_sku: item.sku,
quantity: item.quantity,
customer_email: invoice.payer_email
});
}
// Log event
await db.events.insertOne({
invoice_id: invoiceId,
event_type: 'SENT',
fulfillment_started: true
});
}Track all invoice events for compliance and troubleshooting.
async function logEventAuditTrail(event) {
const { id, event_type, create_time, resource } = event;
await db.auditLog.insertOne({
webhook_id: id,
invoice_id: resource.id,
event_type: event_type,
event_timestamp: new Date(create_time),
recorded_timestamp: new Date(),
payload: event
});
// Alert if unexpected event type
const expectedTypes = [
'INVOICING.INVOICE.CREATED',
'INVOICING.INVOICE.SENT',
'INVOICING.INVOICE.PAID',
'INVOICING.INVOICE.REFUNDED'
];
if (!expectedTypes.includes(event_type)) {
console.warn(`Unexpected event type: ${event_type}`);
await alertService.notify({
level: 'WARNING',
message: `Unexpected invoice event: ${event_type}`,
invoice_id: resource.id
});
}
}Use the following guidance to resolve common webhook issues.
Symptom: PayPal reports a webhook delivery timeout or an endpoint that is not responding.
Causes:
To resolve this:
200 OK within 30 seconds, then process asynchronously.app.post('/webhooks/invoicing', (req, res) => {
res.status(200).send('OK'); // Respond first
handleWebhookAsync(req.body); // Process later
});curl -X POST https://yourdomain.com/webhooks/invoicing \
-H "Content-Type: application/json" \
-d '{"test": true}'Verify that PayPal IPs are allowlisted in your firewall.
If your endpoint is behind a reverse proxy, set the HTTP timeout to 60 seconds or more.
Symptom: Error validating webhook signature or permission denied.
Causes:
404 or an expired certificate.To resolve this:
// WRONG: Parse first, then validate
const body = req.body; // Already parsed by middleware
// CORRECT: Capture raw body string
app.use(express.raw({type: 'application/json'}));
app.post('/webhooks/invoicing', (req, res) => {
const rawBody = req.body.toString(); // Use this for validation
validateSignature(req.headers, rawBody);
});Confirm you're using the correct webhook ID from the dashboard, not your client ID.
Cache certificates with a proper TTL to avoid repeated fetches and expired certificate errors.
const certificateCache = new Map();
async function getCertificate(url) {
if (certificateCache.has(url)) {
return certificateCache.get(url);
}
const cert = await fetchFromUrl(url);
certificateCache.set(url, cert);
return cert;
}Symptom: Payment recorded multiple times or inventory decremented multiple times.
Causes:
event_id column.To resolve this:
Store the event ID before processing.
Add a unique constraint on event_id.
ALTER TABLE webhook_events ADD UNIQUE(event_id);BEGIN TRANSACTION;
INSERT INTO webhook_events (event_id, ...) VALUES (...);
UPDATE invoices SET status = 'PAID' WHERE id = ...;
COMMIT;Symptom: No webhooks received after working previously.
Causes:
200 status codes repeatedly.To resolve this:
Verify the webhook is enabled in the PayPal dashboard.
Test your endpoint manually.
curl -X POST https://yourdomain.com/webhooks/invoicing \
-H "Content-Type: application/json" \
-d '{"test": "data"}'PayPal disables endpoints after 100 or more consecutive failures. Once the issue is fixed, re-enable the webhook in the dashboard.
Symptom: PAID event arrives before SENT event.
Cause: Network delays, multiple events generated in quick succession, or PayPal retrying a failed delivery can cause events to arrive out of order.
To resolve this, don't assume event order. Fetch the current invoice status from the API when needed.
async function handleWebhook(event) {
// Get current state from API, don't trust event order
const invoice = await invoicingApi.getInvoice(event.resource.id);
console.log('Current status:', invoice.status);
}