# Pagefully for developers

> Everything Pagefully does in the browser, it does over HTTP, from a CLI and from an MCP server: read your app, plan a page for each search, make the screenshots, check them, and send them to App Store Connect. It is on every plan, Free included.

This is https://pagefully.com/developers as markdown. The API is at https://pagefully.com/api/v1. Its full description is https://pagefully.com/api/v1/openapi.json (OpenAPI 3.1). The MCP server for coding agents is described at https://pagefully.com/developers/mcp.

## Try it before you sign up

One command, no token and no account. Give it any app on the App Store and you get the pages Pagefully would plan for it, the first one designed, and a link to look at it.

```sh
npx pagefully preview https://apps.apple.com/us/app/your-app/id1234567890
```

The same as one HTTP call:

```sh
curl -X POST https://pagefully.com/api/v1/preview \
  -H "Content-Type: application/json" \
  -d '{"app_store_url": "https://apps.apple.com/us/app/your-app/id1234567890"}'
```

The first call for an app starts the work and answers `"state": "making"`. Ask again in a few seconds and it is `ready`. A preview is made once for an app and kept, so asking again costs nothing.

## Make a token

Sign up free at https://pagefully.com/signup, open https://pagefully.com/settings, and make a token under API tokens. You see it once. Send it with every call:

```
Authorization: Bearer pf_live_...
```

A token is one of three kinds:

- **Read** reads your apps, plans, pages, results and usage. It changes nothing.
- **Generate** also adds apps, makes plans and pages, and edits and approves them. It cannot publish. A good fit for CI.
- **Full** also sends approved pages to App Store Connect.

Revoke a token in Settings and it stops working at once. Say what is calling in an `X-Pagefully-Client` header: `cli`, `ci`, `mcp` or `other`. Never print a token, log it or commit it.

## The CLI

A thin client of the API below, with no dependencies. Node 20 or later.

```sh
pagefully login                      # paste a token from Settings
pagefully add <app store link>       # add your app and read it
pagefully plan <app>                 # one page for each search
pagefully generate <app>             # make the pages
pagefully page <page id>             # the words, pre-flight, and a link to look at it
pagefully page <page id> --approve   # your yes
pagefully publish <page id>          # shows what would be sent, then asks
```

`<app>` is the app's id, its name, or its App Store link or number. An intent is named by its number in the plan, its name or its id.

| Command | What it does |
| --- | --- |
| `pagefully preview <app store link or id>` | The pages Pagefully would plan for any app, and the first one designed. No token |
| `pagefully login` | Keep a token on this machine. logout forgets it |
| `pagefully apps` | Your apps |
| `pagefully add <app store link or id>` | Add an app from the App Store and read it |
| `pagefully plan <app>` | The plan, built the first time. --approve <intent> switches one on, --remove <intent> takes one out |
| `pagefully generate <app>` | Make a page for every intent that is on and has none |
| `pagefully pages <app>` | The app's pages |
| `pagefully page <page id>` | One page: each screenshot's headline, pre-flight, and its preview link. --approve approves it |
| `pagefully refresh <app>` | Make pages again after an update and print their preview links. --pages <ids> names them. It never publishes |
| `pagefully publish <page ids…>` | Show exactly what would be sent, ask, and send on yes |
| `pagefully retire <page id>` | Free the place of a page you sent with publish --local |
| `pagefully results <app>` | Each page against your default page |
| `pagefully usage` | Each app's plan, what is used and what is left |
| `pagefully job <job id>` | A job's state and steps. --wait follows it to the end |

Every command takes `--json` and then prints the API's answer and nothing else. Commands that start long work wait for it and show each step as it finishes. `--no-wait` returns at once with the job's id.

`pagefully login` asks for the token without showing it, checks it, and keeps it in `~/.config/pagefully/token`, readable by you alone. `PAGEFULLY_TOKEN` is used first when it is set. `PAGEFULLY_API_URL` changes the API's address.

`pagefully publish` prints exactly what would be made in App Store Connect and asks "Send these to App Store Connect? [y/N]". Only y or yes sends. Where there is no terminal to ask at, nothing is sent. `--yes` is refused unless `PAGEFULLY_ALLOW_PUBLISH=1` is set, so a pipeline cannot publish by accident.

In CI, give the pipeline a generate token. It can make pages for a person to look at and cannot send one:

```yaml
- run: npx pagefully generate "$APP_ID" --json
  env:
    PAGEFULLY_TOKEN: ${{ secrets.PAGEFULLY_TOKEN }}
```

