# `POST /v1/signups`

C'est l'**unité métrée principale** de l'API DZBuild. Chaque inscription qui passe par ici est comptée, et `GET /v1/usage` rapporte le total du mois à côté de votre limite `signups_per_month`. Sur le plan Enterprise, cette limite vaut `-1` par défaut, ce qui signifie aucune limite (voir [Quotas](https://dzbuild.com/fr/fr/api-docs/resources/quotas.md)), et rien ne bloque un appel qui la dépasse.

Conçu pour un cas précis : **vous avez un site/app externe qui accepte des inscriptions, et vous voulez qu'elles comptent pour votre compte marchand DZBuild.** Exemples :

* Un site WordPress que vous gérez pour le marketing → l'utilisateur remplit le formulaire → vous appelez `/v1/signups`.
* Une app mobile où les utilisateurs s'inscrivent → backend appelle `/v1/signups`.
* Une landing page sur un autre domaine → backend appelle `/v1/signups`.

**Pas conçu** pour les commandes vitrine (qui créent des clients via le flow vitrine) ni pour des inscriptions ponctuelles type lead-magnet (utilisez `/v1/events`).

## Auth[​](#auth "Lien direct vers Auth")

Clé publique + HMAC. **Votre backend signe chaque appel.** Voir [Authentification](https://dzbuild.com/fr/fr/api-docs/authentication.md) pour le schéma HMAC complet.

Deux choses qui bloquent une clé toute neuve

* **Barrière pilote.** L'API est réservée au pilote en production. Une clé que DZBuild n'a pas inscrite renvoie `403 forbidden` `"API is in pilot mode; key not enrolled"` à chaque appel.
* **Délai d'activation.** Une clé publique fraîchement émise n'est pas utilisable à l'instant où elle est créée : tant qu'elle n'est pas activée pour ces endpoints, vous obtenez `401 unauthorized` `"Invalid or revoked public key"`. Contactez le support juste après en avoir créé une pour la faire activer. Révoquer une clé publique ne l'arrête pas à lui seul : les appels signés continuent d'être acceptés jusqu'à ce que le support la désactive, donc prévenez le support chaque fois que vous en révoquez une.

## Corps[​](#corps "Lien direct vers Corps")

```
{

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

}
```

| Champ              | Type             | Requis | Notes                                                                                                                                                                                               |
| ------------------ | ---------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `email`            | string           |        | Utilisé pour dédup. Stocké comme `sha256(lowercase)` uniquement.                                                                                                                                    |
| `phone`            | string           |        | Stocké comme `sha256(value)` uniquement.                                                                                                                                                            |
| `external_user_id` | string ≤ 190     |        | Votre ID interne pour l'utilisateur. Il est stocké mais ne sert pas à la dédup, et aucun endpoint ne le renvoie.                                                                                    |
| `source`           | string ≤ 64      |        | Libellé libre (slug page, campagne…).                                                                                                                                                               |
| `country`          | string (2 chars) |        | ISO 3166-1 alpha-2. On uppercase.                                                                                                                                                                   |
| `ip`               | string           |        | Stocké haché, jamais en clair. Il ne vous rapporte rien que vous puissiez relire et fait quand même sortir des données personnelles de votre système — ne l'envoyez pas.                            |
| `meta`             | object           |        | Tout le reste. Stocké en JSON.                                                                                                                                                                      |
| `nonce`            | 32-hex string    | ✅     | Usage unique, pour toujours. Voir [Règles de déduplication](#r%C3%A8gles-de-d%C3%A9duplication) — la fenêtre d'1 heure n'est que le garde-fou externe ; un nonce n'est jamais utilisable deux fois. |

`nonce` est le seul champ dont la plateforme a besoin, et si le body n'en contient pas, la valeur de l'en-tête `X-DZ-Nonce` est utilisée. Si vous l'envoyez dans le body, utilisez la même valeur que l'en-tête. Un body sans aucun identifiant reste compté comme facturable.

Envoyez `email`. Sans lui, la dédup à vie par email décrite plus bas ne peut pas s'appliquer : une re-soumission accidentelle du même utilisateur compte alors deux fois.

Chaque `POST` doit aussi porter un en-tête **`Idempotency-Key`** (≤ 64 caractères, charset `[A-Za-z0-9_-:.]`). Sans lui, vous obtenez `400 bad_request` et rien n'est mis en file. Le nonce de 32 caractères hex que vous générez déjà est une valeur valide — réutilisez-le.

## Réponse 202 Accepted[​](#réponse-202-accepted "Lien direct vers Réponse 202 Accepted")

```
{

  "data": { "status": "queued", "kind": "signup", "store_id": 13 },

  "meta": { "request_id": "...", "api_version": "v1", "edge": true }

}
```

Le `202` signifie « accepté et mis en file pour persistance ». Le stockage se termine sous environ 5 secondes. Vous **n'attendez pas**. Si vous avez besoin d'une confirmation, enregistrez un webhook `signup.counted`. Il n'est pas instantané : il part après le stockage de l'inscription, seulement pour les inscriptions comptées (pas les doublons), et il porte `key_id`, `source` et `country`, pas les identifiants de l'utilisateur.

## Règles de déduplication[​](#règles-de-déduplication "Lien direct vers Règles de déduplication")

Deux règles, toutes deux appliquées par boutique :

1. **Un comptage par nonce, à vie** — protège des rejeux accidentels du même appel. (Un nonce réutilisé est rejeté d'emblée pendant 1 heure avec `401 "Nonce reused"` ; ensuite il est accepté avec `202` puis écarté silencieusement, car le nonce est mémorisé définitivement.)
2. **Un comptage par adresse email, à vie** — Ne s'applique que si vous envoyez `email`.

Quand une inscription touche l'une des règles, **rien n'est stocké**. Ce que vous voyez à la place, c'est `usage.signup.total` qui augmente de 1 pendant que `usage.signup.billable` reste plat. Les doublons **ne facturent donc pas**, mais ils ne sont pas non plus enregistrés individuellement : il n'y a rien pour retrouver le doublon.

Vous pouvez déduire les doublons depuis `GET /v1/usage/history` en calculant `count − billable_count` pour `endpoint_group = "signup"`. Cet endpoint renvoie des agrégats horaires sous `data.rows` (`period_hour`, `endpoint_group`, `count`, `billable_count`), porte par défaut sur les **7 derniers jours**, et rejette toute fenêtre de plus de 90 jours avec `400 bad_request "range too large (max 90 days)"`. Appelez-le avec une clé plateforme : les clés publiques n'ont pas le scope `usage:read`.

Avantage pratique : votre histoire d'idempotence est automatique, tant que vous envoyez `email`.

## Exemple : Node.js[​](#exemple--nodejs "Lien direct vers Exemple : 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,        // obligatoire sur chaque 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();

}
```

## Exemple : PHP (hook WordPress)[​](#exemple--php-hook-wordpress "Lien direct vers Exemple : PHP (hook WordPress)")

```
<?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",   // obligatoire sur chaque POST

            'Content-Type: application/json',

        ],

        CURLOPT_RETURNTRANSFER => true,

    ]);

    curl_exec($ch);

    curl_close($ch);

}
```

## Exemple : Python (signal Django)[​](#exemple--python-signal-django "Lien direct vers Exemple : Python (signal Django)")

```
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'],   # obligatoire sur chaque POST

        })
