POST /v1/signups
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.
- 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
{
"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 |
|---|---|---|---|
email | string | Used for dedup. Stored as sha256(lowercase) only. | |
phone | string | Stored as sha256(value) only. | |
external_user_id | string ≤ 190 | Your own id for the user. It is stored but not used for dedup, and no endpoint returns it. | |
source | string ≤ 64 | Free-form label (page slug, campaign, etc.) | |
country | string (2 chars) | ISO 3166-1 alpha-2. We uppercase it. | |
ip | 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. | |
meta | object | Anything else. Will be JSON-stored. | |
nonce | 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_save
KEY_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 | bad_request "Body must be valid JSON" | Body wasn't JSON |
| 400 | bad_request "Idempotency-Key header is required for write requests" | Header missing |
| 400 | bad_request "Idempotency-Key must be <=64 chars, [A-Za-z0-9_-:.]" | Too long, or uses characters outside that set (base64 +, /, = are rejected) |
| 401 | unauthorized "Missing signature headers" | Forgot the X-DZ-* headers |
| 401 | unauthorized "Invalid timestamp" | X-DZ-Timestamp is not whole seconds (1 to 12 digits). A millisecond value such as Date.now() fails here |
| 401 | unauthorized "Timestamp out of window" | Clock drift over 5 min |
| 401 | unauthorized "Invalid nonce format" | Nonce isn't exactly 32 hex characters |
| 401 | unauthorized "Nonce reused" | Same nonce twice within 1 h. After an hour the call is accepted, but the duplicate is dropped silently |
| 401 | unauthorized "Signature mismatch" | HMAC inputs wrong |
| 401 | unauthorized "Invalid or revoked public key" | Key revoked, wrong key id, or not yet activated |
| 403 | forbidden "API is in pilot mode; key not enrolled" | Key isn't pilot-enrolled |
| 403 | forbidden "API access requires an active Enterprise plan" | The key's store is not on an active Enterprise plan |
| 403 | forbidden "Missing scope: signups:write" | You called with a platform (Bearer) key; only public keys carry this scope (on /v1/events the message names events:write) |
| 429 | rate_limited | Per-minute burst exceeded: 600 requests per minute per store, shared by all of the store's keys. Wait the Retry-After seconds, then retry with a fresh nonce |
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.