Skip to content

Instantly share code, notes, and snippets.

@L422Y
Last active July 17, 2026 01:46
Show Gist options
  • Select an option

  • Save L422Y/f685c51db049f0b28558be1048566062 to your computer and use it in GitHub Desktop.

Select an option

Save L422Y/f685c51db049f0b28558be1048566062 to your computer and use it in GitHub Desktop.
Mass cancel PayPal Automatic Payments (billing agreements) from the browser console

Mass cancel PayPal Automatic Payments

A browser-console script that cancels every active PayPal billing agreement ("Automatic Payments") in your account. Runs in your own logged-in session — no credentials, no API keys, nothing leaves your browser.

Usage

  1. Log in and go to https://www.paypal.com/myaccount/autopay/
  2. Paste paypal-cancel-autopay.js into the DevTools console.
  3. Dry run — shows what it would cancel, touches nothing:
    await cancelAutopay()
  4. Review the table, then fire:
    await cancelAutopay({ apply: true })

The list lazy-loads behind a "More activity" button and renders only a handful at a time — an untouched page showed 8 agreements where the account actually had 100. Both calls click through it automatically before doing anything.

Cancels serially, 2s apart. Only ACTIVE agreements are attempted — the page lists inactive ones too, and those fail with SERVICE_ERROR. Re-running after a partial failure is safe; anything already cancelled is skipped.

Options: { apply, ids: ['B-…'], delayMs, csrf }. Pass ids to target specific agreements (and skip the expansion). Also exposed: expandList() and inspectAgreement('B-…'), which dumps one agreement's raw detail payload.

Read this before running it

  • Cancelling the automatic payment does not cancel the service. The merchant may bill you another way, or treat it as a missed payment and start dunning you. Anything you actually still use is better cancelled merchant-side.
  • It's irreversible. PayPal will not reactivate a cancelled agreement.
  • Test on one first: await cancelAutopay({ apply: true, ids: ['B-…'] })

How it works

PayPal's own page calls an undocumented endpoint:

POST /myaccount/autopay/api/connect/{agreementId}/cancel
x-requested-with: XMLHttpRequest
content-type: application/json

{"_csrf":"…","data":{"fundingId":"…","agreementStatus":"ACTIVE",
 "agreementState":"ACTIVE","isWebRefreshOn":true},
 "headers":{"Accept":"application/json","Content-Type":"application/json"}}

→ 200 {"status":"INACTIVE"}

