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 is pilot-gated in production. A valid key is not enough — DZBuild must enrol it in the pilot, otherwise every call returns 403 forbidden "API is in pilot mode; key not enrolled".

POST /v1/webhooks — register

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

Body

Field

Type

Required

Notes

url

string (https URL)

Must be http:// or https://. Production: always https://. 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": "fc9b5f0b51b4a93c1d6f8e29b6a2e30c7c2c44a4f2a6c8d8e0e1b9d4f6c1a8b3",
    "note":   "Save the secret now — it is not retrievable after this response."
  },
  "meta": { "request_id": "...", "api_version": "v1" }
}

💡 Tip — Never branch on == 201

No create endpoint in v1 returns 201 today — /v1/webhooks, /v1/products, /v1/orders, /v1/landing-pages and /v1/keys all answer 200 on success. Branch on any 2xx instead.

The secret is shown ONCE. Save it next to the webhook id in your secrets store. Note that it is not currently the key API v1 deliveries are signed with — see Signature — so today it is a value to keep for later rather than one you can verify with. 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 "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

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 cannot validate your verification code. Like every API v1 delivery, its signature is not derived from your webhook's secret — so it proves reachability and payload shape, nothing more. 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, it does not get the 30-second read cache, and some request paths can be blocked there.

Endpoint requirements

Your webhook URL must:

  • Respond with 2xx on success. 4xx and 3xx are one-shot failures; 5xx is re-POSTed every minute until it stops (see Retry behaviour).

  • Answer within 10 seconds total (5 seconds to connect). Slower counts as a timeout, and a timeout is not retried — the delivery is abandoned.

  • Serve valid TLS. Certificates are strictly verified, so self-signed certs fail with no retry.

  • Don't redirect. Location is not followed; a 301/302 is a failure.

  • Accept POST with Content-Type: application/json.

  • Read the raw body if you hash anything (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 sha256(body) 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?