# Troubleshooting (/invoicing/troubleshooting)

Troubleshooting guide for common PayPal Invoicing API integration issues.



Use this guide to identify and fix common issues when integrating the PayPal Invoicing API.

## Issues with authentication and credentials [#issues-with-authentication-and-credentials]

The following issues relate to access tokens and account credentials used in your API requests.

### Invoicer email doesn't match your access token [#invoicer-email-doesnt-match-your-access-token]

**Symptom:** `USER_NOT_FOUND` or `INVALID_REQUEST` error when creating or sending an invoice, even though your credentials appear correct.

**Cause:** The `invoicer.email_address` in your request body must exactly match the PayPal Business account associated with your access token. Using a placeholder or production email in sandbox is one of the most common causes of this error.

To resolve this, find the correct sandbox email:

1. Log in to the [PayPal Developer Dashboard](https://www.paypal.com/signin?returnUri=https%3A%2F%2Fdeveloper.paypal.com%2Fdashboard\&state=%2F\&intent=developer\&ctxId=ul95754158832847379f13a37763c50997).
2. Go to **Testing Tools** > **Sandbox Accounts**.
3. Find your business account, and then select &#x2A;*⋮** > **View/Edit Account**.
4. Copy the email address shown, and use it as the `invoicer.email_address` value in sandbox.

```json
{
  "invoicer": {
    "email_address": "sb-business@yoursandbox.com",
    "name": {
      "given_name": "Your",
      "surname": "Business"
    }
  }
}
```

In production, replace this with the email address associated with your live business account.

### Access token expires mid-session [#access-token-expires-mid-session]

**Symptom:** Requests work initially, then return `401 AUTHENTICATION_FAILURE` after a period of time, often in long-running processes or background jobs.

**Cause:** PayPal access tokens expire after approximately 9 hours. The `expires_in` value is returned in the token response. If you store the token once at startup and never refresh it, your requests will fail after it expires.

To resolve this, cache the token with its expiry time and proactively refresh it before it expires.

```javascript
let tokenCache = { token: null, expiresAt: 0 };

async function getAccessToken() {
  // Refresh 60 seconds before expiry
  if (tokenCache.token && Date.now() < tokenCache.expiresAt - 60_000) {
    return tokenCache.token;
  }

  const res = await fetch("https://api-m.paypal.com/v1/oauth2/token", {
    method: "POST",
    headers: {
      Authorization:
        "Basic " +
        Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64"),
      "Content-Type": "application/x-www-form-urlencoded",
    },
    body: "grant_type=client_credentials",
  });

  const data = await res.json();
  tokenCache = {
    token: data.access_token,
    expiresAt: Date.now() + data.expires_in * 1000,
  };

  return tokenCache.token;
}
```

Call `getAccessToken()` at the start of every API call rather than reading from a static variable.

## Issues with invoice creation and updates [#issues-with-invoice-creation-and-updates]

The following issues relate to creating, updating, and managing invoices through the API.

### `PUT` updates silently discard fields [#put-updates-silently-discard-fields]

**Symptom:** You call PUT to update a single field, but other fields disappear or revert.

**Cause:** The `PUT /v2/invoicing/invoices/{id}` endpoint performs a full replacement and does not support partial updates. Any fields omitted from the request body are removed from the invoice.

To resolve this:

1. Call `GET /v2/invoicing/invoices/{id}` to retrieve the current invoice.
2. Merge your changes into the full response object.
3. Submit the complete payload in your PUT request.

```javascript
async function updateInvoiceField(invoiceId, updates, token) {
  // Step 1: fetch the full invoice
  const getRes = await fetch(
    `https://api-m.paypal.com/v2/invoicing/invoices/${invoiceId}`,
    { headers: { Authorization: `Bearer ${token}` } },
  );
  const current = await getRes.json();

  // Step 2: merge your changes
  const updated = { ...current, ...updates };

  // Step 3: PUT the full object back
  const putRes = await fetch(
    `https://api-m.paypal.com/v2/invoicing/invoices/${invoiceId}`,
    {
      method: "PUT",
      headers: {
        Authorization: `Bearer ${token}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify(updated),
    },
  );

  return putRes.json();
}
```

### Duplicate invoices from retried requests [#duplicate-invoices-from-retried-requests]

**Symptom:** After a network timeout or 5xx error, you retry a `POST /v2/invoicing/invoices` call and end up with two identical invoices.

**Cause:** Without a `PayPal-Request-Id` header, PayPal treats each request as a new operation. Retrying a failed request without this header creates duplicate invoices.

To resolve this, generate a unique, stable ID for each logical operation and pass it as the `PayPal-Request-Id` header. If PayPal has already processed a request with that ID, it returns the original response instead of creating a duplicate.

> **Info:** Store the `PayPal-Request-Id` before making the request. If the request times
> out and you're unsure whether PayPal received it, retry with the same ID to
> safely retrieve the original result.

```javascript
const { v4: uuidv4 } = require("uuid");

async function createInvoice(payload, token) {
  const requestId = uuidv4(); // generate once, store if you need to retry

  const res = await fetch("https://api-m.paypal.com/v2/invoicing/invoices", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${token}`,
      "Content-Type": "application/json",
      "PayPal-Request-Id": requestId,
    },
    body: JSON.stringify(payload),
  });

  return res.json();
}
```

### Still using the v1 Invoicing API [#still-using-the-v1-invoicing-api]

**Symptom:** Newer features such as partial payments, tips, and QR codes are unavailable, or you're seeing deprecation warnings.

**Cause:** The `/v1/invoicing/invoices` endpoint is deprecated. All new integrations must use `/v2/invoicing/invoices`.

The v2 API uses a restructured request body. Key differences:

| Field          | v1 location   | v2 location                         |
| -------------- | ------------- | ----------------------------------- |
| `billing_info` | Top-level     | `primary_recipients[].billing_info` |
| `currency`     | Per line item | `detail` level                      |

See the v2 [Invoicing API reference](/api/invoicing/v2/connections-get/) for the full schema.

## Issues with webhooks [#issues-with-webhooks]

The following issues relate to webhook registration, event delivery, and signature verification.

### INVOICING.INVOICE.PAID webhook not firing [#invoicinginvoicepaid-webhook-not-firing]

**Symptom:** Your webhook listener never receives the `INVOICING.INVOICE.PAID` event, even after an invoice is paid in the sandbox.

**Causes:**

* **Wrong app registration.** PayPal only fires webhook events for the REST app that processed the action. If your invoice was created with one set of credentials and your webhook is registered under a different app, no event is sent. To resolve this, check which app your webhook is registered to and confirm the client ID matches the app used to create invoices.

```bash
curl -X GET https://api-m.sandbox.paypal.com/v1/notifications/webhooks \
  -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \
  -H 'Content-Type: application/json'
