# Move domains to a customer's own Cloudflare account

Porkbun can hand a domain's DNS over to the customer's **own** Cloudflare account: we create the zone there, copy across the DNS records we currently hold, and repoint the registry nameservers at Cloudflare. This guide covers driving that end to end, including the one step that needs a human.

## The shape of it

1. **Connect the Cloudflare account** — a browser action, once per Porkbun account
2. **Check what's eligible** — API
3. **Queue the domains** — API
4. **Poll until they land** — API

Only step 1 needs a person.

## 1. Connect the Cloudflare account (human, one time)

Start by asking:

```bash
curl -s https://api.porkbun.com/api/json/v3/cloudflare/getConnection \
  -H "X-API-Key: pk1_..." -H "X-Secret-API-Key: sk1_..."
```

If `connected` is `false`, the response tells you exactly what to do:

```json
{
  "connected": false,
  "connectUrl": "https://porkbun.com/account/connectCloudflare",
  "nextStep": "Ask the account owner to open ... and authorize Cloudflare."
}
```

**Why this can't be automated:** Cloudflare's consent screen is a deliberate human decision — it grants us the ability to create zones in their account. The authorization is also bound to the Porkbun browser session that started it, so a URL handed to a script won't complete. An agent should surface `connectUrl` to its human and wait.

Then poll the same endpoint (every few seconds is plenty) until:

```json
{ "connected": true, "connection": { "cfAccountName": "My Cloudflare Account" } }
```

This is a one-time setup per Porkbun account. Once connected, everything below is fully programmatic.

## 2. See what's eligible

```bash
curl -s https://api.porkbun.com/api/json/v3/cloudflare/inventory \
  -H "X-API-Key: pk1_..." -H "X-Secret-API-Key: sk1_..."
```

Every domain comes back with a `state` and a plain-English `reason`:

| state | meaning |
|-------|---------|
| `eligible` | ready to move |
| `warn` | will move, but read the reason first (e.g. it uses Porkbun hosting/forwarding, which relies on our DNS) |
| `blocked` | can't move — e.g. DNSSEC is live, or it's on custom nameservers so we don't hold its DNS |
| `connected` | already on Cloudflare |
| `inprogress` | already queued |

This works **before** you're connected, so an agent can plan while the owner is still authorizing.

## 2b. Preview exactly what will be copied

Before committing, ask what the move will actually do to the zone:

```bash
curl -s https://api.porkbun.com/api/json/v3/cloudflare/preview/example.com \
  -H "X-API-Key: pk1_..." -H "X-Secret-API-Key: sk1_..."
```

You get the exact record list we'd create in Cloudflare and anything we'd drop. Nothing is queued. Every record is created **DNS-only (grey cloud)** — see step 5.

## 3. Queue the move

Rehearse first if you like — `dryRun` returns the same per-domain verdicts without queueing:

```bash
curl -X POST https://api.porkbun.com/api/json/v3/cloudflare/connect \
  -H 'Content-Type: application/json' \
  -d '{"apikey":"pk1_...","secretapikey":"sk1_...",
       "domains":["example.com","example.net"],
       "dryRun":true}'
```

Then for real, without `dryRun`:

```json
{
  "status": "SUCCESS",
  "queued":        { "example.com": "" },
  "skipped":       { "example.net": "DNSSEC is enabled for this domain." },
  "alreadyQueued": {},
  "summary": { "queued": 1, "skipped": 1, "alreadyQueued": 0 }
}
```

**Read this per domain.** A non-empty `skipped` is not a failed call — it's the normal way we tell you *this one can't move, and here's why*. Report the reasons to your user rather than treating the whole request as an error.

Limits: 500 domains per call, 2000 per account per hour. Re-submitting a domain is safe.

## 4. Poll until they land

The move happens on a background job over the next few minutes, so a successful queue call means **queued**, never **connected**.

```bash
curl -s https://api.porkbun.com/api/json/v3/cloudflare/getQueue \
  -H "X-API-Key: pk1_..." -H "X-Secret-API-Key: sk1_..."
# or one domain:
curl -s https://api.porkbun.com/api/json/v3/cloudflare/get/example.com \
  -H "X-API-Key: pk1_..." -H "X-Secret-API-Key: sk1_..."
```

The row moves through these states:

