# Migrating domains from GoDaddy to Porkbun

> **Beta.** Every step here has been run end to end against live domains, and
> the numbers and status names come from Porkbun's own transfer records rather
> than from documentation. Two things are still genuinely unsettled, and both
> are GoDaddy's behaviour rather than ours: how long they take to release a
> domain once a transfer is submitted, and whether removing WHOIS privacy ever
> trips ICANN's 60-day change-of-registrant lock (it did not in testing, but
> the sample is small — step 7 tells you how to check before spending).
>
> If what the API returns disagrees with this guide, believe the API, and tell
> the user the guide was wrong.
>
> Agent-readable guide. No install, no account, no repo — a user points any
> web-capable AI at this URL and it can run the whole migration.

You are helping someone move domains they own at GoDaddy to Porkbun. It's their
portfolio and their money. Show your work, and never spend anything without
showing an itemized total first.

There are two tracks. Pick based on what the user has.

- **Track A — API.** They can create a GoDaddy Personal Access Token and you can
  make HTTP requests. Faster, safer, scales to hundreds of domains. Two steps
  still need the user: removing WHOIS privacy, and approving the transfer if
  they don't want to wait out GoDaddy's five-day release.
- **Track B — browser.** No token, no terminal, you cannot reach the APIs, or
  the domain is `.uk` or premium (neither is transferable via Porkbun's API).
  Two logged-in tabs.

### Check you can reach the APIs before you plan anything

Do this first. Many chat clients can fetch a web page — including this one —
but cannot make arbitrary HTTPS requests to other hosts, so "I read the guide"
does not mean "I can run it". Find out in one call rather than after ten
minutes of planning:

```
GET https://api.porkbun.com/api/json/v3/ping
```

No credentials needed; it returns your public IP. `/pricing/get` is also
unauthenticated if you want a second probe. Then, once the user has a token,
the first GoDaddy inventory call tells you the same about `api.godaddy.com` —
a `401` means you got there, a connection or policy error means you did not.

If a host is unreachable, say so immediately and name which one. Then:

- **`api.godaddy.com` blocked** — Track A is out for the GoDaddy half. Use
  Track B to collect auth codes through the user's browser.
- **`api.porkbun.com` blocked** — either use the bulk form at
  https://porkbun.com/transfer (Track B), or, if the user runs Claude or
  another MCP client, install the official Porkbun MCP server
  (https://porkbun.com/mcp). An MCP server is a local process making its own
  outbound calls, so it is not subject to the chat client's fetch restrictions,
  and it exposes transfers, DNS import and the rest as native tools.
- **Both blocked** — Track B end to end, which is designed for exactly this:
  you orchestrate, the user clicks and pastes.

Mixing tracks is fine and often best: collect codes in the browser, then submit
them in bulk through the API or MCP.

---

## Before anything: the four rules

1. **The user's GoDaddy token never leaves the machine it is used on.** Send
   it to `api.godaddy.com` and nowhere else. **Never put it in a shell
   argument** — argv is world-readable via `ps`, so any other user on the box
   can read it out of the process list. Never paste it into your reply, a
   summary, or a commit message, and never repeat it back to the user.
2. **Auth codes are bearer credentials for the domain.** Anyone holding one can
   move that domain. Never put one in a summary, and
   **never interpolate one into a shell command** — real GoDaddy codes contain
   `$`, `!`, backticks, `{`, `[`, which makes that both a corruption bug and a
   command-injection vector. Put codes in a JSON body, nowhere else.
3. **Confirm money before spending it.** Show every domain and price, then the
   total, then ask. Never "confirm once for 200 domains" without showing 200.
4. **Do the work yourself.** The only things the user should have to do are the
   ones that are genuinely impossible for you: creating the GoDaddy token,
   removing WHOIS privacy, and approving the transfer. Everything else —
   inventory, filtering, DNS capture, pricing, submission, tracking — is yours.
   If you find yourself writing "run this command and tell me the output", stop
   and re-read *Holding credentials* below; that is almost always the fixable
   problem, not a real limit.

### Holding credentials

Most agent harnesses give each shell command a **fresh process**, so an
`export` in one call is gone by the next. That is not a reason to hand the work
back to the user. Put the credential in a file only you can read and point
`curl` at it:

```
umask 077
printf 'header = "Authorization: sso-key %s"\n' "$KEY" > ~/.gd_auth   # 0600
curl -s -K ~/.gd_auth "https://api.godaddy.com/v1/domains?limit=1000"
```

`-K` reads the header from the file, so the token never appears in argv and
never lands in shell history. Delete the file when the migration finishes.

This is a deliberate trade: a `0600` file readable only by the account you are
already running as is a smaller exposure than a token echoed into a chat
transcript, a scrollback buffer, or a process list. What actually matters is
argv, the transcript, and never sending it anywhere but the registrar.

The same applies to auth codes if a batch run needs to persist them: same
permissions, same cleanup, and never in a summary or a shell argument.

**The Porkbun side needs no pasting at all.** Mint the key yourself with
`/apikey/request` (see *Getting Porkbun credentials*) — one browser click from
the user, or zero for a sandbox key. Only the GoDaddy token has to come from
them, because GoDaddy has no equivalent flow.

---

## Getting Porkbun credentials

You need a Porkbun API key and secret. There are two ways, and they differ in
one thing that matters: whether the key is restricted.

### Mint one yourself (no copy-paste, works for brand-new users)

`POST /apikey/request` takes **no credentials**. It returns a URL the account
holder approves in their browser, and with PKCE you receive both keys directly
— the user never has to read a secret off a screen and paste it to you.

Generate the verifier and challenge **inside your own runtime**, not in a shell
command (rule 1: secrets never go in argv):

```
codeVerifier  = base64url(32 random bytes)        # 43 chars, keep in memory only
codeChallenge = base64url(sha256(codeVerifier))   # 43 chars, safe to send
```

```
POST https://api.porkbun.com/api/json/v3/apikey/request
{"name":"GoDaddy migration","codeChallenge":"<challenge>","codeChallengeMethod":"S256"}
```

Returns `authUrl` and `requestToken`. Give the user the `authUrl`, and say
plainly what they are approving: full API access to their Porkbun account. The
URL is good for **30 minutes**, which is deliberately long enough for someone
with no Porkbun account to create one and verify their email first.

Then poll:

```
POST https://api.porkbun.com/api/json/v3/apikey/retrieve
{"requestToken":"<token>","codeVerifier":"<verifier>"}
```

`status: PENDING` until they approve. On approval you get `apikey` and
`secretapikey` **once**. Claim it within **10 minutes** of approval or the
request expires (`REQUEST_EXPIRED`) and you start over. Poll politely —
`/apikey/retrieve` allows 120 requests per IP per hour, `/apikey/request` only
20, so do not burn requests re-initiating.

Treat the returned `secretapikey` exactly like the GoDaddy token: never write
it to a file, a summary or a shell argument, and do not repeat it back to the
user.

### Or have the user create one by hand

At https://porkbun.com/account/api they create a key and paste it to you. One
extra manual step, and it is the only way to get a **restricted** key — see the
next section.

### Rehearsing in the sandbox

You can get a sandbox key **without asking the user for anything**:

```
POST https://api.porkbun.com/api/json/v3/apikey/request
{"name":"GoDaddy migration rehearsal","sandbox":true}
```

No credentials, no approval step. It returns a `pk1_sb_` / `sk1_sb_` pair
immediately for a throwaway account seeded with $1,000 of fake credit, and
every response from it carries `"sandbox": true`. (The user *can* create one at
https://porkbun.com/account/api instead, but there is no reason to send them
there — that is a manual step you can skip entirely.) `/apikey/request` allows
20 calls per IP per hour, so mint one and reuse it rather than one per domain.

**Know what a rehearsal does and does not prove.** The sandbox simulates the
Porkbun side only. It exercises pricing, the dry run, cost matching,
idempotency, transfer submission and DNS import. It tells you nothing about
GoDaddy, which is where the failures in this guide actually come from — the
privacy refusal, the unlock lag, the release timing.

**During a rehearsal, read from GoDaddy but change nothing there.** Inventory,
auth codes and DNS reads are safe and are what you need. Do **not** unlock
domains, and do not remove privacy, as part of a practice run: those are real,
visible changes to real domains made in service of a fake transfer, and an
unlock you forget to revert leaves the domain open. Unlocking belongs in step
8, on the live pass, immediately before the real submission.

### Which to use

| | Minted via `/apikey/request` | Created at `/account/api` |
|---|---|---|
| User effort | One click to approve | Create, then copy two values |
| Works with no Porkbun account yet | Yes | They must sign up first |
| Secret exposure | Goes straight to you, never displayed | Shown on screen, pasted into the conversation |
| Restricted to specific domains | **No — full account access** | Yes, if they set an allowlist |

Say which one you are using and what it means. If the user is moving a handful
of domains out of a large portfolio and is at all uneasy, the hand-created,
domain-restricted key is the better trade despite the extra step. **You cannot
set that restriction yourself** — a limit you choose for yourself is not a
limit, so it only exists if the user sets it in the dashboard.

---

## Let Porkbun's API enforce what it can

You are running without local guardrails, so use the server-side ones. Set
these up **first** — they are the difference between a safe run and a hopeful
one.

| Control | How | What it prevents |
|---|---|---|
| **Per-key domain scoping** | The **user** restricts the key at https://porkbun.com/account/api. Type the names in; the list is free text, so domains not yet in the account work fine | A confused agent literally cannot touch a domain the user wanted left alone. Out-of-policy calls return 403 `DOMAIN_NOT_ALLOWED`. **Use this instead of a mental list.** Only available on a hand-created key, not one you minted |
| **Monthly spend limit** | Account API spend settings | Caps total damage regardless of what goes wrong |
| **`dryRun: true`** | On every `/domain/transfer` call first | Full validation and exact cost with no charge |
| **`Idempotency-Key`** | Header on every POST | A retried call replays its response instead of charging twice |
| **Sandbox** | **Mint one yourself** in a single call: `POST /apikey/request` `{"sandbox": true}`. No credentials, no approval, no user involvement | Rehearse the Porkbun half against fake money before spending real money. Do not make the user go and create this — see *Rehearsing in the sandbox* |

Ask the user to scope the key to the domains they're moving before you start.
If they name domains to avoid, that is not a note to remember — it is a key
scope for **them** to configure. A key you minted yourself has full account
access; if that is the one you are holding, say so rather than implying a
restriction that is not there.

---

## Track A — API

Two steps in here are **not** automatable, and both are GoDaddy's doing rather
than ours. Say so to the user at the start rather than surprising them halfway:

- **Removing WHOIS privacy** (step 7). No API exists for it; a domain with
  privacy on has its transfer denied.
- **Approving the transfer**, if they don't want to wait GoDaddy's five days
  (step 9). No API exists for that either — though a browser tab they're already
  signed into will do it.

Everything else — inventory, eligibility, DNS capture, pricing, submission and
tracking — runs unattended.

### 1. Get a GoDaddy token

https://developer.godaddy.com/personal-access-token → **+ Generate Token**.

Scopes — exactly two:
- `domains.domain:read` (inventory, auth codes, DNS zone reads)
- `domains.domain:update` (unlock)

Never request `domains.contact:update` (a contact change starts a fresh 60-day
transfer lock — self-defeating) or `domains.transfer:execute` (that's *inbound*
to GoDaddy, the wrong direction).

Shown once. Sent as `Authorization: Bearer <token>`.

### 2. Inventory — one read pass

```
GET https://api.godaddy.com/v1/domains?includes=authCode&limit=100
Authorization: Bearer $GODADDY_PAT
```

Paginate with `marker=<last domain on the page>`. Auth codes come back **while
the domain is still locked**, so you do not need to unlock anything yet.

GoDaddy does **not** send rate-limit headers despite documenting them. Pace
yourself at roughly 1–2 requests/second and honor `Retry-After` on a 429.

### 3. Filter, and explain every exclusion

Exclude a domain if any of these hold. Tell the user which domain and why —
never silently shrink the list.

- **`status` is not exactly `ACTIVE`.** Match exactly. Real accounts return
  values like `HELD_EXPIRED_REDEMPTION_MOCK`, so never prefix- or
  substring-match. Treat unknown statuses as excluded, not as safe.
- **`transferAwayEligibleAt` is in the future.** This is the registry's own
  answer. Do not infer the lock from age — a 1,791-day-old domain can still be
  locked. Tell the user the date they can retry.
- **Registered under 60 days ago** (`createdAt`). Backstop, because
  `transferAwayEligibleAt` is absent on roughly half of records.
- **`.uk`** — no auth code exists and Porkbun's API can't transfer it. Track B.
- **No `authCode` field returned** — commonly `PENDING_DNS_ACTIVE`.

A domain having an auth code does **not** mean it's transferable; cancelled
domains still return valid-looking codes. Never use code presence as an
eligibility signal.

### 4. Capture DNS — do not skip this

**A transfer does not carry DNS with it.** If the domain uses GoDaddy
nameservers (`*.domaincontrol.com`), its zone vanishes when the nameservers
change — sites go down, email stops. This is where real migrations break.

```
GET https://api.godaddy.com/v1/domains/{domain}/records
```

Works under `domains.domain:read` alone, and reads GoDaddy directly with the
user's own token. This is the authoritative source, so prefer it: it returns the
whole zone, including records that nothing outside could observe. Pay attention
to MX and TXT — email and domain verification depend on them.

**Drop two kinds of record before importing.**

- **NS and SOA.** They describe the delegation and the zone itself, not its
  contents. `/dns/import` ignores them anyway, but do not count them as records
  you migrated.
- **A records whose value is not an IP address.** GoDaddy returns a
  *placeholder string* in `data` when the record points at one of their own
  hosted products — observed values include `Parked` and `WebsiteBuilder Site`,
  and forwarding behaves the same way. These are not addresses; they are
  GoDaddy features that do not follow the domain. Porkbun rejects them (they
  will appear in `failures` with a generic message), and importing them would
  be meaningless even if it worked. Tell the user plainly: *"this domain was
  parked / on GoDaddy Website Builder, so there is no address record to bring
  across — the site itself has to be rehosted."*

If the domain's `nameServers` are **not** `*.domaincontrol.com`, DNS is hosted
somewhere else entirely and the transfer does not endanger it — the delegation
is unchanged and that provider keeps answering. GoDaddy may still return a
stale zone for the domain; do not import it over the live one.

**Hold onto these records; you cannot import them yet.** `/dns/import` needs
somewhere to put them, and until the transfer exists Porkbun has no zone for a
domain that is not in the account — you will get `INVALID_DOMAIN`. The import
happens in step 8, after the transfer is submitted on hold. Keep the captured
zone for now (in memory, or a `0600` file you delete afterwards).

The call, for reference, is:

```
POST https://api.porkbun.com/api/json/v3/dns/import/{domain}
{"apikey":"pk1_...","secretapikey":"sk1_...","records":[
  {"name":"","type":"A","content":"203.0.113.10"},
  {"name":"www","type":"CNAME","content":"example.com"},
  {"name":"","type":"MX","content":"mx.example.net","prio":10}
]}
```

The GoDaddy token never goes to Porkbun — you read the zone yourself and send
only the records. The call is idempotent: records that already exist come back
in `skipped`, so re-running it after a partial failure is safe.

If you do not have a GoDaddy token, `GET /api/json/v3/dns/scan/{domain}`
discovers what the domain is publishing right now by querying its live
nameservers, and `POST /dns/import/{domain}` with no body imports that. Run it
**before** the nameservers change: a scan is thorough but cannot enumerate a
zone, and once GoDaddy stops answering for the domain nothing can read the
records back.

Porkbun also snapshots a pending inbound transfer's zone on its own and
restores it once the transfer completes, if the Porkbun zone is still empty by
then. Treat that as a safety net, not the plan — an explicit capture is exact.

### What a transfer actually costs

Say this before you show a total, because "moving" sounds free and it is not.

**A transfer buys a year.** For gTLDs the transfer fee adds one year to the
expiry, and the time already paid for at GoDaddy carries over on top rather
than being lost — but it is not refunded either. A domain that renewed last
month is effectively prepaying its second year. That is normal and usually
fine; it just should not be a surprise on the invoice.

**For almost every TLD, transferring costs the same as renewing.** Of the 907
TLDs Porkbun prices, 839 charge exactly the renewal price to transfer. Two
groups differ, and both are worth checking with `GET /pricing/get` (free, no
credentials) before you commit:

- **10 TLDs transfer for nothing**, and add no year: the `.uk` family
  (`uk`, `co.uk`, `org.uk`, `me.uk`), the `.au` family (`au`, `com.au`,
  `net.au`, `org.au`, `id.au`) and `fly`. These are an IPS-tag or registry-side
  change, not a purchase. Note `.uk` still is not transferable through
  Porkbun's API — Track B.
- **10 TLDs cost more to transfer than to renew.** The `.ai` family is the one
  that matters: **$82.70 to renew, $165.09 to transfer** — double. If the user
  holds `.ai` domains, tell them plainly that renewing at GoDaddy and moving
  later is the cheaper order of operations, and let them decide. The rest of
  the group are pennies apart and not worth raising.

Do not hardcode any of these numbers. Read `/pricing/get`, or take the exact
integer from the dry run below, which is authoritative for the domain in hand.

### 5. Price and validate — one call, no charge

Do **not** use `/domain/checkDomain` for pricing: it is rate-limited to 1 per
10 seconds by default (configurable per key, so do not count on more) and
returns a decimal string. The transfer dry run is better and free.

```
POST https://api.porkbun.com/api/json/v3/domain/transfer/{domain}
{"apikey":"pk1_...","secretapikey":"sk1_...","authCode":"<code>","cost":0,"dryRun":true}
```

Returns `wouldSucceed`, `cost` (integer pennies), `costDisplay`, `premium`,
`balance`, `sufficientFunds`. Charges nothing and does not consume rate-limit
budget. Use the integer `cost` verbatim in the live call.

> Never compute pennies with `parseFloat(price) * 100`. Truncation
> under-charges on 287 of the first 5,000 possible prices and Porkbun requires
> an exact match. The dry run hands you the correct integer — use it.

### 6. Show the total. Stop. Ask.

Itemize every domain and price, then the total against the account balance.
Wait for explicit confirmation. This is not optional.

### 7. Clear WHOIS privacy — you cannot do this over the API

**A domain with WHOIS privacy on gets its transfer denied.** GoDaddy refuses it
with a reason that names an objection nobody made:

> Express written objection to the transfer from the Transfer Contact.

Two live attempts with privacy on were refused that way within minutes of
reaching the registry — the second with an auth code validated against the
registry moments earlier, so the code was never the problem. The same test with
privacy removed was accepted immediately.

There is no API for removing it. The whole v1 surface has no privacy endpoint,
and `PATCH /v1/domains/{domain}` accepts only `consent`,
`exposeRegistrantOrganization`, `exposeWhois`, `locked`, `nameServers`,
`renewAuto` and `subaccountId`. The adjacent `exposeWhois` field is gated behind
a `consent` block carrying an agreement key, a timestamp and the end-user's
originating IP — a legal attestation, not a setting you can flip on someone's
behalf.

**So stop here and have the user remove privacy in the GoDaddy dashboard, per
domain.** This is the second unavoidable human step in Track A.

**Do not check the `privacy` field to confirm it worked** — it still reads
`true` afterwards. The field that actually moves is `exposeWhois`:

```
GET https://api.godaddy.com/v1/domains/{domain}
  privacy      true     <- unchanged even after removal; ignore it
  exposeWhois  true     <- this is the one that flipped
```

Removing privacy changes the public registrant, and under ICANN's Transfer
Policy a change of registrant can start a 60-day transfer lock. It did not in
testing, but verify before spending: if `transferAwayEligibleAt` appears with a
future date, stop — that domain is out of reach for 60 days.

### 8. Execute, per domain

1. Unlock: `PATCH https://api.godaddy.com/v1/domains/{domain}` body `{"locked": false}`
2. **Verify it propagated** — re-read the domain until `locked` is `false`.
   The `PATCH` returns `204` immediately while the domain stays locked;
   measured lag across three runs was 18, 24 and 30 seconds. Transferring
   inside that window looks like a bad auth code.
3. **Re-read the auth code now**, after the privacy removal and the unlock.
   Both are registrant-facing changes that can rotate it, and a stale code is
   indistinguishable from a rejected one.
4. Re-run the dry run. If `cost` changed, stop and re-confirm. Note that a
   passing dry run means the price and your account are fine — it does **not**
   mean the losing registrar will accept the transfer.
5. Submit the transfer **on hold**, with an idempotency key:

```
POST https://api.porkbun.com/api/json/v3/domain/transfer/{domain}
Idempotency-Key: <stable string per domain per run>
{"apikey":"...","secretapikey":"...","authCode":"<code>","cost":<integer from dry run>,
 "holdForDnsSetup":true}
```

   `holdForDnsSetup` charges the transfer and parks it at `PENDINGDNS` instead
   of releasing it to the registry. **This is the whole no-downtime
   mechanism**: it buys you the window in which the zone can be built before
   the domain moves. Nothing releases a held transfer on a timer — it waits for
   you. Not available for `.uk` or Handshake TLDs, which return
   `TRANSFER_HOLD_NOT_AVAILABLE` and are not charged; re-send those without the
   flag.

6. Create the zone: `POST /domain/prepareTransfer/{domain}`. This is a separate,
   deliberate step — the zone is not conjured by the first record write.
7. Import the records captured in step 4: `POST /dns/import/{domain}`. Now it
   works, because the pending transfer authorises you for a domain that is not
   yet in the account.
8. Release it: `POST /domain/startTransfer/{domain}`. This refuses with
   `TRANSFER_ZONE_EMPTY` if the zone has no records, which is the guard against
   releasing into exactly the outage the hold was preventing. Only pass
   `force: true` if the domain genuinely needs no DNS at Porkbun.

`GET /domain/getTransferSetup/{domain}` reports where a held transfer is and
what it is waiting for, if you lose your place.

Repoint the nameservers at GoDaddy to Porkbun's **before** step 8 if you can —
that is a zero-gap cutover, because both sides then serve the same records. If
you do not, Porkbun repoints a held transfer automatically once it completes,
but only after the domain has already landed, which leaves a short window where
the old registrar may have stopped answering. Automatic is the safety net;
doing it yourself first is the correct order.

9. **If anything fails after the unlock succeeded, re-lock the domain.** Do not
   leave it open.

Partial failure is normal and safe to retry — the idempotency key means a
repeated call replays its original response rather than charging again.

**A domain can only go through this once per API key path.** If a previous API
transfer of the same domain exists in any state — completed, cancelled or
failed — a fresh submission currently fails with a generic *"Unable to initiate
transfer."* Do not read that as a credential or verification problem; it is not.
Report it and move on to the next domain.

### 9. Track to completion

**Most transfers are much faster than the five-day worst case.** Across 76,000+
completed GoDaddy-to-Porkbun transfers, two thirds finished within 24 hours,
three quarters within five days, and only 1% took longer than a week; the
median was same-day. Tell the user "usually within a day, up to five if GoDaddy
sits on it" rather than promising a week — and treat a transfer still sitting
at the same status after a week as worth investigating, not as normal.

Porkbun emits a `domain.transfer.completed` webhook but has **no failure
event**, so poll for the bad cases:

```
GET https://api.porkbun.com/api/json/v3/domain/getTransfer/{domain}
```

Statuses and what to do:

Branch on the `status` code, not on `statusDescription` — the description is
prose and may be reworded.

| `status` | Meaning | Action |
|---|---|---|
Ordered by how often they actually occur, measured over Porkbun's last 180
days of inbound transfers. The three you will realistically meet are
`PROCTRANSFER`, `PENDINGSUBMIT_TOOMANYATTEMPTS` and `PENDINGSUBMIT_BADAUTHCODE`.

| `status` | Meaning | Action |
|---|---|---|
| `PROCTRANSFER`, `PENDINGTRANSFER` | Good — submitted and waiting on GoDaddy to release | Nothing; approving at GoDaddy skips the wait |
| `PENDINGSUBMIT_TOOMANYATTEMPTS`, `PENDINGSUBMIT_LIMITATTEMPTS` | Auto-retries exhausted. The single most common failure state | Fix the underlying cause (privacy, lock, code), then start a fresh transfer — retrying this one will not resubmit |
| `PENDINGSUBMIT_BADAUTHCODE`, `PENDINGWHOIS_BADAUTHCODE` | Code rejected — **or the losing registrar refused the transfer**, which arrives under the same status | **Repair it in place, do not cancel.** See *Fixing a rejected auth code* below |
| `PENDINGDNS` | Waiting on DNS setup at Porkbun | Import the records from step 4 |
| `PENDINGSUBMIT_DOESNOTEXIST` | Domain does not exist or cannot be transferred | Re-check the name and its status at GoDaddy |
| `PENDINGSUBMIT_BADEUCONTACT` | Registry rejected the registrant contact | Fix the account contact details, then retry |
| `PENDINGSUBMIT_BADSTATUS` | Registry refused | In order: **WHOIS privacy still on** (step 7) → still locked → 60-day lock (120 if the domain was bundled) → **GoDaddy "Domain Protection"** still on (a paid add-on that blocks transfers and is invisible to the API) |
| `BILLINGHOLD3` | Price moved mid-transfer | Re-quote and resubmit |
| `PENDINGSUBMIT`, `PENDINGWHOIS`, `PENDINGINSERT` | Early pipeline states | Nothing; keep polling |
| `DONE` | Transferred | Confirm the DNS landed, then set nameservers |
| `CANCELED` | Cancelled, and any charge refunded | Nothing |

**Statuses ending in `_ERROR:<number>` are stopped, not in flight.** These are
the second-largest failure family and the guide above will not name them
individually, because the suffix is a raw EPP result code from the registry:
`PENDINGTRANSFER_ERROR:2200`, `PENDINGSUBMIT_ERROR:2004`,
`PENDINGTRANSFER_ERROR:2303` and so on, plus a bare `PENDINGINSERT:ERROR`.
Treat any status containing `ERROR` as a stop: surface the whole string to the
user, including the number, and do not keep polling it. 2200 is an
authorization failure, 2201 is "not sponsored by us", 2303 is "object does not
exist"; the rest are worth quoting verbatim rather than guessing at.

`PENDINGAUTH`, `PENDINGCONFIRM`, `PROCWHOIS`, `BADAUTHCODE` and
`PROCWHOIS_BADAUTHCODE` are defined in Porkbun's status map but have never been
recorded on a real inbound transfer. If you see one, treat it as in flight and
report it verbatim — do not tell the user to go looking for a confirmation
email on the strength of the name alone.

Anything else you don't recognise and that does not contain `ERROR`: treat it
as still in flight and show it verbatim. Don't guess it into a failure.

**Watch GoDaddy's side too, not just ours.** `GET /v1/domains/{domain}` moving to
`status: PENDING_TRANSFER_OUT` is the signal that GoDaddy has *accepted* the
request and is processing it. In the refused attempts their status never left
`ACTIVE` — they rejected it outright — so this is the earliest reliable
confirmation that a transfer is genuinely under way.

#### Fixing a rejected auth code

You do not need to cancel, refund and resubmit. There is an endpoint that
replaces the code on the existing transfer and re-queues it, with no second
charge and no loss of the zone you built:

```
POST https://api.porkbun.com/api/json/v3/domain/updateTransferAuthCode/{domain}
{"apikey":"pk1_...","secretapikey":"sk1_...","authCode":"<fresh code>"}
```

**First, find out whether the code is actually the problem** — this status is
also what a registrar refusal looks like. Send the *current* code with
`"dryRun": true`. That validates it against the registry and changes nothing:

- `INVALID_AUTH_CODE` — the code really is wrong. Re-read it from GoDaddy
  (`GET /v1/domains/{domain}?includes=authCode`) and send the fresh one without
  `dryRun`. Codes rotate when privacy is removed or the domain is unlocked,
  which is why step 8 says to re-read it *after* those, and the most common
  reason a code that worked an hour ago does not now.
- `SUCCESS` — the code is fine, so something else refused the transfer. Go read
  the registrant's email for a denial notice and re-check step 7; replacing the
  code will not help.

On success the transfer moves to `PENDINGWHOIS` and the pipeline picks it up
again. Only repairable statuses qualify; anything else returns
`TRANSFER_NOT_REPAIRABLE` rather than pretending to work.

Seen live on `goatse.lol`: the stored code came back `2202 Invalid
authorization information` from the registry, confirming a genuinely stale code
rather than a refusal — a distinction worth one free call before you go hunting.

**A registrar refusal arrives labelled as a bad auth code.** A denied transfer
lands in `PENDINGSUBMIT_BADAUTHCODE` even when the code is provably valid
(`infoDomain` returning `1000` for that exact code, seconds earlier). Before
chasing a new code, check whether the losing registrar simply refused —
re-reading the registrant's email is faster than another round trip.

#### Skipping the 5-day wait

GoDaddy will release the domain on its own within 5 days, but the registrant can
approve it and have it move immediately. **There is no API for this.** The
endpoint that does it, `POST /v2/customers/{customerId}/domains/{domain}/transferOutAccept`,
is part of GoDaddy's reseller surface: a normal customer's Personal Access Token
gets `403 ACCESS_DENIED` on every `/v2/customers/…` path, while non-customer-scoped
v2 calls from the same token succeed. Supplying the right customer number does
not change it. Don't waste a round trip looking for a way in.

What does work is the browser. If you can drive a tab the user is already signed
into — the same arrangement Track B relies on — the approval is a couple of
clicks in **Domain Portfolio → the domain → the pending transfer notice**, or
straight from the transfer email GoDaddy sends the registrant. That turns a
five-day wait into a couple of minutes without leaving the conversation. The
user still performs the identity step themselves if GoDaddy asks for one.

---

## Track B — browser, zero install

The user needs two tabs they're already signed into: GoDaddy's Domain Portfolio
and https://porkbun.com/transfer. They log in; you drive from there.

### Per domain, at GoDaddy

1. **Domain Portfolio** → select the domain
2. Under **Transfer**, click **"Transfer to Another Registrar"**
3. Review the checklist → **"Continue with transfer"**
4. **Identity verification** — a 2-step code or emailed one-time password.
   **You cannot do this step.** Stop and let the user complete it. There is one
   of these per domain, which caps realistic batch size.
5. **"Click here to see Authorization Code"** → **"Copy to Clipboard"**

The code is also emailed to the registrant address, so it's recoverable.

Three things block a transfer and must be cleared first: the **domain lock**,
**Domain Privacy** (GoDaddy turns this off during its own flow), and **Domain
Protection** (a paid add-on — must be fully downgraded to "none").

**`.uk` is different:** no auth code. The checklist asks for the receiving
registrar's **IPS tag**, and the domain then moves at the registry with no
Porkbun-side order at all.

**Do not guess or infer the IPS tag.** It is not something you can derive, and
entering the wrong one hands the domain to a different registrar entirely —
with no Porkbun order to cancel and no auth code to retry. Have the user get
the current tag from https://kb.porkbun.com or from Porkbun support, then enter
that value and select **"Complete Transfer"**.

### At Porkbun — bulk

https://porkbun.com/transfer takes **up to 500 domains at once**. Click
**"Show Bulk Entry Form"** and paste one per line:

```
example.com    AUTHCODEHERE
example.net    AUTHCODEHERE
```

Set WHOIS privacy / auto-renew / DNS options, **"Add Transfers to Cart"**, then
check out. Collect every code at GoDaddy first, then do one submission — one
cart, one total to review.

### Auth codes in this track

You will see codes on screen, which means they enter the conversation and any
screenshots. Tell the user plainly. Mitigations, most useful first:

1. **Finish the transfers.** A code is worthless once the domain lands at
   Porkbun. The risk window is "exposed **and** not completed."
2. **Rotate any code you exposed but didn't use.**
3. **Delete the conversation afterward.**

Don't write codes into files or summaries, and don't repeat one back after it's
been submitted.

### DNS still matters

Before changing anything, open the GoDaddy DNS page for each domain and save
the records. Recreate them at Porkbun **before** the nameservers change.

---

## Prerequisites on the Porkbun side

- An API key and secret — mint one with `/apikey/request` or have the user
  create one at https://porkbun.com/account/api (see *Getting Porkbun
  credentials*)
- Enough account credit for the total
- A **verified email and phone** — the transfer API requires both
- Premium/aftermarket names cannot be transferred via the API (Track B only)

## Reference

- Transfer page: https://porkbun.com/transfer
- API spec: https://porkbun.com/api/json/v3/spec
- Domain endpoints: https://porkbun.com/llms/domain
- DNS endpoints: https://porkbun.com/llms/dns
- Transfer statuses: https://kb.porkbun.com/article/79-what-do-transfer-statuses-mean

*A Porkbun guide. Not affiliated with, endorsed by, or sponsored by GoDaddy.*


---

## 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
