This is the headline metered unit of the DZBuild API. Every signup that comes through here is counted, and GET /v1/usage reports the month's count next to your signups_per_month limit. On the Enterprise plan that limit defaults to -1, which means no limit (see Quotas), and nothing blocks a call for exceeding it.
It's designed for one specific use case: you have an external website / app that accepts user signups, and you want each one of those to count for your DZBuild merchant account. Examples:
A WordPress site you run for marketing → user fills your signup form → you call
/v1/signups.A mobile app where users register → app's backend calls
/v1/signups.A landing page on a different domain → backend calls
/v1/signups.
It is not designed for storefront orders (those create customers via the storefront's own flow) or for one-off lead-magnet signups (use /v1/events for those).
Auth
Public key + HMAC. Your backend signs every call. See Authentication for the full HMAC scheme.
⚠️ Warning — Two things that block a brand-new key
Pilot gate. The API is pilot-gated in production. A key DZBuild hasn't enrolled returns
403 forbidden"API is in pilot mode; key not enrolled"on every call.Activation delay. A freshly issued public key is not usable the instant it is created: until it is activated for these endpoints you get
401 unauthorized"Invalid or revoked public key". Contact support right after creating one to have it activated. Revoking a public key does not stop it on its own: signed calls keep being accepted until support disables it, so tell support whenever you revoke one.
Body
{
"email": "[email protected]",
"phone": "+213555000000",
"external_user_id": "u_42",
"source": "landing-page-1",
"country": "DZ",
"ip": "203.0.113.42",
"meta": { "campaign": "spring-2026" },
"nonce": "32-hex-single-use"
}
Field | Type | Required | Notes |
| string | Used for dedup. Stored as | |
| string | Stored as | |
| string ≤ 190 | Your own id for the user. It is stored but not used for dedup, and no endpoint returns it. | |
| string ≤ 64 | Free-form label (page slug, campaign, etc.) | |
| string (2 chars) | ISO 3166-1 alpha-2. We uppercase it. | |
| string | Stored hashed, never in clear. It buys you nothing you can read back, and it moves personal data out of your system — leave it out. | |
| object | Anything else. Will be JSON-stored. | |
| 32-hex string | ✅ | Single-use, forever. See Deduplication rules — the 1-hour rejection window is only the outer guard; a nonce is never usable twice. |
nonce is the only field the platform needs, and when the body has none, the X-DZ-Nonce header value is used. When you send it in the body, use the same value as the header. A body with no identifier at all is still counted as billable.
Send email. Without it the lifetime per-email dedup described below cannot apply, so an accidental re-submission of the same user counts twice.
Every POST must also carry an Idempotency-Key header (≤ 64 characters, charset [A-Za-z0-9_-:.]). Without it you get 400 bad_request and nothing is queued. The 32-hex nonce you already generate is a valid value — reuse it.
Response 202 Accepted
{
"data": { "status": "queued", "kind": "signup", "store_id": 13 },
"meta": { "request_id": "...", "api_version": "v1", "edge": true }
}
The 202 means "we accepted it and queued it for persistence". Storage completes within about 5 seconds. You don't wait for it. If you need confirmation, register a webhook for signup.counted. It is not instant: it is sent after the signup is stored, only for signups that were counted (not duplicates), and it carries key_id, source and country, not the user's identifiers.
Deduplication rules
Two rules, both applied per store:
One count per nonce, for life — protects against accidental replays of the same call. (A reused nonce is rejected outright for 1 hour with
401 "Nonce reused"; after that it is accepted with202and then silently dropped, because the nonce is remembered permanently.)One count per email address, for life — Only applies when you send
email.
When a signup hits either rule, nothing is stored. What you see instead is usage.signup.total incrementing by 1 while usage.signup.billable stays flat. So duplicates do not bill, but they are not recorded individually either: there is nothing to look the duplicate up by.
You can infer duplicates from GET /v1/usage/history as count − billable_count for endpoint_group = "signup". That endpoint returns hourly aggregates under data.rows (period_hour, endpoint_group, count, billable_count), defaults to the last 7 days, and rejects any window wider than 90 days with 400 bad_request "range too large (max 90 days)". Call it with a platform key: public keys do not carry the usage:read scope.
The practical upside: your idempotency story is automatic, as long as you send email.
Worked example: Node.js
import crypto from 'node:crypto';const KEY_ID = process.env.DZ_PUBLIC_KEY;
const SECRET = process.env.DZ_SIGNING_SECRET;export async function trackSignup({ email, phone, external_user_id, source, country, meta }) {
const nonce = crypto.randomBytes(16).toString('hex');
const ts = Math.floor(Date.now() / 1000).toString();
const body = JSON.stringify({ email, phone, external_user_id, source, country, meta, nonce }); const bodyHash = crypto.createHash('sha256').update(body).digest('hex');
const payload = `${KEY_ID}\n${nonce}\n${ts}\n${bodyHash}`;
const sig = crypto.createHmac('sha256', SECRET).update(payload).digest('hex'); const r = await fetch('https://api.dzbuild.app/v1/signups', {
method: 'POST',
headers: {
'Authorization': `DZ-Public ${KEY_ID}`,
'X-DZ-Timestamp': ts,
'X-DZ-Nonce': nonce,
'X-DZ-Signature': sig,
'Idempotency-Key': nonce, // required on every POST
'Content-Type': 'application/json',
},
body,
});
if (!r.ok) {
const err = await r.json();
throw new Error(`signup failed: ${err.error?.code} ${err.error?.message}`);
}
return r.json();
}
Worked example: PHP (e.g. inside a WordPress hook)
<?php
add_action('user_register', function($user_id) {
$user = get_userdata($user_id);
dz_track_signup([
'email' => $user->user_email,
'external_user_id' => "wp_{$user_id}",
'source' => 'wordpress-' . get_bloginfo('name'),
]);
});function dz_track_signup(array $payload): void {
$keyId = getenv('DZ_PUBLIC_KEY');
$secret = getenv('DZ_SIGNING_SECRET');
$nonce = bin2hex(random_bytes(16));
$ts = (string) time();
$payload['nonce'] = $nonce;
$body = json_encode($payload, JSON_UNESCAPED_UNICODE);
$hash = hash('sha256', $body);
$sig = hash_hmac('sha256', "$keyId\n$nonce\n$ts\n$hash", $secret); $ch = curl_init('https://api.dzbuild.app/v1/signups');
curl_setopt_array($ch, [
CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body,
CURLOPT_TIMEOUT => 5,
CURLOPT_HTTPHEADER => [
"Authorization: DZ-Public $keyId",
"X-DZ-Timestamp: $ts",
"X-DZ-Nonce: $nonce",
"X-DZ-Signature: $sig",
"Idempotency-Key: $nonce", // required on every POST
'Content-Type: application/json',
],
CURLOPT_RETURNTRANSFER => true,
]);
curl_exec($ch);
curl_close($ch);
}
Worked example: Python (Django signal)
import hashlib, hmac, json, os, secrets, time, requests
from django.dispatch import receiver
from django.contrib.auth.models import User
from django.db.models.signals import post_saveKEY_ID = os.environ['DZ_PUBLIC_KEY']
SECRET = os.environ['DZ_SIGNING_SECRET']@receiver(post_save, sender=User)
def track_dz_signup(sender, instance, created, **kw):
if not created: return
payload = {
'email': instance.email, 'external_user_id': str(instance.pk),
'source': 'django-app', 'nonce': secrets.token_hex(16),
}
body = json.dumps(payload)
ts = str(int(time.time()))
h = hashlib.sha256(body.encode()).hexdigest()
sig = hmac.new(SECRET.encode(),
f"{KEY_ID}\n{payload['nonce']}\n{ts}\n{h}".encode(),
hashlib.sha256).hexdigest()
requests.post('https://api.dzbuild.app/v1/signups', data=body, timeout=5,
headers={
'Authorization': f'DZ-Public {KEY_ID}',
'X-DZ-Timestamp': ts, 'X-DZ-Nonce': payload['nonce'],
'X-DZ-Signature': sig, 'Content-Type': 'application/json',
'Idempotency-Key': payload['nonce'], # required on every POST
})
Errors
Validation errors come back in ~10 ms, before anything is queued. They cover the headers, the signature and JSON syntax only. Body fields are checked after the 202: a body the platform cannot store, for example a body nonce that is not 32 hex characters, is dropped without an error.
HTTP | Code / Message | Cause |
400 |
| Body wasn't JSON |
400 |
| Header missing |
400 |
| Too long, or uses characters outside that set (base64 |
401 |
| Forgot the X-DZ-* headers |
401 |
|
|
401 |
| Clock drift over 5 min |
401 |
| Nonce isn't exactly 32 hex characters |
401 |
| Same nonce twice within 1 h. After an hour the call is accepted, but the duplicate is dropped silently |
401 |
| HMAC inputs wrong |
401 |
| Key revoked, wrong key id, or not yet activated |
403 |
| Key isn't pilot-enrolled |
403 |
| The key's store is not on an active Enterprise plan |
403 |
| You called with a platform (Bearer) key; only public keys carry this scope (on |
429 |
| Per-minute burst exceeded: 600 requests per minute per store, shared by all of the store's keys. Wait the |
There is no 402 on this endpoint. signups_per_month is counted but never enforced, so exceeding it does not fail a call.
Best practices
Sign on your backend, not in the browser. Don't ship the signing secret to your users.
Generate fresh nonces with a CSPRNG (
crypto.randomBytes,random.SystemRandom,random_bytesin PHP). Never recycle.external_user_idis not used for dedup. Of the user fields onlyemailis, so the same user sent again under a different email address counts again.Don't catch and retry on 401 errors automatically — they're permanent. Inspect once, fix the bug.
Do retry on
429and 5xx with exponential backoff, using a fresh nonce and a new signature each time. Once the signature checks out the nonce is spent, even if the call is then refused, so resending the same signed request returns401 "Nonce reused".Subscribe to
signup.countedwebhook if you need confirmation that the row landed.