| Exit code | Meaning |
| --- | --- |
| 0 | Done. |
| 1 | It failed or was refused. The message says why. |
| 2 | The app's plan does not allow it. The message and the plan screen's address are printed as the API gave them. |
| 3 | No token, or one Pagefully did not accept. |

## Add your app

By its App Store link or Apple's id for it. You do not need an App Store Connect key to plan and make pages. You need one to publish, and you connect it in the web app when you get there.

```sh
curl -X POST https://pagefully.com/api/v1/apps \
  -H "Authorization: Bearer $PAGEFULLY_TOKEN" \
  -H "X-Pagefully-Client: cli" \
  -H "Content-Type: application/json" \
  -d '{"app_store_id": "1234567890"}'

# {"app": {"id": "APP_ID", ...}, "did": "started", "job_id": "JOB_ID"}

curl https://pagefully.com/api/v1/jobs/JOB_ID -H "Authorization: Bearer $PAGEFULLY_TOKEN"

# {"job": {"state": "done", "progress": {"done": 3, "total": 3},
#          "result": {"brief_job_id": "..."}, "error": null, ...}}
```

Anything that takes a while answers with a `job_id`. Poll `GET /jobs/{id}` until its state is `done` or `failed`. Adding an app reads its listing and then writes its brief, so there is nothing else to start.

## Plan the pages

The brief is what Pagefully understood about your app. The plan is one page for each thing people search for. Read both, change what is wrong, and switch off any page you do not want.

```sh
# Read the brief, then build the plan from it
curl https://pagefully.com/api/v1/apps/APP_ID/brief -H "Authorization: Bearer $PAGEFULLY_TOKEN"

curl -X POST https://pagefully.com/api/v1/apps/APP_ID/plan -H "Authorization: Bearer $PAGEFULLY_TOKEN"

# The intents, each with its keywords and what its page says
curl https://pagefully.com/api/v1/apps/APP_ID/plan -H "Authorization: Bearer $PAGEFULLY_TOKEN"

# Change it: switch one off, add one, put them in order
curl -X PATCH https://pagefully.com/api/v1/apps/APP_ID/plan \
  -H "Authorization: Bearer $PAGEFULLY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"update": [{"id": "INTENT_ID", "on": false}],
       "add": [{"name": "Study sessions", "kind": "problem",
                "message": "Timed sessions that keep you off your phone."}]}'
```

## Make and check the pages

Each page is written, drawn on your own screenshots and read against App Review's rules. Every page comes back with a `preview_url`, because a page is something a person looks at before saying yes.

```sh
# One page for every intent that is on
curl -X POST https://pagefully.com/api/v1/apps/APP_ID/pages -H "Authorization: Bearer $PAGEFULLY_TOKEN"

# {"started": 2, "jobs": [{"job_id": "...", "page_id": "PAGE_ID"}, ...]}

# The page: headlines, screenshots, pre-flight, and a link to look at it
curl https://pagefully.com/api/v1/pages/PAGE_ID -H "Authorization: Bearer $PAGEFULLY_TOKEN"

# Change a headline, then approve the page
curl -X PATCH https://pagefully.com/api/v1/pages/PAGE_ID \
  -H "Authorization: Bearer $PAGEFULLY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"position": 1, "headline": "Start in one tap"}'

curl -X POST https://pagefully.com/api/v1/pages/PAGE_ID/approve -H "Authorization: Bearer $PAGEFULLY_TOKEN"
```

Editing a page withdraws its approval, so what is approved is always what was last seen. A page cannot be approved while its pre-flight has a failure.

## Use your own screens

Pages are drawn from your listing's screenshots. You can add screens of your own: captures from the simulator, as PNG or JPEG, 8 MB a file at most, at a size Apple takes for an iPhone or iPad screenshot (an iPhone 17 Pro Max or 16 Pro Max simulator gives 1320 x 2868). An app has 10 places for iPhone screens and 10 for iPad, your listing's own included. An uploaded screen takes the next free place, and a page shows it when you ask for it by that position, or chooses it by itself when the page is made.

```sh
# Capture a screen from the simulator, then upload it
xcrun simctl io booted screenshot --type=jpeg home.jpg

curl -X POST https://pagefully.com/api/v1/apps/APP_ID/screens \
  -H "Authorization: Bearer $PAGEFULLY_TOKEN" \
  -F "file=@home.jpg"

# {"added": [{"id": "SCREEN_ID", "source": "uploaded", "device": "iphone", "position": 6, ...}],
#  "screens": [...]}

# Put it on a page: the screenshot in place 1 now shows the screen in position 6
curl -X PATCH https://pagefully.com/api/v1/pages/PAGE_ID \
  -H "Authorization: Bearer $PAGEFULLY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"position": 1, "screen": 6}'
```

