Skip to main content

Signups

POST /v1/signups — count an end-user signup against your monthly quota. The headline metered unit. HMAC-signed, accepted asynchronously, deduplicated by nonce + email.

Written by Support

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

email

string

one of email/phone/external_user_id

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. Useful if you don't collect email/phone.

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 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_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

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

429

rate_limited

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

Did this answer your question?