# Webhooks (/api/invoicing/webhooks)

Learn how to register, validate, and handle PayPal Invoicing API webhook events in your application.



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.

## Webhook event types [#webhook-event-types]

The following events are triggered by the Invoicing API.

### Invoice state transitions [#invoice-state-transitions]

| 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.             |

### Modifications and notifications [#modifications-and-notifications]

| Event                       | Description                      |
| --------------------------- | -------------------------------- |
| `INVOICING.INVOICE.UPDATED` | Invoice modified before sending. |

### Administrative events [#administrative-events]

| Event                         | Description                            |
| ----------------------------- | -------------------------------------- |
| `INVOICING.INVOICE.SCHEDULED` | Invoice scheduled for future delivery. |

## Event payload structure [#event-payload-structure]

Every webhook event contains the following structure.

```json
{
  "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": "customer@example.com",
    "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"
    }
  ]
}
```

### Key fields [#key-fields]

| 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.                 |

## Create an endpoint [#create-an-endpoint]

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.

```javascript
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');
});
```

## Register endpoint [#register-endpoint]

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](/api/webhooks/v1/simulate-event-post/).

## Webhook signature validation [#webhook-signature-validation]

PayPal signs all webhook payloads. Validate the signature before processing each webhook to ensure authenticity and prevent unauthorized access.

### How signature validation works [#how-signature-validation-works]

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.                            |

### Validation process [#validation-process]

To validate a webhook:

1. Get the certificate from the `cert_url` provided.
2. Construct the verification string from the webhook headers in this exact order: `transmission_id|transmission_time|webhook_id|webhook_body_hash`.
3. Verify the signature using the public certificate and RSA algorithm.
4. Compare timestamps to prevent replay attacks. The timestamp must be within 5 minutes.

### Python example [#python-example]

```python
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 False
```

### Node.js example [#nodejs-example]

```javascript
const 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);
  });
}
```

## Handling duplicates and event idempotency [#handling-duplicates-and-event-idempotency]

PayPal uses at-least-once delivery, meaning the same webhook event may be delivered multiple times. Your system must handle duplicates safely.

### Why duplicates occur [#why-duplicates-occur]

Duplicates can occur for several reasons:

* Network timeouts or intermittent failures
* Server retries on temporary errors
* Load balancer request duplication
* Client acknowledgment delays

The same event with the same `id` and `create_time` can arrive multiple times over several minutes.

## Testing [#testing]

Test your webhook integration in sandbox before going live.

### Sandbox testing [#sandbox-testing]

To test your webhook integration in sandbox:

1. Register a webhook endpoint pointing to your sandbox environment.
2. Trigger invoice events using the Invoicing API create, send, and mark as paid operations.
3. Monitor webhook deliveries in the PayPal dashboard.

### Verifying event delivery [#verifying-event-delivery]

Use the following example to log incoming webhooks and confirm events are being delivered.

```javascript
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' });
});
```

### Expected event sequence [#expected-event-sequence]

When you create and send an invoice, then mark it as paid, PayPal fires events in this order:

1. `INVOICING.INVOICE.CREATED`: when the invoice is created
2. `INVOICING.INVOICE.SENT`: when you send the invoice
3. `INVOICING.INVOICE.PAID`: when you mark it as paid

## Common webhook scenarios [#common-webhook-scenarios]

The following examples show how to handle common invoice events in your webhook listener.

### Scenario: Update accounting system on invoice payment [#scenario-update-accounting-system-on-invoice-payment]

When an invoice is paid, update your accounting system and generate a payment receipt.

To trigger this scenario in sandbox:

```bash
# 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": "merchant@example.com"
    },
    "primary_recipients": [{
      "billing_info": {
        "email_address": "customer@sandbox.paypal.com"
      }
    }],
    "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.

```javascript
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);
}
```

### Scenario: Trigger fulfillment on invoice sent [#scenario-trigger-fulfillment-on-invoice-sent]

When an invoice is sent to the customer, initiate fulfillment to prepare for payment.

```javascript
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
  });
}
```

### Scenario: Audit trail and compliance logging [#scenario-audit-trail-and-compliance-logging]

Track all invoice events for compliance and troubleshooting.

```javascript
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
    });
  }
}
```

## Troubleshooting [#troubleshooting]

Use the following guidance to resolve common webhook issues.

### Webhook endpoint returns timeout error [#webhook-endpoint-returns-timeout-error]

**Symptom:** PayPal reports a webhook delivery timeout or an endpoint that is not responding.

**Causes:**

* The endpoint takes longer than 30 seconds to respond.
* The endpoint isn't accessible from the internet.
* The endpoint requires client certificate authentication.
* A firewall is blocking PayPal IP addresses.

To resolve this:

1. Return `200 OK` within 30 seconds, then process asynchronously.

```javascript
app.post('/webhooks/invoicing', (req, res) => {
  res.status(200).send('OK');  // Respond first
  handleWebhookAsync(req.body); // Process later
});
```

2. Test your endpoint from an external IP to verify accessibility.

```bash
curl -X POST https://yourdomain.com/webhooks/invoicing \
  -H "Content-Type: application/json" \
  -d '{"test": true}'
```

3. Verify that PayPal IPs are allowlisted in your firewall.

4. If your endpoint is behind a reverse proxy, set the HTTP timeout to 60 seconds or more.

### Signature validation fails [#signature-validation-fails]

**Symptom:** Error validating webhook signature or permission denied.

**Causes:**

* The webhook body was modified before validation.
* An incorrect webhook ID was used in the verification string.
* The certificate URL returns `404` or an expired certificate.
* The wrong hash algorithm was used.

To resolve this:

1. Validate the raw request body before parsing it as JSON.

```javascript
// 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);
});
```

2. Confirm you're using the correct webhook ID from the dashboard, not your client ID.

3. Cache certificates with a proper TTL to avoid repeated fetches and expired certificate errors.

```javascript
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;
}
```

### Duplicate events processed multiple times [#duplicate-events-processed-multiple-times]

**Symptom:** Payment recorded multiple times or inventory decremented multiple times.

**Causes:**

* The event ID isn't stored before processing.
* A unique constraint is missing on the `event_id` column.
* Processing isn't wrapped in a database transaction.

To resolve this:

1. Store the event ID before processing.

2. Add a unique constraint on `event_id`.

```sql
ALTER TABLE webhook_events ADD UNIQUE(event_id);
```

3. Wrap processing in a database transaction to ensure atomicity.

```sql
BEGIN TRANSACTION;
INSERT INTO webhook_events (event_id, ...) VALUES (...);
UPDATE invoices SET status = 'PAID' WHERE id = ...;
COMMIT;
```

### Webhooks stopped arriving [#webhooks-stopped-arriving]

**Symptom:** No webhooks received after working previously.

**Causes:**

* The webhook endpoint was disabled or deleted.
* The endpoint certificate expired.
* The endpoint returned non-`200` status codes repeatedly.
* PayPal disabled the endpoint after too many consecutive failures.

To resolve this:

1. Verify the webhook is enabled in the PayPal dashboard.

2. Test your endpoint manually.

```bash
curl -X POST https://yourdomain.com/webhooks/invoicing \
  -H "Content-Type: application/json" \
  -d '{"test": "data"}'
```

3. Review your server logs for errors.

> **Info:** PayPal disables endpoints after 100 or more consecutive failures. Once the issue is fixed, re-enable the webhook in the dashboard.

### Events arrive out of order [#events-arrive-out-of-order]

**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.

```javascript
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);
}
```