| status | meaning | keep polling? |
|--------|---------|---------------|
| `queued` | accepted, waiting for the worker | yes |
| `working` | being acted on right now | yes |
| `setup` | the zone exists in the customer's Cloudflare account but Cloudflare hasn't provisioned it yet (`initializing`). **Nameservers have NOT been touched** — the domain still resolves from Porkbun. Cloudflare can sit here for hours on an account with a backlog of unactivated zones | yes |
| `activating` | **nameservers are already repointed**; Cloudflare is confirming the zone. Legitimately slow — registry and resolver propagation, up to 72h (a real move has taken 59h) | yes |
| `connected` / `done` | finished | no |
| `skipped` | not moved; `message` says why | no |
| `failed` / `error` | didn't complete; `message` says why. Use `retry` | no |

Don't treat `activating` as failure or as success: the DNS change has happened, but Cloudflare hasn't confirmed the zone yet.

**A large batch is paced deliberately.** Cloudflare provisions each new zone from a queue nobody outside Cloudflare can see or hurry, and creating zones faster than it drains that queue leaves an account holding domains Cloudflare will neither set up nor add to. So we stop creating zones while the account has several still waiting, and the remaining rows sit in `queued` with a message saying so. Nothing is wrong and nothing is lost — the domains keep resolving from Porkbun until their turn.

`setup` and `activating` differ in the one way that matters: in `setup` nothing about the domain has changed for anyone resolving it, in `activating` the delegation has already moved.

Those two waits are also bounded differently, on purpose. A domain that is still resolving while it waits gets the full 72 hours. A domain that has **stopped** resolving — the delegation is on Cloudflare, Cloudflare is not serving it yet, and Porkbun's zone for it has been retired — is not waiting, it is down: its nameservers go back to Porkbun within minutes and the row comes back as `undone` with the reason in `message`. A `failed` row that was waiting on Cloudflare is also re-checked for up to 30 days, so a zone Cloudflare activates late still gets reported as connected instead of staying failed forever.

**A queued domain can still end up skipped.** Ownership, nameserver state and DNSSEC are re-checked immediately before each domain is acted on, because they can change between queueing and running. That's by design, not a bug — check the final `status` and `message`, don't assume queueing was a promise.

## After the move: Porkbun DNS is no longer authoritative

This is the part that surprises people. Once the domain is on Cloudflare, **Cloudflare's nameservers answer for it, not ours.**

Porkbun's `/dns/*` endpoints still work and still return `SUCCESS` — we keep writing to the Porkbun zone deliberately, so it stays in step if you ever roll the move back. But those writes **do not change what resolves.** Use the Cloudflare record endpoints below instead. Every `/dns/*` response for a connected domain carries a warning saying exactly that:

```json
{
  "status": "SUCCESS",
  "id": 580586861,
  "warnings": ["example.com is connected to Cloudflare, so Cloudflare's nameservers — not Porkbun's — answer DNS queries for it. ..."]
}
```

### Managing DNS after the move

You don't have to send people to a second dashboard — the Cloudflare endpoints write to the zone that answers:

```bash
# list (ids come from here)
curl -s https://api.porkbun.com/api/json/v3/cloudflare/getRecords/example.com \
  -H "X-API-Key: pk1_..." -H "X-Secret-API-Key: sk1_..."

# create
curl -X POST https://api.porkbun.com/api/json/v3/cloudflare/createRecord/example.com \
  -H 'Content-Type: application/json' \
  -d '{"apikey":"pk1_...","secretapikey":"sk1_...",
       "type":"A","name":"www","content":"192.0.2.1","ttl":1,"proxied":true}'

# edit (partial — omitted fields keep their value)
curl -X POST https://api.porkbun.com/api/json/v3/cloudflare/editRecord/example.com/<recordId> \
  -H 'Content-Type: application/json' \
  -d '{"apikey":"pk1_...","secretapikey":"sk1_...","content":"192.0.2.2"}'

# delete
curl -X POST https://api.porkbun.com/api/json/v3/cloudflare/deleteRecord/example.com/<recordId> \
  -H 'Content-Type: application/json' -d '{"apikey":"pk1_...","secretapikey":"sk1_..."}'
```

Notes: `name` takes `@` for the apex or a bare label like `www`. `ttl: 1` means Cloudflare-automatic. MX needs `priority`. `proxied` applies to A/AAAA/CNAME only. Record types Cloudflare models as structured objects (SRV, CAA, …) take a `data` object instead of `content`. Everything supports `dryRun`.

To read what is actually being served, ask Cloudflare:

```bash
curl -s https://api.porkbun.com/api/json/v3/cloudflare/getRecords/example.com \
  -H "X-API-Key: pk1_..." -H "X-Secret-API-Key: sk1_..."
```

