Porkbun Integration Guide
██████╗ ██████╗ ██████╗ ██╗ ██╗██████╗ ██╗ ██╗███╗ ██╗
██╔══██╗██╔═══██╗██╔══██╗██║ ██╔╝██╔══██╗██║ ██║████╗ ██║
██████╔╝██║ ██║██████╔╝█████╔╝ ██████╔╝██║ ██║██╔██╗ ██║
██╔═══╝ ██║ ██║██╔══██╗██╔═██╗ ██╔══██╗██║ ██║██║╚██╗██║
██║ ╚██████╔╝██║ ██║██║ ██╗██████╔╝╚██████╔╝██║ ╚████║
╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ ╚═════╝ ╚═╝ ╚═══╝Porkbun Integration Guide
Focus: Automating domain registration, DNS management, and SSL certificate workflows from Claude Code using the Porkbun REST API.
Overview
Porkbun is a domain registrar known for competitive pricing and a clean REST/JSON API. As of API v3.15 (2026) Porkbun ships a first-party MCP server (@porkbunllc/mcp-server) that exposes the whole API as native tools — plus you can still drive it directly with REST calls from Bash or Node.js. The v3.15 surface now covers the full domain lifecycle over the API — register, renew, transfer-in, DNS & DNSSEC CRUD, SSL bundle retrieval, URL forwarding, glue records, contacts, email forwarding, static hosting, and signed webhooks — with agent-safety features (machine-readable error code + next_action, Idempotency-Key, dryRun, and per-key IP/domain scoping). This guide shows how to wire Porkbun into your Claude Code automation workflow.
Here is the big picture — once you see how the pieces connect, the rest of this guide is just filling in the details:
Official Documentation
| Resource | URL |
|---|---|
| Porkbun API Docs (v3.15) | https://porkbun.com/api/json/v3/documentation |
| Full reference (one flat Markdown file) | https://porkbun.com/llms-full.txt |
| Per-topic Markdown (dns, domain, webhooks…) | https://porkbun.com/llms |
| OpenAPI 3.0 spec (JSON) | https://porkbun.com/api/json/v3/spec |
| Mock server (schema-accurate, no auth) | https://api.porkbun.com/api/json/v3/mock/<path> |
| Official MCP server | https://github.com/oborseth/Porkbun-MCP (npx -y @porkbunllc/mcp-server) |
| Control Panel | https://porkbun.com/account/domainsSpeedy |
| API keys / access + per-key scoping | https://porkbun.com/account/api |
API Setup
Get Your API Keys
- Log in at porkbun.com
- Go to Account → API Access (https://porkbun.com/account/api)
- Enable API access and generate your API key (
pk1_…) and Secret API key (sk1_…) - Still enable API access per-domain (domain settings → API Access toggle) — without it, calls for that domain return a not-found error even though you own it.
- Optional but recommended for agents: scope the key to specific domains and/or source-IP CIDRs so a leaked key can't touch your whole account.
Two auth methods (v3.15). You can send
apikey/secretapikeyin the JSON body (works on GET and POST) orX-API-Key/X-Secret-API-Keyrequest headers. Header auth pairs naturally with the new GET form of read endpoints; writes are always POST. Keys work whether or not account 2FA is enabled. For a throwaway test environment, mint a sandbox key pair (pk1_sb_/sk1_sb_) — it runs the full API against an isolated account with fake credit, no real charges.
Test Your Credentials
# Body auth (POST) — works everywhere
curl -s https://api.porkbun.com/api/json/v3/ping \
-X POST \
-H "Content-Type: application/json" \
-d '{
"apikey": "'$PORKBUN_API_KEY'",
"secretapikey": "'$PORKBUN_SECRET_API_KEY'"
}' | jq .
# Header auth (GET) — v3.15
curl -s "https://api.porkbun.com/api/json/v3/ping" \
-H "X-API-Key: $PORKBUN_API_KEY" \
-H "X-Secret-API-Key: $PORKBUN_SECRET_API_KEY" | jq .Expected response (valid creds add credentialsValid: true):
{ "status": "SUCCESS", "yourIp": "1.2.3.4", "credentialsValid": true }API Integration
TypeScript Client Helper
// lib/porkbun.ts
const PORKBUN_BASE = "https://api.porkbun.com/api/json/v3";
const auth = {
apikey: process.env.PORKBUN_API_KEY!,
secretapikey: process.env.PORKBUN_SECRET_API_KEY!,
};
async function porkbun<T>(path: string, body: object = {}): Promise<T> {
const res = await fetch(`${PORKBUN_BASE}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...auth, ...body }),
});
const data = await res.json();
if (data.status !== "SUCCESS") throw new Error(`Porkbun API error: ${data.message}`);
return data;
}
// List all domains
export const listDomains = () => porkbun<{ domains: any[] }>("/domain/listAll");
// Get DNS records for a domain
export const getDnsRecords = (domain: string) =>
porkbun<{ records: any[] }>(`/dns/retrieve/${domain}`);
// Create a DNS record
export const createDnsRecord = (
domain: string,
type: "A" | "AAAA" | "CNAME" | "MX" | "TXT" | "NS",
name: string,
content: string,
ttl = "600"
) =>
porkbun(`/dns/create/${domain}`, { type, name, content, ttl });
// Delete a DNS record
export const deleteDnsRecord = (domain: string, recordId: string) =>
porkbun(`/dns/delete/${domain}/${recordId}`);
// Edit a DNS record
export const editDnsRecord = (
domain: string,
recordId: string,
type: string,
name: string,
content: string
) => porkbun(`/dns/edit/${domain}/${recordId}`, { type, name, content });Python Client Helper
import os, requests
PORKBUN_BASE = "https://api.porkbun.com/api/json/v3"
AUTH = {
"apikey": os.environ["PORKBUN_API_KEY"],
"secretapikey": os.environ["PORKBUN_SECRET_API_KEY"],
}
def porkbun(path: str, **kwargs) -> dict:
res = requests.post(
f"{PORKBUN_BASE}{path}",
json={**AUTH, **kwargs},
)
data = res.json()
if data.get("status") != "SUCCESS":
raise Exception(f"Porkbun API error: {data.get('message')}")
return data
# List domains
domains = porkbun("/domain/listAll")["domains"]
# Get DNS records
records = porkbun(f"/dns/retrieve/example.com")["records"]
# Create a DNS record
porkbun(
"/dns/create/example.com",
type="A",
name="api",
content="1.2.3.4",
ttl="600",
)
# Create a TXT record (for domain verification)
porkbun(
"/dns/create/example.com",
type="TXT",
name="", # root domain
content="v=spf1 include:mailgun.org ~all",
ttl=600, # integer seconds; min is account-set (typically 600), 0 = account minimum
)Common API Endpoints
The auth fields merge into the JSON body. Build the body once with jq so the JSON is always valid (the old "$AUTH"' + {...}' string-concat trick emits a literal + and is broken — don't use it):
BASE="https://api.porkbun.com/api/json/v3"
# body <endpoint-json> -> merges auth + your fields into one valid JSON object
body() { jq -nc --arg k "$PORKBUN_API_KEY" --arg s "$PORKBUN_SECRET_API_KEY" \
--argjson extra "${1:-{}}" '{apikey:$k, secretapikey:$s} + $extra'; }
# Ping / test connection
curl -s -X POST -H "Content-Type: application/json" -d "$(body)" "$BASE/ping" | jq .status
# List all domains (paginated 1000 at a time via "start")
curl -s -X POST -H "Content-Type: application/json" -d "$(body)" "$BASE/domain/listAll" | jq '.domains[].domain'
# Get DNS records for a domain (also: GET with X-API-Key headers). Note the new "cloudflare" field.
curl -s -X POST -H "Content-Type: application/json" -d "$(body)" "$BASE/dns/retrieve/example.com" | jq '.records[] | {id,type,name,content}'
# Create DNS A record — ttl is an INTEGER now; min is account-set (typically 600), 0 = account minimum
curl -s -X POST -H "Content-Type: application/json" \
-d "$(body '{"type":"A","name":"subdomain","content":"1.2.3.4","ttl":600}')" \
"$BASE/dns/create/example.com" | jq .
# Update-in-place by name+type (no need to retrieve→delete→create for existing records)
curl -s -X POST -H "Content-Type: application/json" \
-d "$(body '{"content":"5.6.7.8","ttl":600}')" \
"$BASE/dns/editByNameType/example.com/A/subdomain" | jq .status
# Check domain availability + price (rate-limited: 1 / 10 s / account by default). Domain is in the PATH only.
curl -s -X POST -H "Content-Type: application/json" -d "$(body)" \
"$BASE/domain/checkDomain/my-new-domain.com" | jq '{avail:.response.avail, price:.response.price, limits}'
# Get SSL certificate bundle (Let's Encrypt; must be issued — status HAVECERT)
curl -s -X POST -H "Content-Type: application/json" \
-d "$(body)" "$BASE/ssl/retrieve/example.com" | jq '{certificatechain,privatekey,publickey}'
# Set URL forwarding (wildcard is required; use redirectType for an exact 301/302/307/masked)
curl -s -X POST -H "Content-Type: application/json" \
-d "$(body '{"subdomain":"www","location":"https://example.com","type":"temporary","includePath":"yes","wildcard":"no"}')" \
"$BASE/domain/addUrlForward/example.com" | jq .Zero-setup shape discovery: every path is mirrored, no auth, under /mock — e.g. curl -s "$BASE/mock/domain/checkDomain/example.com" | jq returns a schema-accurate example response.
Domain pricing & lifecycle via API
This changed with v3.15 (2026). Porkbun now exposes the full domain lifecycle over the API — domain/create (register), domain/renew, and domain/transfer (inbound). The old "registration is dashboard-only" limitation is gone. Registration is a billable write paid from account credit, so it's gated and rate-limited on purpose:
domain/create/{domain}— register using account credit. Requirements: verified account email + phone, sufficient credit,agreeToTerms="yes"/"1", and acost(in pennies) that exactly matches the current price for the domain's minimum duration (get it fromcheckDomainfirst). The account must also have made at least one prior registration, and premium/aftermarket names can't be registered via API. Registrations are always for the registry-minimum term (usually 1 year); WHOIS privacy is auto-enabled where supported. Rate limits: 1 attempt / 10 s and 50 successes / 24 h (both per account, configurable per key). AlwaysdryRun: truefirst to validate availability + price + funds without charging.
# 1) Confirm price (pennies) from checkDomain, then dry-run the registration
curl -s -X POST -H "Content-Type: application/json" \
-d "$(body '{"cost":973,"agreeToTerms":"yes","dryRun":true}')" \
"$BASE/domain/create/codeamanilabs.io" | jq '{wouldSucceed, cost, costDisplay, sufficientFunds}'
# 2) Drop dryRun to actually register (charges account credit)
curl -s -X POST -H "Content-Type: application/json" \
-d "$(body '{"cost":973,"agreeToTerms":"yes"}')" \
"$BASE/domain/create/codeamanilabs.io" | jq '{status, domain, cost, orderId, balance}'For per-TLD eligibility fields (.us nexus, .ca legal type, etc.) call GET /domain/getRegistrationRequirements/{tld} first — it returns the create-request body as JSON Schema. The rest of the API-supported lifecycle:
Get the full price list
pricing/get returns registration, renewal, and transfer prices (USD strings) for every TLD. No auth required. Pass an optional tlds array to filter (POST); the GET form returns all TLDs:
BASE="https://api.porkbun.com/api/json/v3"
# Pricing for ALL TLDs (public)
curl -s "$BASE/pricing/get" | jq '.pricing.com, .pricing.org, .pricing.dev'
# Filter to specific TLDs
curl -s -X POST -H "Content-Type: application/json" \
-d '{"tlds":["com","org","dev"]}' "$BASE/pricing/get" | jq .pricing{ "registration": "9.73", "renewal": "9.73", "transfer": "9.73", "specialType": null, "coupons": {} }Check availability + price for one name
checkDomain/{domain} returns availability and price. It's the go-to before domain/create. Rate-limited to 1 check / 10 s / account by default (returns a limits object + ttlRemaining):
curl -s -X POST -H "Content-Type: application/json" -d "$(body)" \
"$BASE/domain/checkDomain/codeamanilabs.io" | jq '.response'
# { "avail":"yes", "type":"registration", "price":"9.73", "regularPrice":"9.73",
# "premium":"no", "minDuration":1, "additional":{ "renewal":{…}, "transfer":{…} } }List your registered domains (with filters)
domain/listAll paginates 1000 at a time via the start offset (or fetch one with GET /domain/get/{domain}):
curl -s -X POST -H "Content-Type: application/json" \
-d "$(body '{"start":"0","includeLabels":"yes"}')" \
"$BASE/domain/listAll" | jq '.domains[] | {domain, status, expireDate, autoRenew}'Get & update nameservers
Register (or hold) a domain, then point it at an external DNS provider (Cloudflare, Vercel, etc.):
# Get current authoritative nameservers
curl -s -X POST -H "Content-Type: application/json" -d "$(body)" \
"$BASE/domain/getNs/codeamanilabs.org" | jq '.ns'
# Replace nameservers (the ns array fully overwrites — list ALL of them)
curl -s -X POST -H "Content-Type: application/json" \
-d "$(body '{"ns":["maceio.ns.porkbun.com","fortaleza.ns.porkbun.com"]}')" \
"$BASE/domain/updateNs/codeamanilabs.org" | jq '.status'Glue records (vanity / child nameservers)
Needed only if you run your own nameservers on a subdomain of the registered domain:
# List existing glue records
curl -s -X POST -H "Content-Type: application/json" -d "$(body)" \
"$BASE/domain/getGlue/example.com" | jq .
# Create glue: ns1.example.com -> IPs (v4 and/or v6)
curl -s -X POST -H "Content-Type: application/json" \
-d "$(body '{"ips":["1.2.3.4","2606:4700::1"]}')" \
"$BASE/domain/createGlue/example.com/ns1" | jq '.status'
# updateGlue/{domain}/{subdomain} and deleteGlue/{domain}/{subdomain} mirror this shapeDNSSEC records
DNSSEC DS records live under the /dns/ namespace, not /domain/:
# Get DNSSEC records
curl -s -X POST -H "Content-Type: application/json" -d "$(body)" \
"$BASE/dns/getDnssecRecords/example.com" | jq .
# Create a DS record
curl -s -X POST -H "Content-Type: application/json" \
-d "$(body '{"keyTag":"64087","alg":"13","digestType":"2","digest":"<hash>"}')" \
"$BASE/dns/createDnssecRecord/example.com" | jq '.status'
# Delete by key tag
curl -s -X POST -H "Content-Type: application/json" -d "$(body)" \
"$BASE/dns/deleteDnssecRecord/example.com/64087" | jq '.status'Gotcha — checkDomain is rate-limited. Unlike DNS endpoints, domain/checkDomain hits Porkbun's upstream registry and is throttled — the default is 1 check per 10 seconds per account (configurable per key); the response carries a limits object and ttlRemaining so you can pace yourself. Don't loop it over a wordlist to brainstorm names. For bulk price comparisons use pricing/get once (it's the whole TLD table in a single call) and only call checkDomain for the handful of finalists.
Environment Variables
# Required (both are secrets — neither is publishable)
PORKBUN_API_KEY=pk1_... # From porkbun.com/account/api (API key; pk1_sb_ = sandbox)
PORKBUN_SECRET_API_KEY=sk1_... # From porkbun.com/account/api (Secret API key; sk1_sb_ = sandbox)Store in .env and never commit to version control. Add to .gitignore:
.env
.env.local
*.envAutomation Workflows
You've got the API down — now let automation handle the repetitive parts. For a record that already exists, dns/editByNameType/{domain}/{type}/{subdomain} updates it in place (v3.15) — no retrieve→delete→create needed. When you can't assume the record exists (the general case the /dns slash command and the GitHub Action below handle), the safe flow is still retrieve → replace:
Claude Code Slash Command: Update DNS
.claude/commands/dns.md:
Update the DNS record for $ARGUMENTS.
Parse $ARGUMENTS as: "subdomain.domain.com TYPE value" (e.g., "api.example.com A 1.2.3.4")
Use Bash to call the Porkbun API:
1. First retrieve existing records to check if the record exists
2. If it exists, delete the old record, then create a new one
3. If it doesn't exist, create it directly
4. Verify the update by retrieving records again and confirming the new value
Report: what was changed, the record ID, and the new value.Usage: /project:dns api.example.com A 1.2.3.4
Hook: Verify Domain After Deploy
.claude/settings.json:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "node scripts/verify-dns.js"
}
]
}
]
}
}scripts/verify-dns.js:
const domain = process.env.DOMAIN_NAME;
if (!domain) process.exit(0);
const response = await fetch(`https://api.porkbun.com/api/json/v3/dns/retrieve/${domain}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
apikey: process.env.PORKBUN_API_KEY,
secretapikey: process.env.PORKBUN_SECRET_API_KEY,
}),
});
const data = await response.json();
if (data.status === "SUCCESS") {
console.log(`DNS verified — ${data.records.length} records for ${domain}`);
} else {
console.error("DNS verification failed:", data.message);
}GitHub Actions: Auto-update DNS on Vercel Deploy
# .github/workflows/update-dns.yml
name: Update Porkbun DNS
on:
workflow_dispatch:
inputs:
subdomain:
description: Subdomain to update
required: true
ip:
description: New IP address
required: true
jobs:
update-dns:
runs-on: ubuntu-latest
steps:
- name: Update DNS A record
env:
PORKBUN_API_KEY: ${{ secrets.PORKBUN_API_KEY }}
PORKBUN_SECRET_API_KEY: ${{ secrets.PORKBUN_SECRET_API_KEY }}
run: |
# Get existing record ID
records=$(curl -s -X POST https://api.porkbun.com/api/json/v3/dns/retrieve/${{ secrets.DOMAIN_NAME }} \
-H "Content-Type: application/json" \
-d "{\"apikey\":\"$PORKBUN_API_KEY\",\"secretapikey\":\"$PORKBUN_SECRET_API_KEY\"}")
record_id=$(echo $records | jq -r ".records[] | select(.name==\"${{ inputs.subdomain }}\") | .id")
if [ -n "$record_id" ]; then
# Delete existing
curl -s -X POST "https://api.porkbun.com/api/json/v3/dns/delete/${{ secrets.DOMAIN_NAME }}/$record_id" \
-H "Content-Type: application/json" \
-d "{\"apikey\":\"$PORKBUN_API_KEY\",\"secretapikey\":\"$PORKBUN_SECRET_API_KEY\"}"
fi
# Create new — ttl is an integer; min is account-set (typically 600), 0 = account minimum
curl -s -X POST "https://api.porkbun.com/api/json/v3/dns/create/${{ secrets.DOMAIN_NAME }}" \
-H "Content-Type: application/json" \
-d "{\"apikey\":\"$PORKBUN_API_KEY\",\"secretapikey\":\"$PORKBUN_SECRET_API_KEY\",\"type\":\"A\",\"name\":\"${{ inputs.subdomain }}\",\"content\":\"${{ inputs.ip }}\",\"ttl\":600}"Common Use Cases
| Use Case | Approach |
|---|---|
| Register a domain | /domain/create/{domain} (dryRun first) — v3.15, no longer dashboard-only |
| Renew / transfer-in | /domain/renew/{domain} · /domain/transfer/{domain} |
| Point subdomain to new IP | Create A record, or dns/editByNameType to update in place |
| Domain verification (TXT) | Create TXT record for email/GSC verification |
| SSL certificates | ssl/retrieve/{domain} for the Let's Encrypt bundle (status HAVECERT) |
| URL forwarding | /domain/addUrlForward/{domain} (list getUrlForwarding, remove deleteUrlForward/{id}) |
| Check availability + price | /domain/checkDomain/{domain} |
| DKIM/SPF for email | Create TXT records via API |
| Give an agent least-privilege access | Scope the key to specific domains / source-IP CIDR at porkbun.com/account/api |
Troubleshooting
Errors now carry a machine-readable code (and often a next_action object) alongside the human-readable message — branch on code, not on message text.
| Issue | Fix |
|---|---|
API key invalid | Regenerate keys; ensure no trailing spaces when copying. Confirm you're not mixing a sandbox pk1_sb_ key with the production base URL. |
Domain not found | Domain must be in your account and have per-domain API Access toggled on (domain settings). |
| Registration rejected | checkDomain first; cost (pennies) must exactly match; account needs verified email/phone, credit, ≥1 prior registration; premium names aren't API-registerable. Use dryRun to see why. |
Rate limited (checkDomain / create) | Respect limits/ttlRemaining in the response; default is 1 check or attempt / 10 s / account. |
| SSL retrieve fails | Certificate must already be issued (status HAVECERT); wait for DNS propagation + issuance, then retry. |
| API access denied | Enable API access at porkbun.com/account/api, and toggle per-domain API Access. |
| TTL rejected as too low | ttl is an integer; minimum is account-set (typically 600). Send 0 to use the account minimum. |