Skip to main content

POST /v1/signups

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.

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"
}
FieldTypeRequiredNotes
emailstringone of email/phone/external_user_idUsed for dedup. Stored as sha256(lowercase) only.
phonestringStored as sha256(value) only.
external_user_idstring ≤ 190Your own id for the user. Useful if you don't collect email/phone.
sourcestring ≤ 64Free-form label (page slug, campaign, etc.)
countrystring (2 chars)ISO 3166-1 alpha-2. We uppercase it.
ipstringStored hashed, never in clear. It buys you nothing you can read back, and it moves personal data out of your system — leave it out.
metaobjectAnything else. Will be JSON-stored.
nonce32-hex stringSingle-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:

  1. 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 with 202 and then silently dropped, because the nonce is remembered permanently.)
  2. 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_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 — quick feedback for malformed requests.

HTTPCode / MessageCause
400bad_request "Body must be valid JSON"Body wasn't JSON
400bad_request "Idempotency-Key header is required for write requests"Header missing
400bad_request "Idempotency-Key must be <=64 chars, [A-Za-z0-9_-:.]"Too long, or uses characters outside that set (base64 +, /, = are rejected)
401unauthorized "Missing signature headers"Forgot the X-DZ-* headers
401unauthorized "Timestamp out of window"Clock drift over 5 min
401unauthorized "Invalid nonce format"Nonce isn't exactly 32 hex characters
401unauthorized "Nonce reused"Same nonce twice within 1 h. After an hour the call is accepted, but the duplicate is dropped silently
401unauthorized "Signature mismatch"HMAC inputs wrong
401unauthorized "Invalid or revoked public key"Key revoked, wrong key id, or not yet activated
403forbidden "API is in pilot mode; key not enrolled"Key isn't pilot-enrolled
429rate_limitedPer-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_bytes in PHP). Never recycle.
  • Send external_user_id even 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.counted webhook if you need confirmation that the row landed.