Skip to main content

Registering a webhook

Create, list, test, and delete webhook subscriptions for your store.

Written by Support

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

⚠️ Warning — 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 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" }
}

💡 Tip — 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). 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 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.

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

  • 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 for full code in 4 languages.

Did this answer your question?