# Porkbun API v3 — Webhooks

> Register HTTPS endpoints to receive signed, real-time event notifications.

Part of the Porkbun API v3.62. Topic index: https://porkbun.com/llms · Full reference: https://porkbun.com/llms-full.txt · Overview: https://porkbun.com/llms.txt · OpenAPI spec: https://porkbun.com/api/json/v3/spec

**Auth:** send `X-API-Key` / `X-Secret-API-Key` headers (preferred) or `apikey` / `secretapikey` in the JSON body. Create keys at https://porkbun.com/account/api

---

# Webhooks overview

Subscribe HTTPS endpoints to account events and Porkbun will `POST` a signed JSON payload to them as those events happen — no polling required. Manage endpoints with the **Webhooks** endpoints in this reference, the MCP tools (`create_webhook`, `list_webhooks`, …), or the web UI at `porkbun.com/account/api`.

**Event types:** `domain.registered`, `domain.renewed`, `domain.transfer.completed`, `domain.expiring` (fires at 60/30/5 days before expiry), `dns.record.created`, `dns.record.updated`, `dns.record.deleted`, `account.verification.code_sent`, `account.verification.completed`, `cloudflare.connect.completed`, `cloudflare.connect.failed` (a Cloudflare move reached a terminal state — `status` in the payload distinguishes `done` from `skipped`/`failed`, so you can stop polling `/cloudflare/getQueue`). Subscribe to specific types, to a prefix wildcard like `dns.*`, or to `*` for everything (recommended — you'll receive new event types automatically). Call `GET /webhook/eventTypes` for the live catalog.

**Payload envelope** — every delivery has the same outer shape:

```json
{
  "event": "domain.registered",
  "id": "018f9c2a-7b3e-7c41-9b8a-2f1e6d4c5a90",
  "createdAt": "2026-06-17T18:30:00Z",
  "data": { "domain": "example.com", "tld": "com", "expireDate": "2027-06-17 18:30:00" }
}
```

`id` is a UUIDv7 (time-ordered) and is also sent as the `X-Porkbun-Webhook-Id` header; use it to dedupe, since an endpoint may occasionally receive the same event more than once. `createdAt` is UTC (ISO 8601). The `data` object is event-specific; its fields are listed below.

**What each event sends in `data`.** Dates inside `data` are UTC, formatted `YYYY-MM-DD HH:MM:SS`. Fields may be added over time, so ignore ones you don't recognise. No event carries contact details, auth codes, keys or other secrets.

| Event | When it fires | `data` fields |
|---|---|---|
| `domain.registered` | A registration completes | `domain`, `tld`, `expireDate` |
| `domain.renewed` | A renewal completes (manual, API or auto-renew) | `domain`, `tld`, `expireDate` (the new expiry) |
| `domain.transfer.completed` | An inbound transfer finishes and the domain is in the account | `domain`, `tld` |
| `domain.expiring` | 60, 30 and 5 days before expiry | `domain`, `tld`, `daysToExpire` (number: 60, 30 or 5), `expireDate` |
| `dns.record.created` | A record is added (website, API, MCP, or a connected app) | `domain`, `name`, `id`, `type`, `content`, `ttl`, `prio` |
| `dns.record.updated` | A record is changed | `domain`, `name`, `id`, `type`, `content`, `ttl`, `prio` (the values after the change) |
| `dns.record.deleted` | A record is removed | `domain`, `name`, `id`, `type`, `content` (the values it had) |
| `cloudflare.connect.completed` | A move to Cloudflare finished | `domain`, `status` (`done`), `message`, `cfAccountId`, `zoneId`, `previousNameservers`, `assignedNameservers`, `completedAt` |
| `cloudflare.connect.failed` | A move to Cloudflare stopped without finishing | Same fields as completed; `status` is `failed` or `skipped`, `message` says why, `zoneId` may be `null` and the nameserver lists empty |
| `account.verification.code_sent` | A verification code went to the account's email address or phone (from the website or the API) | `channel` (`email` or `phone`), `method` (`email`, `sms` or `call`), `sentTo` (masked), `expiresInMinutes` |
| `account.verification.completed` | The account's email address or phone was verified (by link, website or API) | `channel`, `emailVerified`, `phoneVerified` (the account's state after this) |
| `webhook.test` | You call `POST /webhook/test` | `endpointId` (number), `message` |