```

## Erreurs[​](#erreurs "Lien direct vers Erreurs")

Les erreurs de validation reviennent en \~10 ms, avant toute mise en file. Elles ne couvrent que les en-têtes, la signature et la syntaxe JSON. Les champs du body sont vérifiés après le `202` : un body que la plateforme ne peut pas stocker, par exemple un `nonce` de body qui ne fait pas 32 caractères hex, est jeté sans erreur.

| HTTP | Code / Message                                                          | Cause                                                                                                                                                                                                |
| ---- | ----------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 400  | `bad_request` "Body must be valid JSON"                                 | Body non-JSON                                                                                                                                                                                        |
| 400  | `bad_request` "Idempotency-Key header is required for write requests"   | En-tête manquant                                                                                                                                                                                     |
| 400  | `bad_request` "Idempotency-Key must be `<=64` chars, `[A-Za-z0-9_-:.]`" | Trop long, ou utilise des caractères hors de cet ensemble (les `+`, `/`, `=` du base64 sont rejetés)                                                                                                 |
| 401  | `unauthorized` "Missing signature headers"                              | En-têtes X-DZ-\* manquants                                                                                                                                                                           |
| 401  | `unauthorized` "Invalid timestamp"                                      | `X-DZ-Timestamp` n'est pas en secondes entières (1 à 12 chiffres). Une valeur en millisecondes comme `Date.now()` échoue ici                                                                         |
| 401  | `unauthorized` "Timestamp out of window"                                | Dérive d'horloge de plus de 5 min                                                                                                                                                                    |
| 401  | `unauthorized` "Invalid nonce format"                                   | Le nonce ne fait pas exactement 32 caractères hex                                                                                                                                                    |
| 401  | `unauthorized` "Nonce reused"                                           | Même nonce deux fois en 1 h. Après une heure l'appel est accepté, mais le doublon est jeté silencieusement                                                                                           |
| 401  | `unauthorized` "Signature mismatch"                                     | Inputs HMAC erronés                                                                                                                                                                                  |
| 401  | `unauthorized` "Invalid or revoked public key"                          | Clé révoquée, mauvais key id, ou pas encore activée                                                                                                                                                  |
| 403  | `forbidden` "API is in pilot mode; key not enrolled"                    | Clé non inscrite au pilote                                                                                                                                                                           |
| 403  | `forbidden` "API access requires an active Enterprise plan"             | La boutique de la clé n'est pas sur un plan Enterprise actif                                                                                                                                         |
| 403  | `forbidden` "Missing scope: signups<!-- -->:write<!-- -->"              | Vous avez appelé avec une clé plateforme (Bearer) ; seules les clés publiques portent ce scope (sur `/v1/events`, le message nomme `events:write`)                                                   |
| 429  | `rate_limited`                                                          | Burst par minute dépassé : 600 requêtes par minute par boutique, partagées par toutes les clés de la boutique. Attendez les secondes indiquées par `Retry-After`, puis réessayez avec un nonce frais |

Il n'y a pas de `402` sur cet endpoint. `signups_per_month` est compté mais jamais appliqué : le dépasser ne fait échouer aucun appel.

## Bonnes pratiques[​](#bonnes-pratiques "Lien direct vers Bonnes pratiques")

* **Signez côté backend, pas dans le navigateur.** N'envoyez pas le secret aux utilisateurs.
* **Générez des nonces frais** avec un CSPRNG (`crypto.randomBytes`, `random.SystemRandom`, `random_bytes` en PHP). Jamais de recyclage.
* **`external_user_id` ne sert pas à la dédup.** Parmi les champs utilisateur, seul `email` y sert : le même utilisateur renvoyé avec une autre adresse email compte une nouvelle fois.
* **Ne retry pas** automatiquement sur 401 — c'est permanent. Inspectez une fois, fixez le bug.
* **Retry** sur `429` et 5xx avec exponential backoff, avec un nonce frais et une nouvelle signature à chaque fois. Dès que la signature est validée, le nonce est consommé, même si l'appel est ensuite refusé : renvoyer la même requête signée renvoie `401 "Nonce reused"`.
* **Abonnez-vous au webhook `signup.counted`** si besoin de confirmation que la ligne a atterri.