`GET /apps/{id}/pages` gives each page `screens_changed`: true when a screen the page shows is a different picture now than when the page was drawn, false when none is, and null when that cannot be told (the page is not drawn yet, or the listing was not read before it was drawn). A new screen the page does not show is not a change to it. Nothing you upload is sent to Apple until a page that shows it is published.

## Make it again

When a page is not right, change it by hand with `PATCH`, or have it made again. Making it again counts against the month's rewrites for the app's plan. Changing words by hand does not.

```sh
# The whole page, written and drawn again. Answers 202 with a job_id
curl -X POST https://pagefully.com/api/v1/pages/PAGE_ID/regenerate -H "Authorization: Bearer $PAGEFULLY_TOKEN"

# One screenshot: new words, the next style, or the next screen
curl -X POST https://pagefully.com/api/v1/pages/PAGE_ID/regenerate \
  -H "Authorization: Bearer $PAGEFULLY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"what": "headline", "position": 2}'
```

The whole page answers `202` with a `job_id` to poll. One screenshot is done before the answer, which is `200` with the page and `"job_id": null`.

## Publish, in two steps

Sending a page to App Store Connect is slow to take back, so no single call does it. The first call says exactly what would be sent and gives a confirmation token. The second call spends it. The token lasts 10 minutes, works once, and only for the API token that asked for it.

```sh
# Step one: what would be sent. Nothing is sent yet.
curl -X POST https://pagefully.com/api/v1/pages/publish \
  -H "Authorization: Bearer $PAGEFULLY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"page_ids": ["PAGE_ID"]}'

# {"confirmation_token": "pfc_...", "expires_in_minutes": 10,
#  "will_send": [{"name": "...", "screenshots": [...], "preview_url": "..."}],
#  "will_not_send": [], "what_happens": [...]}

# Step two, once a person has read the summary and said yes
curl -X POST https://pagefully.com/api/v1/pages/publish/confirm \
  -H "Authorization: Bearer $PAGEFULLY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"confirmation_token": "pfc_..."}'
```

If you are an agent, show the person the summary and each preview link between the two calls, and wait for their yes. A page is made in App Store Connect and is not submitted for review: that click stays the developer's, in App Store Connect. Pagefully never touches the default page, the keyword field, the app binary, pricing or in-app purchases.

## Errors

Every error has the same shape. The message is a plain sentence that can be shown as it is.

```json
{
  "error": {
    "code": "upgrade_required",
    "message": "This app is on Free and its one page is made. The Pack ($49 once) allows 10 pages. Pro ($348 a year or $59 a month) allows 70, which is Apple's limit for an app.",
    "upgrade": {
      "url": "https://pagefully.com/apps/APP_ID/billing",
      "plan": "pack",
      "facts": { "plan": "free", "pages_made": 1, "pages_allowed": 1 }
    }
  }
}
```

| Status | Code | Meaning |
| --- | --- | --- |
| 400 | invalid_request | The body could not be read, or a field is missing. |
| 401 | unauthorized | No token, or one that is not valid or was revoked. |
| 402 | upgrade_required | The app's plan does not allow it. The message has the facts and upgrade.url is the plan screen. |
| 403 | forbidden | The token's scope is too small for this call. |
| 404 | not_found | Not on your workspace. |
| 409 | key_required, plan_exists, own_words, not_ready, confirmation_used, changed | Something has to happen first. The message says what. |
| 410 | confirmation_expired | The confirmation is more than 10 minutes old. Ask for a new one. |
| 413 | too_large | An uploaded screen, or the call that carries it, is over the limit. |
| 422 | refused | Understood, and the product said no. The message is the one the web app shows. |
| 429 | rate_limited | Too many calls. retry_after is seconds to wait. |

Plans belong to an app: Free makes 1 page, the Pack ($49 once) makes 10, and Pro goes up to Apple's 70. When a plan stops a call the answer is `upgrade_required` with the numbers and a link to the app's plan screen. If you are an agent, repeat that message and that link to the person exactly as given. `GET /account/usage` says what is left before you ask. There is no charge for a call.

## Webhooks

Instead of polling, give Pagefully an https address and it tells you when something happens. Add one in Settings, or with a full token. A workspace holds 5.

