> ## Documentation Index
> Fetch the complete documentation index at: https://outcrawl.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Cancel every subscription you forgot about

> One agent per service walks the cancellation flow, declines every offer, and stops at the last button until you approve.

export const OutcrawlStyles = () => {
  if (typeof document !== 'undefined' && !document.getElementById('outcrawl-overrides')) {
    const s = document.createElement('style');
    s.id = 'outcrawl-overrides';
    s.textContent = `
      /* Match the product's radius grid (4 / 6 / 8 / 9999), not Mintlify's default. */
      .rounded-sm { border-radius: 0.375rem !important; } /* 6px, --r-ctl */
      .rounded-md { border-radius: 0.375rem !important; } /* 6px, --r-ctl */
      .rounded-lg { border-radius: 0.5rem !important; }   /* 8px, --r-panel */
      .rounded-l-sm { border-top-left-radius: 0.375rem !important; border-bottom-left-radius: 0.375rem !important; }
      .rounded-r-sm { border-top-right-radius: 0.375rem !important; border-bottom-right-radius: 0.375rem !important; }
      .rounded-l-md { border-top-left-radius: 0.375rem !important; border-bottom-left-radius: 0.375rem !important; }
      .rounded-r-md { border-top-right-radius: 0.375rem !important; border-bottom-right-radius: 0.375rem !important; }
      .rounded-l-lg { border-top-left-radius: 0.5rem !important; border-bottom-left-radius: 0.5rem !important; }
      .rounded-r-lg { border-top-right-radius: 0.5rem !important; border-bottom-right-radius: 0.5rem !important; }

      /* Cards: never pure white in light mode, matching the one-big-card surface tone. */
      .card { background-color: #fcfcfc !important; border-radius: 0.5rem !important; }
      html.dark .card { background-color: #141311 !important; }

      /* Docs hero box */
      .oc-hero { background-color: #fcfcfc; border: 1px solid #e5e2dc; }
      html.dark .oc-hero { background-color: #141311; border-color: #26241f; }
      html.dark .oc-hero h1 { color: #edeae4; }

      /* Thin, transparent-track, hide-until-hover scrollbar. */
      ::-webkit-scrollbar { width: 6px; height: 6px; background-color: transparent; }
      ::-webkit-scrollbar-track { background-color: transparent; }
      ::-webkit-scrollbar-thumb { background-color: rgba(155, 155, 155, 0.5); border-radius: 10px; transition: opacity 0.3s ease; opacity: 0; }
      ::-webkit-scrollbar-thumb:hover { background-color: rgba(155, 155, 155, 0.7); }
      *:hover::-webkit-scrollbar-thumb,
      *:focus::-webkit-scrollbar-thumb,
      *:active::-webkit-scrollbar-thumb { opacity: 1; }
      * { scrollbar-width: thin; scrollbar-color: rgba(155, 155, 155, 0.5) transparent; }
    `;
    document.head.appendChild(s);
  }
  return null;
};

<OutcrawlStyles />

Cancelling a subscription is slow on purpose: a survey, a discount, a pause offer, a second discount, then the real button at the bottom of the page. Four of them take an evening. This program sends one agent per service, each signed in through its own profile, to walk every cancellation flow as far as the last button and stop there. You read what each one found and pick which to cancel, and a second run presses the button for those, and only those.

## What you need

* An Outcrawl API key in `OUTCRAWL_API_KEY`.
* One [profile](/docs/account/profiles) per service, signed in to that site and labelled `subs-<service>` (`subs-nytimes`, `subs-audible`...). Sign each one in once: from TypeScript with `oc.browser({ profile })`, or with an agent run that types your password from a [text secret](/docs/account/secrets). If a site emails a sign-in code, that run reads it through a connected inbox [integration](/docs/account/integrations), such as AgentMail.
* A terminal. The program asks you which services to cancel between its two passes.

## How it works