Things worth knowing if you're adapting this:

  • _csrf goes in the body, not a header. The script scrapes it from the page (meta tag → #__react_data__ → inline HTML regex) and aborts if it can't find it. Note #__react_data__ does not exist on the autopay page, so the pattern used by finance-dl for /myaccount/transactions/ doesn't apply here.
  • fundingId is per-agreement, so it can't be hardcoded from one capture. GET /api/connect/{id} returns it as funding.id, along with the status and state the cancel body needs.
  • fundingId is often absent, and that's fine. Agreements backed by your PayPal balance have a funding object with no id (just isPayPalBalance: true). Omit the key entirely and the cancel succeeds — roughly a third of one test account's agreements were like this.
  • status/state are top-level on the detail response, not agreementStatus/ agreementState as the cancel body names them.

Verified against a real account

100 agreements enumerated; 30+ cancelled in one run, all 200 {"status":"INACTIVE"}, mixing card-backed and balance-backed. No rate limiting hit at 2s intervals.

Untested: I-… agreements (PayPal Payments Standard subscriptions, a different object from the B-… reference transactions). If you have those, watch whether they come back SERVICE_ERROR while the B-… ones succeed — they may need a different path.

Why not the official REST API

POST /v1/payments/billing-agreements/{id}/cancel exists, but it's deprecated and its OAuth2 client-credentials flow authenticates the merchant's app, not the payer. Your agreements belong to each merchant's account — you're only the payer. There's no consumer grant for "cancel the agreements where I'm the payer," so you'd need every merchant's client-id/secret. Dead end.

Related

/**
* Mass-cancel PayPal Automatic Payments (billing agreements).
*
* Cookies are attached by the browser — nothing here touches credentials.
*
* The endpoint, reverse-engineered from the page's own traffic:
* POST /myaccount/autopay/api/connect/{agreementId}/cancel
* x-requested-with: XMLHttpRequest
* {"_csrf":"…","data":{"fundingId":"…","agreementStatus":"ACTIVE",
* "agreementState":"ACTIVE","isWebRefreshOn":true},
* "headers":{"Accept":"application/json","Content-Type":"application/json"}}
* → 200 {"status":"INACTIVE"}
*
* Note _csrf goes in the BODY, not a header. fundingId is per-agreement (it's the
* card or balance backing that agreement), so it's read from each detail response
* rather than reused — GET /api/connect/{id} returns status, state, and funding.id.
*
* Usage:
* 1. Log in, go to https://www.paypal.com/myaccount/autopay/
* 2. Paste this file into the DevTools console.
* 3. await cancelAutopay() // dry run: shows what it would do
* 4. await cancelAutopay({ apply: true }) // fires, serially, 2s apart
*
* The list lazy-loads behind a "More activity" button and only renders a handful
* at a time; both calls click through it first. Passing `ids` skips that.
*
* Only ACTIVE agreements are cancelled; the page lists inactive ones too and those
* 400 with SERVICE_ERROR if you try. Re-running after a partial failure is safe.
*
* Cancelling does NOT cancel the underlying service — the merchant may bill you
* another way or treat it as a missed payment. And PayPal will not reactivate a
* cancelled agreement.
*/
// Wrapped in an IIFE so re-pasting into the same console works. Top-level `const`
// would throw "already declared" on the second paste and silently abort the whole
// file, leaving the previous version running.
(() => {
const AUTOPAY_API = 'https://www.paypal.com/myaccount/autopay/api/connect';
/** The token is in the page somewhere; the capture proves the value exists client-side. */
function findCsrf() {
const meta = document.querySelector('meta[name="csrf-token"], meta[name="_csrf"]');
if (meta?.content) return meta.content;
const reactData = document.getElementById('__react_data__');
if (reactData) {
try {
const parsed = JSON.parse(atob(reactData.getAttribute('data')));
if (parsed._csrf) return parsed._csrf;
} catch {}
}
// Server-rendered Node app: the token is usually inlined in the initial HTML.
const inline = document.documentElement.innerHTML.match(/"_csrf"\s*:\s*"([^"]+)"/);
if (inline) return inline[1];
return null;
}
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
async function waitUntil(predicate, timeoutMs = 8000, stepMs = 250) {
for (let waited = 0; waited < timeoutMs; waited += stepMs) {
await wait(stepMs);
if (predicate()) return true;
}
return false;
}
/**
* The list lazy-loads behind a "More activity" button; unexpanded it showed 8 of 100.
* Click until the button is gone or the list stops growing.
*/
async function expandList({ maxClicks = 200 } = {}) {
let clicks = 0;
for (;;) {
const btn = [...document.querySelectorAll('button[name="moreActivity"]')].find(
(b) => !b.disabled && b.offsetParent !== null
);
if (!btn) break;
if (clicks >= maxClicks) {
console.warn(`Stopped after ${maxClicks} clicks — "More activity" is still there. Raise maxClicks.`);
break;
}
const before = agreementIdsOnPage().length;
btn.click();
clicks++;
if (!(await waitUntil(() => agreementIdsOnPage().length > before))) {
console.warn(`Clicked "More activity" but the list stopped growing at ${before}. Stopping.`);
break;
}
console.log(` expanded to ${agreementIdsOnPage().length}…`);
}
return { clicks, total: agreementIdsOnPage().length };
}
function agreementIdsOnPage() {
const ids = new Set();
for (const a of document.querySelectorAll('a[href*="/autopay/connect/"]')) {
const m = a.getAttribute('href').match(/\/autopay\/connect\/((?:B|BA|I)-[A-Z0-9]+)/);
if (m) ids.add(m[1]);
}
return [...ids];
}
/** Errors come back as text, not JSON — .json() alone turns the diagnosis into null. */
async function readBody(res) {
const text = await res.text().catch(() => '');
if (!text) return null;
try {
return JSON.parse(text);
} catch {
return text.slice(0, 2000);
}
}
async function fetchDetail(agreementId) {
const res = await fetch(`${AUTOPAY_API}/${agreementId}`, {
credentials: 'same-origin',
headers: { accept: 'application/json, text/plain, */*', 'x-requested-with': 'XMLHttpRequest' },
});
return { status: res.status, body: await readBody(res) };
}
async function cancelOne({ agreementId, csrf, fundingId, status, state }) {
const res = await fetch(`${AUTOPAY_API}/${agreementId}/cancel`, {
method: 'POST',
credentials: 'same-origin',
headers: {
accept: 'application/json, text/plain, */*',
'content-type': 'application/json',
'x-requested-with': 'XMLHttpRequest',
},
body: JSON.stringify({
_csrf: csrf,
data: {
// Balance-backed agreements have a funding object with no id — nothing to send.
// undefined drops the key entirely rather than sending null.
fundingId: fundingId ?? undefined,
agreementStatus: status,
agreementState: state,
isWebRefreshOn: true,
},
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
}),
});
return { ok: res.ok, status: res.status, body: await readBody(res) };
}
/** Dump one agreement's raw detail payload, to find where the real field names live. */
async function inspectAgreement(agreementId) {
const { status, body } = await fetchDetail(agreementId);
console.log(`GET /api/connect/${agreementId} — http ${status}`);
console.log(JSON.stringify(body, null, 2));
return body;
}
async function cancelAutopay({ apply = false, ids = null, delayMs = 2000, csrf: csrfOverride = null } = {}) {
const csrf = csrfOverride || findCsrf();
if (!csrf) {
console.error(
'No _csrf token found on this page. It exists (the captured request carries one), ' +
'but not anywhere findCsrf() looks. Grab it from a fresh cancel request and pass it in.'
);
return null;
}
if (!ids) {
console.log('Expanding the list…');
const { clicks, total } = await expandList();
console.log(`Fully expanded: ${total} agreement(s) after ${clicks} click(s).`);
}
const agreementIds = ids || agreementIdsOnPage();
if (!agreementIds.length) {
console.warn('No agreement links found. Are you on https://www.paypal.com/myaccount/autopay/ ?');
return null;
}
console.log(`Found ${agreementIds.length} agreement(s). Reading details…`);
const plan = [];
for (const agreementId of agreementIds) {
const { status: detailHttp, body } = await fetchDetail(agreementId);
plan.push({
agreementId,
detailHttp,
merchant: body?.merchantName ?? '?',
status: body?.status ?? null,
state: body?.state ?? null,
totalPaid: body?.totalAmountPaid ?? null,
fundingId: body?.funding?.id ?? null,
});
await new Promise((r) => setTimeout(r, 300));
}
console.table(plan);
// The autopay page lists inactive agreements too; cancelling one 400s (SERVICE_ERROR).
// A missing fundingId is normal, not an error: balance-backed agreements have no
// funding instrument pinned, so funding.id is simply absent.
const ready = plan.filter((p) => p.status === 'ACTIVE');
const inactive = plan.filter((p) => p.status && p.status !== 'ACTIVE');
const broken = plan.filter((p) => !p.status);
if (inactive.length) {
console.log(
`${inactive.length} already inactive, skipping:`,
inactive.map((p) => `${p.merchant} (${p.status})`)
);
}
if (broken.length) {
console.warn(`${broken.length} could not be read, skipping:`, broken);
}
if (!apply) {
console.log(
`DRY RUN — would cancel ${ready.length} ACTIVE of ${plan.length} listed:`,
ready.map((p) => p.merchant)
);
console.log('To fire: await cancelAutopay({ apply: true })');
return plan;
}
const results = [];
for (const item of ready) {
const res = await cancelOne({ ...item, csrf });
// Keep the response body: a bare status code says nothing about WHY a 400 happened.
results.push({
agreementId: item.agreementId,
merchant: item.merchant,
status: res.status,
ok: res.ok,
response: res.body,
});
console.log(`${res.ok ? '✓' : '✗'} ${item.merchant} (${item.agreementId}) — http ${res.status}`, res.body ?? '');
await new Promise((r) => setTimeout(r, delayMs));
}
console.table(results.map(({ response, ...row }) => row));
if (results.some((r) => !r.ok)) console.log('Failures with full response bodies:', results.filter((r) => !r.ok));
return results;
}
window.cancelAutopay = cancelAutopay;
window.inspectAgreement = inspectAgreement;
window.expandList = expandList;
window.findCsrf = findCsrf;
console.log('Ready. Dry run: await cancelAutopay() — inspect: await inspectAgreement("B-…")');
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment