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
eventin 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 |
| string (https URL) | ✅ | Must be |
| 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 URL format |
400 |
| An |
400 |
| An IP address instead of a host name |
400 |
| A |
400 |
| A host that is not a full domain name, such as |
400 |
| The host name has no A or AAAA record |
400 |
| The host resolves to at least one private or reserved address |
400 |
| A custom port such as |
400 |
| The request body is not valid JSON |
400 |
| Empty array |
400 |
| Event name not in catalog |
400 |
| Missing |
400 |
| Key too long, or contains characters outside that set (base64 |
403 |
| Key exists but isn't pilot-enrolled |
403 |
| The store is not on an active Enterprise plan |
403 |
| The token belongs to an installed app |
403 |
| The key does not carry |
500 |
| Usually a |
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 |
| Receiving deliveries. In practice this is the only value you will ever see. |
| Reserved for future use — nothing sets it today, and there is no dashboard page for API v1 webhooks. |
| Reserved for future use. There is no auto-disable; |
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
secretlike 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-17within 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,
408and429are 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.
Locationis not followed; a301/302is a failure.Accept
POSTwithContent-Type: application/json.Read the raw body for the signature check (don't re-serialize).
Be idempotent — same
delivery_idMAY 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, thenJSON.parse(req.body)in the handler.Django:
request.bodyis 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.