Field notes:

- `domain` is the registered domain (`example.com`); `tld` is its extension without the dot (`com`).
- DNS events: `name` is the full hostname (`www.example.com`, or `example.com` for the apex). `id`, `ttl` and `prio` are strings, the same as in `/dns/retrieve`, and `prio` is `null` for types that have none. Record changes made by Porkbun itself for certificate issuance (`_acme-challenge` names) are not sent.
- Verification events never contain the code: it only proves the address or phone is real because it arrives there. `code_sent` tells an agent to ask the person for it; `completed` tells it to retry what failed with `VERIFICATION_REQUIRED` once both are `true`. Login codes are not sent.
- Cloudflare events: `previousNameservers` and `assignedNameservers` are arrays of hostnames; `cfAccountId` and `zoneId` are Cloudflare's IDs.

Examples:

```json
{
  "event": "domain.expiring",
  "id": "018f9c2a-7b3e-7c41-9b8a-2f1e6d4c5a91",
  "createdAt": "2026-06-17T09:00:00Z",
  "data": { "domain": "example.com", "tld": "com", "daysToExpire": 30, "expireDate": "2026-07-17 18:30:00" }
}
```

```json
{
  "event": "dns.record.updated",
  "id": "018f9c2a-7b3e-7c41-9b8a-2f1e6d4c5a92",
  "createdAt": "2026-06-17T18:31:05Z",
  "data": { "domain": "example.com", "name": "www.example.com", "id": "123456789", "type": "A", "content": "192.0.2.1", "ttl": "600", "prio": null }
}
```

```json
{
  "event": "cloudflare.connect.completed",
  "id": "018f9c2a-7b3e-7c41-9b8a-2f1e6d4c5a93",
  "createdAt": "2026-06-17T19:02:11Z",
  "data": {
    "domain": "example.com", "status": "done", "message": "Connected to Cloudflare.",
    "cfAccountId": "0123456789abcdef0123456789abcdef", "zoneId": "fedcba9876543210fedcba9876543210",
    "previousNameservers": ["curitiba.ns.porkbun.com", "fortaleza.ns.porkbun.com"],
    "assignedNameservers": ["anna.ns.cloudflare.com", "lee.ns.cloudflare.com"],
    "completedAt": "2026-06-17 19:02:10"
  }
}
```

To see any event's exact payload against your own handler, use a sandbox key and `POST /sandbox/triggerWebhook` with `{"eventType": "...", "domain": "example.com"}`; `GET /webhook/delivery/{id}` shows the full payload of any past delivery.

**Delivery headers:**

- `X-Porkbun-Event` — the event type (e.g. `domain.renewed`).
- `X-Porkbun-Webhook-Id` — the event UUID (matches `id` in the body).
- `X-Porkbun-Webhook-Timestamp` — Unix seconds when the request was signed.
- `X-Porkbun-Signature` — `sha256=` + the signature (see below).

**Verify the signature.** The signature is `HMAC-SHA256(secret, "{timestamp}.{rawBody}")` where `secret` is the endpoint's signing secret, `{timestamp}` is the `X-Porkbun-Webhook-Timestamp` header value, and `{rawBody}` is the exact bytes of the request body (verify before parsing). Compare using a constant-time equality check, and reject timestamps that are too old (e.g. >5 minutes) to blunt replay attacks.

```php
$timestamp = $_SERVER['HTTP_X_PORKBUN_WEBHOOK_TIMESTAMP'];
$signature = $_SERVER['HTTP_X_PORKBUN_SIGNATURE']; // "sha256=..."
$body      = file_get_contents('php://input');
$expected  = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $body, $endpointSecret);
if (!hash_equals($expected, $signature) || abs(time() - (int)$timestamp) > 300) {
    http_response_code(400); exit;
}
// signature OK — now json_decode($body) and process
```