```

* **Listener not responding fast enough.** PayPal requires your endpoint to return `HTTP 200` before your business logic runs. If your handler takes too long or throws an error, PayPal marks the delivery as failed and retries up to 25 times over 3 days. To resolve this, return `HTTP 200` immediately and process the event asynchronously.

```javascript
app.post("/webhook", (req, res) => {
  res.sendStatus(200); // acknowledge immediately

  const event = req.body;
  processWebhookAsync(event).catch(console.error); // handle separately
});
```

* **Testing with simulated events.** The Webhooks Simulator sends mock events using a special webhook ID, not your real registered ID. Signature verification fails for simulated events. Use real sandbox transactions to test end-to-end.

### Webhook signature verification returns `FAILURE` [#webhook-signature-verification-returns-failure]

**Symptom:** You call `POST /v1/notifications/verify-webhook-signature` and always receive `"verification_status": "FAILURE"`, even with the correct headers.

**Cause:** Middleware frameworks that parse the request body before you read it. For example, Express with `express.json()` can subtly alter the raw bytes, breaking the signature check.

To resolve this:

* Use `express.raw()` on your webhook route to preserve the exact payload.
* Pass your registered webhook ID, not your client ID. Retrieve your webhook ID with `GET /v1/notifications/webhooks`.

```javascript
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const rawBody = req.body.toString("utf8"); // preserve exact bytes

  const verifyPayload = {
    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: "YOUR_REGISTERED_WEBHOOK_ID", // from Developer Dashboard, not your client ID
    webhook_event: JSON.parse(rawBody),
  };

  // POST verifyPayload to /v1/notifications/verify-webhook-signature
  res.sendStatus(200);
});
```

## Issues with environment configuration [#issues-with-environment-configuration]

The following issues relate to sandbox and live environment setup and credential configuration.

### Sandbox and live environment mix-up [#sandbox-and-live-environment-mix-up]

**Symptoms:** `401 AUTHENTICATION_FAILURE` or `RESOURCE_NOT_FOUND` errors even though credentials appear correct, or test invoices don't appear in your live account.

**Cause:** Sandbox and live environments use different base URLs and entirely separate credentials. Hardcoding either in your application is a common source of authentication failures.

| Environment | Base URL                           |
| ----------- | ---------------------------------- |
| Sandbox     | `https://api-m.sandbox.paypal.com` |
| Live        | `https://api-m.paypal.com`         |

To resolve this, drive the base URL and credentials from environment variables so there's no hardcoded switching.

```javascript
const PAYPAL_BASE =
  process.env.PAYPAL_ENV === "production"
    ? "https://api-m.paypal.com"
    : "https://api-m.sandbox.paypal.com";

const CLIENT_ID = process.env.PAYPAL_CLIENT_ID;
const CLIENT_SECRET = process.env.PAYPAL_CLIENT_SECRET;
```

When going live, verify that your live PayPal Business account settings, such as invoice preferences and IPN listeners, match your sandbox configuration.

## Issues with performance and rate limits [#issues-with-performance-and-rate-limits]

The following issues relate to request volume, rate limiting, and retry behavior.

### Rate limit errors under high request volume [#rate-limit-errors-under-high-request-volume]

**Symptom:** You receive `429 RATE_LIMIT_REACHED` errors when creating or sending many invoices in quick succession.

To resolve this:

* Implement exponential backoff with jitter on any request that returns `429` or `5xx`.
* If you're hitting rate limits regularly, batch invoice creation during off-peak hours and cache `GET` responses where possible.

```javascript
async function requestWithBackoff(fn, maxRetries = 4) {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      const res = await fn();
      if (res.status === 429 || res.status >= 500) {
        const delay = Math.pow(2, attempt) * 1000 + Math.random() * 500;
        await new Promise((r) => setTimeout(r, delay));
        continue;
      }
      return res;
    } catch (err) {
      if (attempt === maxRetries - 1) throw err;
    }
  }
}

const res = await requestWithBackoff(() =>
  fetch("https://api-m.paypal.com/v2/invoicing/invoices", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${token}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify(payload),
  }),
);
```
