← Back to dashboard
porkbundomainsfreshReader view (for NotebookLM)

Porkbun Integration Guide

Text
██████╗  ██████╗ ██████╗ ██╗  ██╗██████╗ ██╗   ██╗███╗   ██╗
██╔══██╗██╔═══██╗██╔══██╗██║ ██╔╝██╔══██╗██║   ██║████╗  ██║
██████╔╝██║   ██║██████╔╝█████╔╝ ██████╔╝██║   ██║██╔██╗ ██║
██╔═══╝ ██║   ██║██╔══██╗██╔═██╗ ██╔══██╗██║   ██║██║╚██╗██║
██║     ╚██████╔╝██║  ██║██║  ██╗██████╔╝╚██████╔╝██║ ╚████║
╚═╝      ╚═════╝ ╚═╝  ╚═╝╚═╝  ╚═╝╚═════╝  ╚═════╝ ╚═╝  ╚═══╝

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

ResourceURL
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 serverhttps://github.com/oborseth/Porkbun-MCP (npx -y @porkbunllc/mcp-server)
Control Panelhttps://porkbun.com/account/domainsSpeedy
API keys / access + per-key scopinghttps://porkbun.com/account/api

API Setup

Get Your API Keys

  1. Log in at porkbun.com
  2. Go to Account → API Access (https://porkbun.com/account/api)
  3. Enable API access and generate your API key (pk1_…) and Secret API key (sk1_…)
  4. 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.
  5. 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/secretapikey in the JSON body (works on GET and POST) or X-API-Key/X-Secret-API-Key request 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

Bash
# 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):

JSON
{ "status": "SUCCESS", "yourIp": "1.2.3.4", "credentialsValid": true }

API Integration

TypeScript Client Helper

TypeScript
// 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

Python
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):

Bash
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 a cost (in pennies) that exactly matches the current price for the domain's minimum duration (get it from checkDomain first). 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). Always dryRun: true first to validate availability + price + funds without charging.

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

Bash
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
JSON
{ "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):

Bash
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}):

Bash
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.):

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

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

DNSSEC records

DNSSEC DS records live under the /dns/ namespace, not /domain/:

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

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

Text
.env
.env.local
*.env

Automation 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:

Markdown
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:

JSON
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "node scripts/verify-dns.js"
          }
        ]
      }
    ]
  }
}

scripts/verify-dns.js:

JavaScript
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

YAML
# .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 CaseApproach
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 IPCreate A record, or dns/editByNameType to update in place
Domain verification (TXT)Create TXT record for email/GSC verification
SSL certificatesssl/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 emailCreate TXT records via API
Give an agent least-privilege accessScope 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.

IssueFix
API key invalidRegenerate keys; ensure no trailing spaces when copying. Confirm you're not mixing a sandbox pk1_sb_ key with the production base URL.
Domain not foundDomain must be in your account and have per-domain API Access toggled on (domain settings).
Registration rejectedcheckDomain 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 failsCertificate must already be issued (status HAVECERT); wait for DNS propagation + issuance, then retry.
API access deniedEnable API access at porkbun.com/account/api, and toggle per-domain API Access.
TTL rejected as too lowttl is an integer; minimum is account-set (typically 600). Send 0 to use the account minimum.