# `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](https://dzbuild.com/api-docs/resources/quotas.md)), 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](https://dzbuild.com/api-docs/authentication.md) 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":            "user@example.com",

  "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](#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.

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