# Registering a webhook

API v1 does not enforce a per-store webhook limit — register as many URLs as your integration genuinely needs, and clean up the ones you stop using. (The no-code [Merchant Webhooks addon](https://dzbuild.com/api-docs/webhooks/overview.md) does cap endpoints: 1 on Unlimited, 3 on Enterprise.)

Each webhook can subscribe to a different list of events; pick one model that suits you:

* **Single endpoint, all events** — easiest for small apps. Branch on `event` in your handler.
* **Multiple endpoints, one event each** — tidier in microservice setups, but more URLs to manage.

Pilot access

API v1 webhooks need a merchant API key from a store on an active **Enterprise** plan; any other plan, or an expired subscription, gets `403 forbidden` `"API access requires an active Enterprise plan"`. Keys generated at `/dashboard/api` are already enrolled in the pilot; a key that is not enrolled gets `403 forbidden` `"API is in pilot mode; key not enrolled"`. Tokens of installed apps get `403 forbidden` `"Apps cannot use this endpoint"` on every call under `/v1/webhooks`.

## `POST /v1/webhooks` — register

**Auth:** platform key with `webhooks:write`. **Requires `Idempotency-Key`.** Unlike most writes, this response is never stored for replay because it carries the secret: a retry with the same key registers a second webhook with a new secret. If a call times out, check `GET /v1/webhooks` before you retry and delete any duplicate.

### Body

| Field    | Type               | Required | Notes                                                                                                                                                               |
| -------- | ------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `url`    | string (https URL) | ✅       | Must be `https://`; an `http://` URL is refused. Maximum length is 500 characters; a longer URL comes back as `500 server_error` rather than as a validation error. |
| `events` | string\[]          | ✅       | List of event names. See [Event catalog](https://dzbuild.com/api-docs/webhooks/events.md) for the allowed values. Empty = error.                                    |

### Request

```
curl -X POST 'https://api.dzbuild.app/v1/webhooks' \

  -H "Authorization: Bearer $DZ_KEY" \

  -H "Content-Type: application/json" \

  -H "Idempotency-Key: $(uuidgen)" \

  -d '{

    "url":    "https://yourapp.example/webhooks/dzbuild",

    "events": ["order.created", "order.confirmed", "order.shipped",

               "order.cancelled", "signup.counted"]

  }'
```

### Response 200

```
{

  "data": {

    "id":     17,

    "secret": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",

    "note":   "Save the secret now — it is not retrievable after this response."

  },

  "meta": { "request_id": "...", "api_version": "v1" }

}
```

Never branch on `== 201`

The create endpoints `/v1/webhooks`, `/v1/products`, `/v1/orders`, `/v1/landing-pages` and `/v1/keys` answer **200** on success, while some others, such as `/v1/categories`, answer **201**. Branch on any 2xx instead.

**The `secret` is shown ONCE.** Save it next to the webhook id in your secrets store: every delivery to this webhook is signed with it (see [Signature](https://dzbuild.com/api-docs/webhooks/overview.md#signature)). If you lose it, delete the webhook and create a new one.

### Errors

| HTTP | Code / Message                                                          | Cause                                                                                         |
| ---- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| 400  | `bad_request` "url must be a valid http(s) URL"                         | Bad URL format                                                                                |
| 400  | `bad_request` "url must be https"                                       | An `http://` URL                                                                              |
| 400  | `bad_request` "url host must be a hostname, not an IP literal"          | An IP address instead of a host name                                                          |
| 400  | `bad_request` "url host is missing or carries credentials"              | A `user:password@` part in the URL                                                            |
| 400  | `bad_request` "url host must be a public hostname"                      | A host that is not a full domain name, such as `localhost`                                    |
| 400  | `bad_request` "url host does not resolve"                               | The host name has no A or AAAA record                                                         |
| 400  | `bad_request` "url must resolve to a public address"                    | The host resolves to at least one private or reserved address                                 |
| 400  | `bad_request` "url port must be 80 or 443"                              | A custom port such as `:8443`                                                                 |
| 400  | `bad_request` "Body must be valid JSON"                                 | The request body is not valid JSON                                                            |
| 400  | `bad_request` "events must be a non-empty list"                         | Empty array                                                                                   |
| 400  | `bad_request` "unknown event: foo. Allowed: …"                          | Event name not in catalog                                                                     |
| 400  | `bad_request` "Idempotency-Key header is required for write requests"   | Missing `Idempotency-Key` on POST/DELETE                                                      |
| 400  | `bad_request` "Idempotency-Key must be `<=64` chars, `[A-Za-z0-9_-:.]`" | Key too long, or contains characters outside that set (base64 `+`, `/`, `=` are all rejected) |
| 403  | `forbidden` "API is in pilot mode; key not enrolled"                    | Key exists but isn't pilot-enrolled                                                           |
| 403  | `forbidden` "API access requires an active Enterprise plan"             | The store is not on an active Enterprise plan                                                 |
| 403  | `forbidden` "Apps cannot use this endpoint"                             | The token belongs to an installed app                                                         |
| 403  | `forbidden` "Missing scope: webhooks<!-- -->:write<!-- -->"             | The key does not carry `webhooks:write`                                                       |
| 500  | `server_error` "Could not register webhook"                             | Usually a `url` longer than 500 characters                                                    |

## `GET /v1/webhooks` — list

**Auth:** platform key with `webhooks:read`.

```
curl https://api.dzbuild.app/v1/webhooks \

  -H "Authorization: Bearer $DZ_KEY"
```

```
{

  "data": {

    "items": [

      {

        "id":              17,

        "url":             "https://yourapp.example/webhooks/dzbuild",

        "events":          ["order.created", "order.confirmed"],

        "status":          "active",

        "last_success_at": "2026-04-30 21:18:23",

        "last_failure_at": null,

        "failure_count":   0,

        "created_at":      "2026-04-30 19:00:00"

      }

    ],

    "allowed_events": [

      "order.created", "order.confirmed", "order.shipped", "order.delivered",

      "order.cancelled", "order.returned", "payment.received",

      "signup.counted", "event.recorded", "product.stock_low"

    ]

  }

}
```

`allowed_events` is the list the API will accept at registration — but it is wider than what actually fires. `payment.received`, `event.recorded` and `product.stock_low` are accepted and then never emitted. Check the [Event catalog](https://dzbuild.com/api-docs/webhooks/events.md) before you build against one.

| Status   | Meaning                                                                                                                                       |
| -------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `active` | Receiving deliveries. In practice this is the only value you will ever see.                                                                   |
| `paused` | Reserved for future use — nothing sets it today, and there is no dashboard page for API v1 webhooks.                                          |
| `dead`   | Reserved for future use. There is no auto-disable; `failure_count` just keeps counting failed attempts and resets to `0` on the next success. |

To stop deliveries, delete the webhook.

## `POST /v1/webhooks/{id}/test`

Trigger a `webhook.test` delivery so you can check your endpoint is reachable and see the envelope shape.

`webhook.test` cannot be subscribed to: putting it in the `events` array at registration returns `400 bad_request` `"unknown event: webhook.test. Allowed: …"`. A test delivery is sent to the target webhook regardless of what that webhook subscribes to.

**Auth:** platform key with `webhooks:write`. **Requires `Idempotency-Key`.**

```
curl -X POST 'https://api.dzbuild.app/v1/webhooks/17/test' \

  -H "Authorization: Bearer $DZ_KEY" \

  -H "Idempotency-Key: test-17-$(date +%s)"
```

```
{ "data": { "tested": true, "note": "a webhook.test delivery was enqueued; check your endpoint" } }
```

The test delivery looks like:

```
{

  "event":       "webhook.test",

  "store_id":    13,

  "occurred_at": "2026-04-30T21:24:17+00:00",

  "data":        { "ts": 1717112657 },

  "delivery_id": "9f2c41ab77e05d18"

}
```

Two things to know about it:

* **It is queued, not synchronous.** The response only confirms the delivery was enqueued; the POST itself arrives shortly after, typically within a minute.
* **It exercises your verification code.** It is signed with your webhook's `secret` like every other delivery, so a test that passes your check proves reachability, payload shape and signature handling. See [Signature](https://dzbuild.com/api-docs/webhooks/overview.md#signature).

## `DELETE /v1/webhooks/{id}`

**Auth:** platform key with `webhooks:write`. **Requires `Idempotency-Key`.**

```
curl -X DELETE 'https://api.dzbuild.app/v1/webhooks/17' \

  -H "Authorization: Bearer $DZ_KEY" \

  -H "Idempotency-Key: del-17"
```

```
{ "data": { "deleted": true, "id": 17 } }
```

After delete:

* No new deliveries are queued.
* **Every queued delivery for that webhook is discarded immediately.** Nothing is "attempted one last time". If you care about the backlog, pause your writes and let the queue drain (usually under a minute) before deleting.
* The webhook's secret is now useless.

### Idempotency-Key replay on `test` and `DELETE`

The API caches the response for each `Idempotency-Key` for **24 hours** and replays it with an `Idempotency-Replay: 1` header. That has two consequences:

* Reusing a literal key like `del-17` within 24 hours **replays the cached response instead of performing a new call**. Use a fresh key (or one that encodes the attempt) whenever you really want the operation to run.
* Error responses are cached too. A botched write replays its own 4xx for 24 hours under the same key — change the key after you fix the request.

Replay is guaranteed for 24 hours whichever host you call, and always answers with `Idempotency-Replay: 1`. Prefer `https://api.dzbuild.app/v1` anyway: the `dzbuild.com/api/v1` path is only an alias, and some request paths can be blocked there.

## Endpoint requirements

Your webhook URL must:

* Respond with **2xx** on success. 5xx, `408` and `429` are retried after 1 min, 5 min, 30 min and 2 h, then dropped; any other 4xx, and every 3xx, sends the delivery to the dead-letter queue at once (see [Retry behaviour](https://dzbuild.com/api-docs/webhooks/overview.md#retry-behaviour)).
* Answer within **10 seconds** total (**5 seconds** to connect). Slower counts as a timeout, which is retried like a 5xx.
* Use an `https://` URL and serve valid TLS. Certificates are strictly verified, so a self-signed cert fails every attempt.
* Don't redirect. `Location` is not followed; a `301`/`302` is a failure.
* Accept `POST` with `Content-Type: application/json`.
* Read **the raw body** for the signature check (don't re-serialize).
* Be **idempotent** — same `delivery_id` MAY arrive more than once.

A common pitfall in some frameworks: middleware re-encodes the JSON body before your handler sees it, so the HMAC won't match. Solutions:

* **Express:** use `express.raw({ type: 'application/json' })` for the webhook route, then `JSON.parse(req.body)` in the handler.
* **Django:** `request.body` is the raw bytes — that's what you want.
* **Laravel:** `$request->getContent()` returns the raw body.
* **PHP raw:** `file_get_contents('php://input')`.

See [Verifying signatures](https://dzbuild.com/api-docs/webhooks/verifying-signatures.md) for full code in 4 languages.