<Steps>
  <Step title="Each service gets its own signed-in browser">
    Every run names its service's [profile](/docs/account/profiles), so it starts signed in. A profile opens one browser at a time, so a service's two runs never overlap, while different services run side by side, `MAX_AT_ONCE` at a time. Every key on your account shares one browser slot count. Browsers, agent runs, crawls, searches and scrapes that aren't served from cache each hold a slot. A submit over the limit is refused with `ConcurrencyLimitError`, and the program waits 20 seconds and tries again.
  </Step>

  <Step title="Pass 1 walks each flow to the last screen">
    An [agent run](/docs/agents/overview) answers the survey, declines every offer, and stops on the final screen without pressing its button. Its [schema](/docs/agents/overview#typed-results) brings back the plan, the price, the renewal date, every screen it passed, the offers it declined, any fee to cancel, and the exact label of the final button. The [rule](/docs/account/rules) `Only use nytimes.com.` is enforced: the agent cannot go to any other site.
  </Step>

  <Step title="You choose">
    Nothing has been cancelled yet. The program prints what each run found and asks which to cancel. No run is waiting on you: the approval sits between two runs.
  </Step>

  <Step title="Pass 2 presses the button you approved">
    A fresh run on the same profile follows the path pass 1 found, declines the offers again, and presses the final button, unless the fee on that screen differs from the one you saw. It returns the confirmation and the last day of access, or exactly what is left for you to do.
  </Step>
</Steps>

<Note>
  The stop at the last screen is in the task, not in a rule. Rules can block a payment, an order, a message or a sign-up, but Outcrawl's irreversible check reads a button that starts with "Cancel" as a way out of a page, so no rule can block a cancellation. That is why nothing rests on pass 1: the only run told to press the final button is the one you approved. Read `path` in pass 1's answer before you say yes.
</Note>

## The program

<CodeGroup>
  ```typescript TypeScript theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
  import { writeFile } from 'node:fs/promises';
  import { stdin, stdout } from 'node:process';
  import { createInterface } from 'node:readline/promises';
  import { setTimeout as sleep } from 'node:timers/promises';
  import { ConcurrencyLimitError, Outcrawl, QuotaExceededError } from '@outcrawl/sdk';
  import type { AgentRequest } from '@outcrawl/sdk';

  const oc = new Outcrawl();

  // One row per subscription. Each profile is already signed in to that site.
  const services = [
    { name: 'The New York Times', site: 'nytimes.com', start: 'https://myaccount.nytimes.com/seg/subscription', profile: 'subs-nytimes' },
    { name: 'Audible', site: 'audible.com', start: 'https://www.audible.com/account/overview', profile: 'subs-audible' },
    { name: 'Adobe Creative Cloud', site: 'adobe.com', start: 'https://account.adobe.com/plans', profile: 'subs-adobe' },
    { name: 'Headspace', site: 'headspace.com', start: 'https://www.headspace.com/subscriptions', profile: 'subs-headspace' },
  ];

  // Runs at once. Keep it below your plan's browser count (Free 2, Builder 5, Pro 25, Scale 100).
  const MAX_AT_ONCE = 2;

  interface Walkthrough {
    active: boolean;
    plan?: string;
    price?: string;
    renewsOn?: string;
    path: string[];
    offersDeclined?: string[];
    finalButton?: string;
    feeToCancel?: string;
    blocker?: string;
  }

  interface Cancellation {
    cancelled: boolean;
    accessEndsOn?: string;
    confirmation?: string;
    leftForYou?: string;
  }

  const walkthroughSchema = {
    type: 'object',
    properties: {
      active: { type: 'boolean', description: 'Whether this account has an active paid subscription' },
      plan: { type: 'string' },
      price: { type: 'string', description: 'Price per billing period, with currency' },
      renewsOn: { type: 'string', description: 'Next renewal date, YYYY-MM-DD' },
      path: { type: 'array', items: { type: 'string' }, description: 'Each screen passed, in order' },
      offersDeclined: { type: 'array', items: { type: 'string' } },
      finalButton: { type: 'string', description: 'Exact label of the button that ends the subscription' },
      feeToCancel: { type: 'string', description: 'Any fee for cancelling now, with currency, or "none"' },
      blocker: { type: 'string', description: 'What stopped you before the final screen, if anything' },
    },
    required: ['active', 'path'],
  };

  const cancellationSchema = {
    type: 'object',
    properties: {
      cancelled: { type: 'boolean' },
      accessEndsOn: { type: 'string', description: 'Last day of access, YYYY-MM-DD' },
      confirmation: { type: 'string', description: 'Confirmation message or number shown' },
      leftForYou: { type: 'string', description: 'What a person still has to do, if not cancelled' },
    },
    required: ['cancelled'],
  };

  // Submit a run and answer its id, waiting for a free browser slot instead of failing.
  async function start(request: AgentRequest): Promise<string> {
    for (;;) {
      try {
        return (await oc.agent(request).submitted).id;
      } catch (error) {
        if (error instanceof ConcurrencyLimitError) {
          await sleep(20_000); // no Retry-After: a slot frees when one of your runs ends
        } else if (error instanceof QuotaExceededError && error.ceiling === 'requests_per_minute') {
          await sleep(error.retryAfterSeconds * 1000); // the error says how long to wait
        } else {
          throw error; // out of monthly credits, or a bad request: retrying cannot help
        }
      }
    }
  }

  // Run `work` over every item, at most `size` at a time.
  async function inPool<T, R>(items: readonly T[], size: number, work: (item: T) => Promise<R>): Promise<R[]> {
    const results: R[] = new Array<R>(items.length);
    let next = 0;
    const worker = async () => {
      while (next < items.length) {
        const index = next++;
        results[index] = await work(items[index] as T);
      }
    };
    await Promise.all(Array.from({ length: Math.min(size, items.length) }, worker));
    return results;
  }

  const profiles = await oc.profiles.list({ label: 'subs-*' });
  const profileIds = new Map(profiles.map((profile) => [profile.label, profile.id]));
  for (const service of services) {
    if (!profileIds.has(service.profile)) throw new Error(`No profile labelled ${service.profile}`);
  }

  // Pass 1: walk each cancellation flow up to its last screen, and stop there.
  const walkthroughs = await inPool(services, MAX_AT_ONCE, async (service) => {
    const run = await oc.agent.job(await start({
      task:
        `You are signed in to ${service.name}. Starting at ${service.start}, find how to cancel the subscription ` +
        'on this account and work through the cancellation flow. Answer any survey with "I no longer use it" and ' +
        'decline every retention offer, discount, downgrade and pause. Stop on the final screen, the one whose ' +
        'button would end the subscription, and do not press that button. If there is no active subscription, say so.',
      caps: { budget: '150.00', duration: '10m' },
      profile: profileIds.get(service.profile),
      rules: [`Only use ${service.site}.`],
      schema: walkthroughSchema,
    })).settled();
    return { service, walkthrough: run.data as Walkthrough | undefined, credits: run.credits };
  });

  walkthroughs.forEach(({ service, walkthrough }, index) => {
    if (walkthrough === undefined) {
      console.log(`${index + 1}. ${service.name}: no answer, cancel this one yourself`);
    } else if (!walkthrough.active) {
      console.log(`${index + 1}. ${service.name}: no active subscription`);
    } else {
      console.log(
        `${index + 1}. ${service.name}: ${walkthrough.plan ?? '?'} at ${walkthrough.price ?? '?'}, renews ${walkthrough.renewsOn ?? '?'}. ` +
          (walkthrough.finalButton !== undefined
            ? `Fee to cancel: ${walkthrough.feeToCancel ?? 'none'}. Ready to press "${walkthrough.finalButton}".`
            : `Stopped early: ${walkthrough.blocker ?? 'no reason given'}`),
      );
    }
  });

  // You decide. Nothing has been cancelled yet.
  const prompt = createInterface({ input: stdin, output: stdout });
  const answer = await prompt.question('Cancel which? Numbers separated by commas, "all", or Enter for none: ');
  prompt.close();

  const ready = walkthroughs.filter(({ walkthrough }) => walkthrough?.active === true && walkthrough.finalButton !== undefined);
  const approved =
    answer.trim() === 'all'
      ? ready
      : answer
          .split(',')
          .map((part) => walkthroughs[Number(part.trim()) - 1])
          .filter((row): row is (typeof walkthroughs)[number] => row !== undefined && ready.includes(row));

  // Pass 2: only the ones you approved, following the path pass 1 found.
  const cancellations = await inPool(approved, MAX_AT_ONCE, async ({ service, walkthrough }) => {
    const steps = walkthrough as Walkthrough;
    const run = await oc.agent.job(await start({
      task:
        `You are signed in to ${service.name}. I have approved cancelling this subscription. Last time you reached ` +
        `the final screen this way: ${steps.path.join(' > ')}. Decline every retention offer again, then press ` +
        `"${steps.finalButton}" on the final screen. The fee to cancel I approved is: ${steps.feeToCancel ?? 'none'}. ` +
        'If the final screen shows a different fee, do not press it. Report the confirmation. If anything stops you (a sign-in, a ' +
        'cancellation that must be done by phone or chat, a page that changed), stop and say exactly what is left for me to do.',
      caps: { budget: '100.00', duration: '10m' },
      profile: profileIds.get(service.profile),
      rules: [`Only use ${service.site}.`],
      schema: cancellationSchema,
    })).settled();
    const result = run.data as Cancellation | undefined;
    return { service: service.name, ...(result ?? { cancelled: false, leftForYou: `Run ended ${run.stopReason ?? run.status}; see ${run.replayUrl ?? 'the console'}` }) };
  });

  console.table(cancellations);
  await writeFile('cancellations.json', JSON.stringify({ walkthroughs, cancellations }, null, 2));
  ```

  ```python Python theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
  import asyncio
  import json

  from outcrawl import ConcurrencyLimitError, Outcrawl, QuotaExceededError

  # One row per subscription. Each profile is already signed in to that site.
  SERVICES = [
      {"name": "The New York Times", "site": "nytimes.com", "start": "https://myaccount.nytimes.com/seg/subscription", "profile": "subs-nytimes"},
      {"name": "Audible", "site": "audible.com", "start": "https://www.audible.com/account/overview", "profile": "subs-audible"},
      {"name": "Adobe Creative Cloud", "site": "adobe.com", "start": "https://account.adobe.com/plans", "profile": "subs-adobe"},
      {"name": "Headspace", "site": "headspace.com", "start": "https://www.headspace.com/subscriptions", "profile": "subs-headspace"},
  ]

  # Runs at once. Keep it below your plan's browser count (Free 2, Builder 5, Pro 25, Scale 100).
  MAX_AT_ONCE = 2

  WALKTHROUGH_SCHEMA = {
      "type": "object",
      "properties": {
          "active": {"type": "boolean", "description": "Whether this account has an active paid subscription"},
          "plan": {"type": "string"},
          "price": {"type": "string", "description": "Price per billing period, with currency"},
          "renewsOn": {"type": "string", "description": "Next renewal date, YYYY-MM-DD"},
          "path": {"type": "array", "items": {"type": "string"}, "description": "Each screen passed, in order"},
          "offersDeclined": {"type": "array", "items": {"type": "string"}},
          "finalButton": {"type": "string", "description": "Exact label of the button that ends the subscription"},
          "feeToCancel": {"type": "string", "description": 'Any fee for cancelling now, with currency, or "none"'},
          "blocker": {"type": "string", "description": "What stopped you before the final screen, if anything"},
      },
      "required": ["active", "path"],
  }

  CANCELLATION_SCHEMA = {
      "type": "object",
      "properties": {
          "cancelled": {"type": "boolean"},
          "accessEndsOn": {"type": "string", "description": "Last day of access, YYYY-MM-DD"},
          "confirmation": {"type": "string", "description": "Confirmation message or number shown"},
          "leftForYou": {"type": "string", "description": "What a person still has to do, if not cancelled"},
      },
      "required": ["cancelled"],
  }


  async def start(oc: Outcrawl, **request) -> str:
      """Submit a run and answer its id, waiting for a free browser slot instead of failing."""
      while True:
          try:
              return (await oc.agent(**request).submitted)["id"]
          except ConcurrencyLimitError:
              await asyncio.sleep(20)  # no Retry-After: a slot frees when one of your runs ends
          except QuotaExceededError as error:
              if error.ceiling != "requests_per_minute":
                  raise  # out of monthly credits: retrying cannot help
              await asyncio.sleep(error.retry_after_seconds)  # the error says how long to wait


  async def main() -> None:
      async with Outcrawl() as oc:
          profiles = await oc.profiles.list(label="subs-*")
          profile_ids = {profile["label"]: profile["id"] for profile in profiles}
          for service in SERVICES:
              if service["profile"] not in profile_ids:
                  raise SystemExit(f"No profile labelled {service['profile']}")
          slots = asyncio.Semaphore(MAX_AT_ONCE)

          # Pass 1: walk each cancellation flow up to its last screen, and stop there.
          async def walk(service: dict) -> dict:
              async with slots:
                  run_id = await start(
                      oc,
                      task=(
                          f"You are signed in to {service['name']}. Starting at {service['start']}, find how to cancel "
                          "the subscription on this account and work through the cancellation flow. Answer any survey "
                          'with "I no longer use it" and decline every retention offer, discount, downgrade and pause. '
                          "Stop on the final screen, the one whose button would end the subscription, and do not press "
                          "that button. If there is no active subscription, say so."
                      ),
                      caps={"budget": "150.00", "duration": "10m"},
                      profile=profile_ids[service["profile"]],
                      rules=[f"Only use {service['site']}."],
                      schema=WALKTHROUGH_SCHEMA,
                  )
                  run = await oc.agent.job(run_id).settled()
              return {"service": service, "walkthrough": run.get("data"), "credits": run["credits"]}

          walkthroughs = await asyncio.gather(*(walk(service) for service in SERVICES))

          for number, row in enumerate(walkthroughs, start=1):
              name, steps = row["service"]["name"], row["walkthrough"]
              if steps is None:
                  print(f"{number}. {name}: no answer, cancel this one yourself")
              elif not steps["active"]:
                  print(f"{number}. {name}: no active subscription")
              elif steps.get("finalButton"):
                  print(f"{number}. {name}: {steps.get('plan')} at {steps.get('price')}, renews {steps.get('renewsOn')}. "
                        f"Fee to cancel: {steps.get('feeToCancel', 'none')}. Ready to press \"{steps['finalButton']}\".")
              else:
                  print(f"{number}. {name}: stopped early: {steps.get('blocker', 'no reason given')}")

          # You decide. Nothing has been cancelled yet.
          answer = await asyncio.to_thread(input, 'Cancel which? Numbers separated by commas, "all", or Enter for none: ')
          ready = [row for row in walkthroughs if row["walkthrough"] and row["walkthrough"]["active"] and row["walkthrough"].get("finalButton")]
          if answer.strip() == "all":
              approved = ready
          else:
              picked = {int(part) - 1 for part in answer.split(",") if part.strip().isdigit()}
              approved = [row for index, row in enumerate(walkthroughs) if index in picked and row in ready]

          # Pass 2: only the ones you approved, following the path pass 1 found.
          async def cancel(row: dict) -> dict:
              service, steps = row["service"], row["walkthrough"]
              async with slots:
                  run_id = await start(
                      oc,
                      task=(
                          f"You are signed in to {service['name']}. I have approved cancelling this subscription. Last "
                          f"time you reached the final screen this way: {' > '.join(steps['path'])}. Decline every "
                          f'retention offer again, then press "{steps["finalButton"]}" on the final screen. The fee to '
                          f"cancel I approved is: {steps.get('feeToCancel', 'none')}. If the final screen shows a different "
                          "fee, do not press it. Report the confirmation. If anything stops you (a sign-in, a cancellation "
                          "that must be done by phone or chat, a page that changed), stop and say exactly what is left "
                          "for me to do."
                      ),
                      caps={"budget": "100.00", "duration": "10m"},
                      profile=profile_ids[service["profile"]],
                      rules=[f"Only use {service['site']}."],
                      schema=CANCELLATION_SCHEMA,
                  )
                  run = await oc.agent.job(run_id).settled()
              result = run.get("data") or {
                  "cancelled": False,
                  "leftForYou": f"Run ended {run.get('stopReason') or run['status']}; see {run.get('replayUrl') or 'the console'}",
              }
              return {"service": service["name"], **result}

          cancellations = await asyncio.gather(*(cancel(row) for row in approved))
          for row in cancellations:
              print(row)
          with open("cancellations.json", "w") as out:
              json.dump({"walkthroughs": walkthroughs, "cancellations": cancellations}, out, indent=2)


  asyncio.run(main())
  ```
</CodeGroup>

## What you get back

Pass 1, and your answer:

```text theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
1. The New York Times: All Access at $25.00 every 4 weeks, renews 2026-10-08. Fee to cancel: none. Ready to press "Complete cancellation".
2. Audible: Premium Plus at $14.95 a month, renews 2026-10-02. Fee to cancel: none. Ready to press "Cancel membership".
3. Adobe Creative Cloud: Photography (20GB) at US$11.99 a month, renews 2026-10-14. Fee to cancel: none. Ready to press "Confirm".
4. Headspace: no active subscription
Cancel which? Numbers separated by commas, "all", or Enter for none: 1,2,3
```

Pass 2, also written to `cancellations.json` with pass 1's walkthroughs:

```json theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
[
  {
    "service": "The New York Times",
    "cancelled": true,
    "accessEndsOn": "2026-10-07",
    "confirmation": "Your subscription has been canceled. You will have access until October 7, 2026."
  },
  {
    "service": "Audible",
    "cancelled": false,
    "leftForYou": "The final screen asked for a verification code sent to your email. Sign in on the subs-audible profile again, or give the run an inbox integration so it can read the code."
  },
  {
    "service": "Adobe Creative Cloud",
    "cancelled": true,
    "accessEndsOn": "2026-10-13",
    "confirmation": "Your plan has been cancelled. Order number 7710482213."
  }
]
```

## What it costs

An agent run is billed for the model's usage, browser time and proxy usage, per [pricing](/docs/billing/pricing). A typical run costs about 15 credits a minute all in, proxy usage included. That is an estimate: the real figure depends on the task, and a page-heavy site costs more.

* **Pass 1** takes about 2.5 minutes per service, so **about 38 credits** a service.
* **Pass 2** takes about 1.5 minutes, **about 23 credits** for each service you approve.

Four services with three approved: 4 × 38 + 3 × 23 = **about 221 credits**, once. Each run's `caps.budget` is 150 in pass 1 and 100 in pass 2.

## Make it yours

* Watch pass 2 as it happens: log the `watchUrl` from `job.submitted` and open it; press take control in the live viewer to step in at any point, then hand it back. While you hold the wheel, the run bills browser time (1 credit a minute) and proxy usage only, with no model usage.
* Let a run sign back in when a session has lapsed: give it your password as a [secret](/docs/account/secrets) and an inbox [integration](/docs/account/integrations) for the emailed code.
* Run pass 1 alone each month as an audit: every plan, price and renewal date you pay, with nothing cancelled.
* Post the pass 2 results to Slack with one HTTP call to an incoming webhook.

## Related

<CardGroup cols={2}>
  <Card title="Agents" icon="player-play" href="/docs/agents/overview">Submit a run, caps and typed results.</Card>
  <Card title="Profiles" icon="id" href="/docs/account/profiles">Stay signed in to each service.</Card>
  <Card title="Rules" icon="shield-check" href="/docs/account/rules">Which rules are enforced, and which are guidance.</Card>
  <Card title="Human in the loop" icon="hand-click" href="/docs/agents/human-in-the-loop">Take and release control of a live run.</Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.