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, 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.

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"
}
FieldTypeRequiredNotes
emailstringUsed for dedup. Stored as sha256(lowercase) only.
phonestringStored as sha256(value) only.
external_user_idstring ≤ 190Your own id for the user. It is stored but not used for dedup, and no endpoint returns it.
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 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:

  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)". 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.

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 "Invalid timestamp"X-DZ-Timestamp is not whole seconds (1 to 12 digits). A millisecond value such as Date.now() fails here
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
403forbidden "API access requires an active Enterprise plan"The key's store is not on an active Enterprise plan
403forbidden "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)
429rate_limitedPer-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_bytes in PHP). Never recycle.
  • external_user_id is not used for dedup. Of the user fields only email is, 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 429 and 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 returns 401 "Nonce reused".
  • Subscribe to signup.counted webhook if you need confirmation that the row landed.
This page for AI toolsView as MarkdownOpen in ChatGPTOpen in Claude