This is the headline metered unit of the DZBuild API. Every signup that comes through here is counted against your tier's signups_per_month figure and contributes to platform pricing for partner integrations. Counting is real; enforcement is not — the limit is reported by GET /v1/usage, but nothing currently 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". Revocation is not instant either: if you need a key stopped immediately, ask support.
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 | one of email/phone/external_user_id | Used for dedup. Stored as |
| string | Stored as | |
| string ≤ 190 | Your own id for the user. Useful if you don't collect email/phone. | |
| 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 strictly required field, but 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 immediate confirmation, register a webhook for signup.counted.
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)".
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 — quick feedback for malformed requests.
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 |
| 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 |
429 |
| Per-minute burst exceeded. Caps follow the store's plan: free 60, pro 120, unlimited 300, enterprise 600 req/min |
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.Send
external_user_ideven when you have email — it survives email changes.Don't catch and retry on 401 errors automatically — they're permanent. Inspect once, fix the bug.
Do retry on 5xx with exponential backoff using a fresh nonce each time.
Subscribe to
signup.countedwebhook if you need confirmation that the row landed.