```javascript
import crypto from 'node:crypto';
const ts  = req.header('X-Porkbun-Webhook-Timestamp');
const sig = req.header('X-Porkbun-Signature');
const expected = 'sha256=' + crypto.createHmac('sha256', endpointSecret)
  .update(`${ts}.${rawBody}`).digest('hex');
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))
  && Math.abs(Date.now()/1000 - Number(ts)) < 300;
```

**Delivery log & manual resend.** Every attempt is recorded — list recent deliveries with `GET /webhook/deliveries` (filter by `endpointId` or `status`, newest first) and fetch one with its full payload via `GET /webhook/delivery/{id}`. History is retained ~30 days. To replay a delivery (e.g. after fixing a bug on your side), call `POST /webhook/resend` with the delivery `id`; it re-queues a fresh attempt reusing the **original event id**, so a consumer that dedupes on `X-Porkbun-Webhook-Id` treats it as the same event. The target endpoint must still exist and be ACTIVE.

**Responding & retries.** Return any `2xx` status to acknowledge. Non-2xx responses, timeouts, or connection errors are retried with exponential backoff (~1m, 5m, 30m, 2h, 6h) up to 6 attempts. An endpoint that racks up 20 consecutive failures is automatically disabled and the account owner emailed; re-enable it (which resets the failure counter) via `POST /webhook/update` with `status: "ACTIVE"`. Use `POST /webhook/test` to send a `webhook.test` event and confirm your verifier works end-to-end.

---

# Endpoints

## GET /api/json/v3/webhook/eventTypes

**List subscribable event types**

Return the catalog of event types a webhook endpoint can subscribe to. Read-only; supports GET (header auth) or POST (body or header auth).

**Key permission needed:** `read`

| Parameter | In | Required | Description |
|---|---|---|---|
| `Authorization` | header | no | Bearer token: `Authorization: Bearer <token>` |
| `X-API-Key` | header | no | API key header auth (use with X-Secret-API-Key) |
| `X-Secret-API-Key` | header | no | Secret API key header auth (use with X-API-Key) |

```bash
curl 'https://api.porkbun.com/api/json/v3/webhook/eventTypes' \
  -H 'X-API-Key: pk1_...' \
  -H 'X-Secret-API-Key: sk1_...'
```

Response fields (WebhookEventTypesResponse):

| Field | Type | Description |
|---|---|---|
| `status` | string |  |
| `eventTypes` | string[] |  |

## GET /api/json/v3/webhook/list

**List webhook endpoints**

List all webhook endpoints registered on the authenticated account, including each endpoint's signing secret and delivery health. Read-only; supports GET (header auth) or POST (body or header auth).

**Key permission needed:** `read`

| Parameter | In | Required | Description |
|---|---|---|---|
| `Authorization` | header | no | Bearer token: `Authorization: Bearer <token>` |
| `X-API-Key` | header | no | API key header auth (use with X-Secret-API-Key) |
| `X-Secret-API-Key` | header | no | Secret API key header auth (use with X-API-Key) |

```bash
curl 'https://api.porkbun.com/api/json/v3/webhook/list' \
  -H 'X-API-Key: pk1_...' \
  -H 'X-Secret-API-Key: sk1_...'
```

Response fields (WebhookListResponse):

| Field | Type | Description |
|---|---|---|
| `status` | string |  |
| `endpoints` | object[] |  |

## GET /api/json/v3/webhook/get/{id}

**Get a webhook endpoint**

Fetch a single webhook endpoint by id, including its signing secret and delivery health. Read-only; supports GET (header auth) or POST (body or header auth).

**Key permission needed:** `read`

| Parameter | In | Required | Description |
|---|---|---|---|
| `id` | path | yes |  |
| `Authorization` | header | no | Bearer token: `Authorization: Bearer <token>` |
| `X-API-Key` | header | no | API key header auth (use with X-Secret-API-Key) |
| `X-Secret-API-Key` | header | no | Secret API key header auth (use with X-API-Key) |