And to check the zone itself is still healthy — including whether someone has repointed the nameservers away since the move:

```bash
curl -s https://api.porkbun.com/api/json/v3/cloudflare/getZone/example.com \
  -H "X-API-Key: pk1_..." -H "X-Secret-API-Key: sk1_..."
# -> zone.status, zone.nameServers, liveNameservers, nameserversDrifted
```

## 5. Turning the proxy on (optional)

Records are imported **grey cloud** on purpose: changing how traffic is served at the same moment as changing who serves DNS gives you two things to debug at once. Confirm the site still works, then decide:

```bash
curl -X POST https://api.porkbun.com/api/json/v3/cloudflare/setProxy/example.com \
  -H 'Content-Type: application/json' \
  -d '{"apikey":"pk1_...","secretapikey":"sk1_...","enabled":true}'
```

Only A, AAAA and CNAME records can be proxied; anything else is reported under `skipped`. If the zone's MX points at a hostname you're proxying, the response warns you — proxying hides the origin IP and mail to that host would break. Reverse it any time with `"enabled": false`.

### Check the SSL mode before you proxy

Proxying makes Cloudflare terminate TLS, so the zone's **SSL mode** decides how it then reaches your origin. `Flexible` means plain HTTP to the origin while visitors still see a padlock — worth checking:

```bash
curl -s https://api.porkbun.com/api/json/v3/cloudflare/getZoneSettings/example.com \
  -H "X-API-Key: pk1_..." -H "X-Secret-API-Key: sk1_..."
# warns if ssl is "off" or "flexible"

curl -X POST https://api.porkbun.com/api/json/v3/cloudflare/setZoneSettings/example.com \
  -H 'Content-Type: application/json' \
  -d '{"apikey":"pk1_...","secretapikey":"sk1_...","ssl":"full"}'
```

Editable: `ssl`, `always_use_https`, `automatic_https_rewrites`, `min_tls_version`, `development_mode`, `cache_level`. WAF and firewall controls are deliberately not exposed here — manage those in Cloudflare.

**If you get `CLOUDFLARE_REAUTHORIZE_REQUIRED`:** the account's Cloudflare connection was made before we started asking for the zone-settings permission. The response includes a `connectUrl` — the owner reconnects with the same one-click flow, and domains already moved carry on untouched.

## Skip the polling: use webhooks

Rather than polling the queue, subscribe once and be told:

- `cloudflare.connect.completed` — the move finished
- `cloudflare.connect.failed` — it didn't; `status` distinguishes `skipped` from `failed`, and `message` says why

Both payloads carry `domain`, `status`, `message`, `zoneId`, and the previous/assigned nameservers. Register an endpoint with the `/webhook/*` endpoints, and verify the signature as described in the webhooks guide.

## Undoing and retrying

```bash
# undo a completed move: nameservers go back to Porkbun
curl -X POST https://api.porkbun.com/api/json/v3/cloudflare/rollback/example.com \
  -H 'Content-Type: application/json' -d '{"apikey":"pk1_...","secretapikey":"sk1_..."}'

# re-queue one that failed
curl -X POST https://api.porkbun.com/api/json/v3/cloudflare/retry/example.com \
  -H 'Content-Type: application/json' -d '{"apikey":"pk1_...","secretapikey":"sk1_..."}'
```

Rollback restores the nameservers we replaced (we keep them for exactly this reason) but **leaves the Cloudflare zone in place** — deleting a zone inside someone's own Cloudflare account is their call, not ours.

## Notes

- Queue rows are never deleted, so `/cloudflare/getQueue` is also the audit trail.
- A domain with **no DNS records** is fine to move (people often add records at Cloudflare afterwards). A domain with **no zone at all** is refused. Those are different things.
- `POST /cloudflare/disconnect` forgets the grant. Domains already moved stay on Cloudflare and keep resolving.
- Not available with sandbox (`pk1_sb_`) keys — a real Cloudflare grant can't be simulated.


---

## More

- Guides (how-tos): https://porkbun.com/llms/guides
- Topic index: https://porkbun.com/llms
- Full reference (one file): https://porkbun.com/llms-full.txt
- OpenAPI spec (full schemas): https://porkbun.com/api/json/v3/spec
- Short overview: https://porkbun.com/llms.txt
- Official MCP server: https://porkbun.com/mcp (`npx -y @porkbunllc/mcp-server`)
- Create API keys: https://porkbun.com/account/api