```sh
curl -X POST https://pagefully.com/api/v1/webhooks \
  -H "Authorization: Bearer $PAGEFULLY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/pagefully", "events": ["page.ready", "page.sent", "job.failed"]}'

# {"webhook": {"id": "WEBHOOK_ID", ...}, "secret": "whsec_..."}   The secret is shown this once.

# What arrives, as a POST:
# Pagefully-Signature: t=1791547200,v1=5f2b...
# {"id": "page.ready:JOB_ID", "type": "page.ready", "created_at": "2026-10-09T12:00:00.000Z",
#  "data": {"app_id": "...", "page_id": "...", "name": "...", "state": "ready", "preview_url": "..."}}
```

| Event | When |
| --- | --- |
| `page.ready` | A page has been written, drawn and checked, and is ready to look at. |
| `page.sent` | A page was made in App Store Connect. It is not submitted for review: that click is the developer's, in App Store Connect. |
| `job.failed` | A job failed. The message says what happened and what can be done. |
| `results.weekly` | Once a week, for an app on the Pack or Pro: each page's numbers against the default page. |

There is no page.live and no page.rejected. Pagefully makes a page in App Store Connect and does not submit it for review, and it does not read Apple's verdict back, so it cannot say that a page went live or was rejected.

Every delivery is signed. `Pagefully-Signature` carries a timestamp and an HMAC-SHA256, made with your secret, of the timestamp, a full stop and the body exactly as sent. Check it before you trust a delivery, and refuse one more than 300 seconds old:

```js
import { createHmac, timingSafeEqual } from 'node:crypto'

// body is the request body exactly as it arrived, before any JSON parsing
export function fromPagefully(secret, header, body) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')))
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false
  const want = createHmac('sha256', secret).update(parts.t + '.' + body).digest('hex')
  return want.length === parts.v1.length && timingSafeEqual(Buffer.from(want), Buffer.from(parts.v1))
}
```

Answer with any 2xx within ten seconds. Anything else is tried again, 6 attempts in all, after 1 minute, 5 minutes, 30 minutes, 2 hours, 12 hours. A delivery can arrive more than once, so dedupe by its `id`. A redirect is not followed, and an address on a private or local network is refused.

## Limits

- 120 requests a minute for a token.
- 20 calls an hour that start generation: adding an app, a brief, a plan, pages.
- Past either the answer is `429` with `retry_after` in seconds.
- The preview without a token has the same limits as the preview on the site.

## Every call

All under `https://pagefully.com/api/v1`.

| Path | Methods | What it is |
| --- | --- | --- |
| `/preview` | POST | Preview any app's pages. No token |
| `/apps` | GET, POST | Your apps, and adding one from the App Store |
| `/apps/{id}` | GET | One app, and where it stands |
| `/apps/{id}/screens` | GET, POST | The app's screens, and uploading ones you captured |
| `/apps/{id}/screens/{screenId}` | DELETE | Remove an uploaded screen |
| `/apps/{id}/brief` | GET, POST, PATCH | What the app does and who it is for |
| `/apps/{id}/plan` | GET, POST, PATCH | One page for each search intent |
| `/apps/{id}/pages` | GET, POST | The app's pages, and making them |
| `/pages/{id}` | GET, PATCH | One page, and changing a screenshot's words, screen or style |
| `/pages/{id}/regenerate` | POST | Make a page, or one screenshot, again |
| `/pages/{id}/results` | GET | One page against your default page, by day |
| `/pages/{id}/approve` | POST | Approve a page |
| `/pages/publish` | POST | Step one of publishing |
| `/pages/publish/confirm` | POST | Step two of publishing |
| `/pages/{id}/bundle` | POST | Local mode: a page's files and the App Store Connect calls, to send with your own key |
| `/pages/{id}/sent` | POST | Local mode: say which page your machine made |
| `/pages/{id}/retire` | POST | Local mode: free a page's place on your plan |
| `/apps/{id}/results` | GET | Each page against your default page |
| `/apps/{id}/suggestions` | GET | Pages your app does not have yet |
| `/screenshots/score` | POST | Measure a set of screenshots against its category in the App Screenshot Index |
| `/jobs/{id}` | GET | A job's state and progress |
| `/account/usage` | GET | Each app's plan, and what is left |
| `/webhooks` | GET, POST | Your webhooks, and adding one |
| `/webhooks/{id}` | DELETE | Remove a webhook |
| `/webhooks/{id}/test` | POST | Send a test delivery |

Not here yet: signing in with OAuth. Questions: hello@pagefully.com.