```bash
curl 'https://api.porkbun.com/api/json/v3/webhook/get/42' \
  -H 'X-API-Key: pk1_...' \
  -H 'X-Secret-API-Key: sk1_...'
```

Response fields (WebhookEndpointResponse):

| Field | Type | Description |
|---|---|---|
| `status` | string |  |
| `endpoint` | object |  |

## POST /api/json/v3/webhook/create

**Create a webhook endpoint**

Register an HTTPS endpoint to receive signed event payloads. The response includes the generated `secret` — store it securely; it is the HMAC key used to verify the `X-Porkbun-Signature` header. Omit `events` (or pass `["*"]`) to subscribe to all event types. Maximum 20 endpoints per account. **URL requirements:** the endpoint must be an `https://` URL on the standard port 443, with a hostname that resolves to a public internet address. Private, loopback, link-local, CGNAT and other reserved ranges are refused (`INVALID_WEBHOOK_URL`) because Porkbun delivers from inside its own network. Credentials in the URL (`https://user:pass@host`) are also refused — authenticate the receiver by verifying the `X-Porkbun-Signature` HMAC instead. A hostname that does not resolve yet is accepted, so you can register the endpoint before the receiver is deployed, but delivery is re-checked against these rules immediately before each request.

**Key permission needed:** `account`

| Parameter | In | Required | Description |
|---|---|---|---|
| `Idempotency-Key` | header | no | Optional, recommended on every purchase and write. Send a unique value (a UUID) per intended operation. Repeating the request with the same key and an identical body within 24 hours returns the original response instead of doing it again, so a retry after a timeout cannot double-charge or double-apply. The same key with a different body returns 409 IDEMPOTENCY_KEY_MISMATCH; while the first request is still running, 409 IDEMPOTENCY_KEY_IN_USE. |

Request body fields:

| Field | Type | Required | Description |
|---|---|---|---|
| `url` | string | yes | HTTPS endpoint that receives the signed POST. Port 443 only, and the hostname must resolve to a public internet address — private/loopback/reserved targets are refused. |
| `events` | string[] | no | Event types to subscribe to. Omit, or pass ["*"], for all events. Prefix wildcards like "dns.*" are allowed. |

```bash
curl -X POST https://api.porkbun.com/api/json/v3/webhook/create \
  -H 'Content-Type: application/json' \
  -d '{"apikey":"pk1_...","secretapikey":"sk1_...","url":"https://example.com/porkbun/webhook","events":["*"]}'
```

Response fields (WebhookEndpointResponse):

| Field | Type | Description |
|---|---|---|
| `status` | string |  |
| `endpoint` | object |  |

## POST /api/json/v3/webhook/update

**Update a webhook endpoint**

Update an endpoint's URL, event subscriptions, and/or status. Only the supplied fields change. Set `status` to `DISABLED` to pause deliveries or `ACTIVE` to resume (resuming also resets the consecutive-failure counter). **URL requirements:** the endpoint must be an `https://` URL on the standard port 443, with a hostname that resolves to a public internet address. Private, loopback, link-local, CGNAT and other reserved ranges are refused (`INVALID_WEBHOOK_URL`) because Porkbun delivers from inside its own network. Credentials in the URL (`https://user:pass@host`) are also refused — authenticate the receiver by verifying the `X-Porkbun-Signature` HMAC instead. A hostname that does not resolve yet is accepted, so you can register the endpoint before the receiver is deployed, but delivery is re-checked against these rules immediately before each request.

**Key permission needed:** `account`

| Parameter | In | Required | Description |
|---|---|---|---|
| `Idempotency-Key` | header | no | Optional, recommended on every purchase and write. Send a unique value (a UUID) per intended operation. Repeating the request with the same key and an identical body within 24 hours returns the original response instead of doing it again, so a retry after a timeout cannot double-charge or double-apply. The same key with a different body returns 409 IDEMPOTENCY_KEY_MISMATCH; while the first request is still running, 409 IDEMPOTENCY_KEY_IN_USE. |

