On this page
No Headings
Last updated: June 10, 2026
Estimated time: 10 minutes
Use this guide to identify and fix common issues when integrating the PayPal Invoicing API.
The following issues relate to access tokens and account credentials used in your API requests.
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:
invoicer.email_address value in sandbox.{
"invoicer": {
"email_address": "[email protected]",
"name": {
"given_name": "Your",
"surname": "Business"
}
}
}In production, replace this with the email address associated with your live business account.
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.
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.
The following issues relate to creating, updating, and managing invoices through the API.
PUT updates silently discard fieldsSymptom: 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:
GET /v2/invoicing/invoices/{id} to retrieve the current invoice.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();
}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.
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.
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();
}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 for the full schema.
The following issues relate to webhook registration, event delivery, and signature verification.
Symptom: Your webhook listener never receives the INVOICING.INVOICE.PAID event, even after an invoice is paid in the sandbox.
Causes:
curl -X GET https://api-m.sandbox.paypal.com/v1/notifications/webhooks \
-H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \
-H 'Content-Type: application/json'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.app.post("/webhook", (req, res) => {
res.sendStatus(200); // acknowledge immediately
const event = req.body;
processWebhookAsync(event).catch(console.error); // handle separately
});FAILURESymptom: 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:
express.raw() on your webhook route to preserve the exact payload.GET /v1/notifications/webhooks.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);
});The following issues relate to sandbox and live environment setup and credential configuration.
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.
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.
The following issues relate to request volume, rate limiting, and retry behavior.
Symptom: You receive 429 RATE_LIMIT_REACHED errors when creating or sending many invoices in quick succession.
To resolve this:
429 or 5xx.GET responses where possible.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),
}),
);