Request body fields:

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | integer | yes | Endpoint id to update. |
| `url` | string | no | HTTPS endpoint that receives the signed POST. Port 443 only, and the hostname must resolve to a public internet address — private/loopback/reserved targets are refused. |
| `events` | string[] | no | Replacement event subscription list (optional). |
| `status` | string | no | Set ACTIVE to resume (also clears the failure counter) or DISABLED to pause (optional). |

```bash
curl -X POST https://api.porkbun.com/api/json/v3/webhook/update \
  -H 'Content-Type: application/json' \
  -d '{"apikey":"pk1_...","secretapikey":"sk1_...","id":42,"status":"DISABLED"}'
```

Response fields (WebhookEndpointResponse):

| Field | Type | Description |
|---|---|---|
| `status` | string |  |
| `endpoint` | object |  |

## POST /api/json/v3/webhook/rotateSecret

**Rotate the signing secret**

Generate a new signing secret for an endpoint and return the endpoint with the new secret. Deliveries are signed with the new secret immediately, so update your verifier as part of the same operation.

**Key permission needed:** `account`

| Parameter | In | Required | Description |
|---|---|---|---|
| `Idempotency-Key` | header | no | Optional, recommended on every purchase and write. Send a unique value (a UUID) per intended operation. Repeating the request with the same key and an identical body within 24 hours returns the original response instead of doing it again, so a retry after a timeout cannot double-charge or double-apply. The same key with a different body returns 409 IDEMPOTENCY_KEY_MISMATCH; while the first request is still running, 409 IDEMPOTENCY_KEY_IN_USE. |

Request body fields:

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | integer | yes | Endpoint id. |

```bash
curl -X POST https://api.porkbun.com/api/json/v3/webhook/rotateSecret \
  -H 'Content-Type: application/json' \
  -d '{"apikey":"pk1_...","secretapikey":"sk1_...","id":42}'
```

Response fields (WebhookEndpointResponse):

| Field | Type | Description |
|---|---|---|
| `status` | string |  |
| `endpoint` | object |  |

## POST /api/json/v3/webhook/test

**Send a test event**

Enqueue a `webhook.test` event to the endpoint so you can confirm reachability and that your signature verification works. The endpoint must be ACTIVE. Delivery is asynchronous (usually within a minute).

**Key permission needed:** `account`

| Parameter | In | Required | Description |
|---|---|---|---|
| `Idempotency-Key` | header | no | Optional, recommended on every purchase and write. Send a unique value (a UUID) per intended operation. Repeating the request with the same key and an identical body within 24 hours returns the original response instead of doing it again, so a retry after a timeout cannot double-charge or double-apply. The same key with a different body returns 409 IDEMPOTENCY_KEY_MISMATCH; while the first request is still running, 409 IDEMPOTENCY_KEY_IN_USE. |

Request body fields:

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | integer | yes | Endpoint id. |

```bash
curl -X POST https://api.porkbun.com/api/json/v3/webhook/test \
  -H 'Content-Type: application/json' \
  -d '{"apikey":"pk1_...","secretapikey":"sk1_...","id":42}'
```

Response fields (WebhookTestResponse):

| Field | Type | Description |
|---|---|---|
| `status` | string |  |
| `eventId` | string | UUID of the queued webhook.test event. |
| `message` | string |  |

## POST /api/json/v3/webhook/delete

**Delete a webhook endpoint**

Delete a webhook endpoint by id. Deliveries stop immediately.

**Key permission needed:** `account`

| Parameter | In | Required | Description |
|---|---|---|---|
| `Idempotency-Key` | header | no | Optional, recommended on every purchase and write. Send a unique value (a UUID) per intended operation. Repeating the request with the same key and an identical body within 24 hours returns the original response instead of doing it again, so a retry after a timeout cannot double-charge or double-apply. The same key with a different body returns 409 IDEMPOTENCY_KEY_MISMATCH; while the first request is still running, 409 IDEMPOTENCY_KEY_IN_USE. |

Request body fields:

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | integer | yes | Endpoint id. |

```bash
curl -X POST https://api.porkbun.com/api/json/v3/webhook/delete \
  -H 'Content-Type: application/json' \
  -d '{"apikey":"pk1_...","secretapikey":"sk1_...","id":42}'
```

Response fields:

| Field | Type | Description |
|---|---|---|
| `status` | string |  |
| `message` | string |  |

## GET /api/json/v3/webhook/deliveries

**List webhook deliveries**

List recent delivery attempts across the account (newest first), optionally filtered by endpoint or status. The bulky payload is omitted — use GET /webhook/delivery/{id} for it. History is retained about 30 days. Read-only; supports GET (header auth) or POST (body or header auth).

**Key permission needed:** `read`

| Parameter | In | Required | Description |
|---|---|---|---|
| `endpointId` | query | no | Only deliveries for this endpoint. |
| `status` | query | no | Filter by delivery status. |
| `start` | query | no | Pagination offset (default 0). |
| `limit` | query | no | Page size, 1-200 (default 50). |
| `Authorization` | header | no | Bearer token: `Authorization: Bearer <token>` |
| `X-API-Key` | header | no | API key header auth (use with X-Secret-API-Key) |
| `X-Secret-API-Key` | header | no | Secret API key header auth (use with X-API-Key) |

```bash
curl 'https://api.porkbun.com/api/json/v3/webhook/deliveries?status=FAILED&limit=50' \
  -H 'X-API-Key: pk1_...' \
  -H 'X-Secret-API-Key: sk1_...'
```

Response fields (WebhookDeliveryListResponse):

| Field | Type | Description |
|---|---|---|
| `status` | string |  |
| `deliveries` | object[] | Newest first. The bulky `payload` field is omitted here. |
| `total` | integer | Total matching deliveries (for pagination). |
| `start` | integer | Offset of this page. |
| `limit` | integer | Page size used. |
| `message` | string |  |

## GET /api/json/v3/webhook/delivery/{id}

**Get a webhook delivery**

Fetch a single delivery including the full event payload that was (or will be) sent and its delivery status. Read-only; supports GET (header auth) or POST (body or header auth).

**Key permission needed:** `read`

| Parameter | In | Required | Description |
|---|---|---|---|
| `id` | path | yes |  |
| `Authorization` | header | no | Bearer token: `Authorization: Bearer <token>` |
| `X-API-Key` | header | no | API key header auth (use with X-Secret-API-Key) |
| `X-Secret-API-Key` | header | no | Secret API key header auth (use with X-API-Key) |

```bash
curl 'https://api.porkbun.com/api/json/v3/webhook/delivery/9001' \
  -H 'X-API-Key: pk1_...' \
  -H 'X-Secret-API-Key: sk1_...'
```

Response fields (WebhookDeliveryResponse):

| Field | Type | Description |
|---|---|---|
| `status` | string |  |
| `delivery` | object |  |

## POST /api/json/v3/webhook/resend

**Resend a delivery**

Re-queue a past delivery to its endpoint. Clones it into a fresh attempt reusing the ORIGINAL event id (so consumers that dedupe on X-Porkbun-Webhook-Id treat it as the same event). The endpoint must still exist and be ACTIVE. The original delivery row is left intact as history.

**Key permission needed:** `account`

| Parameter | In | Required | Description |
|---|---|---|---|
| `Idempotency-Key` | header | no | Optional, recommended on every purchase and write. Send a unique value (a UUID) per intended operation. Repeating the request with the same key and an identical body within 24 hours returns the original response instead of doing it again, so a retry after a timeout cannot double-charge or double-apply. The same key with a different body returns 409 IDEMPOTENCY_KEY_MISMATCH; while the first request is still running, 409 IDEMPOTENCY_KEY_IN_USE. |

Request body fields:

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | integer | yes | Endpoint id. |

```bash
curl -X POST https://api.porkbun.com/api/json/v3/webhook/resend \
  -H 'Content-Type: application/json' \
  -d '{"apikey":"pk1_...","secretapikey":"sk1_...","id":9001}'
```

Response fields (WebhookResendResponse):

| Field | Type | Description |
|---|---|---|
| `status` | string |  |
| `delivery` | object |  |
| `message` | string |  |

---

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