# `POST /v1/signups`

هذه هي **الوحدة الرئيسية القابلة للقياس** في DZBuild API. كل اشتراك يصل عبرها يُحتسب، و`GET /v1/usage` يعرض عدد الشهر بجانب حد `signups_per_month` الخاص بك. وفي خطة Enterprise يكون هذا الحد `-1` افتراضيًا، أي بلا حد (انظر [الحصص](https://dzbuild.com/ar/ar/api-docs/resources/quotas.md))، ولا شيء يحجب نداءً لتجاوزه هذا الحد.

مصممة لحالة استخدام محددة: **لديك موقع/تطبيق خارجي يقبل اشتراكات المستخدمين، وتريد أن يُحتسب كل واحد منها لحساب التاجر في DZBuild.** أمثلة:

* موقع WordPress للتسويق → يملأ المستخدم نموذج التسجيل → تنادي `/v1/signups`.
* تطبيق جوال يسجّل المستخدمون فيه → الباك إند يُنادي `/v1/signups`.
* صفحة هبوط على نطاق آخر → الباك إند يُنادي `/v1/signups`.

**ليست** مصممة لطلبات الواجهة (تلك تُنشئ عملاء عبر تدفق الواجهة نفسه) ولا لاشتراكات lead-magnet المعزولة (استخدم `/v1/events` لها).

## المصادقة[​](#المصادقة "رابط مباشر إلى المصادقة")

مفتاح عام + HMAC. **خادمك الخلفي يوقّع كل نداء.** انظر [المصادقة](https://dzbuild.com/ar/ar/api-docs/authentication.md) لمخطط HMAC الكامل.

أمران يعطّلان أي مفتاح جديد تمامًا

* **بوابة البرنامج التجريبي.** الـ API مُقيَّد بالبرنامج التجريبي في الإنتاج. المفتاح الذي لم تُسجّله DZBuild يُرجع `403 forbidden` `"API is in pilot mode; key not enrolled"` في كل نداء.
* **تأخير التفعيل.** المفتاح العام الجديد ليس صالحًا للاستعمال لحظة إنشائه: وإلى أن يُفعَّل لهذه النقاط ستحصل على `401 unauthorized` `"Invalid or revoked public key"`. راسل الدعم مباشرة بعد إنشائه لتفعيله. وإلغاء مفتاح عام لا يوقفه وحده: تظل النداءات الموقّعة مقبولة إلى أن يعطّله الدعم، لذا راسل الدعم كلما ألغيت مفتاحًا.

## الجسم[​](#الجسم "رابط مباشر إلى الجسم")

```
{

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

}
```

| الحقل              | النوع          | إلزامي | ملاحظات                                                                                                                                                                                                                               |
| ------------------ | -------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `email`            | string         |        | لإزالة التكرار. يُخزّن `sha256(lowercase)` فقط.                                                                                                                                                                                       |
| `phone`            | string         |        | يُخزّن `sha256(value)` فقط.                                                                                                                                                                                                           |
| `external_user_id` | string ≤ 190   |        | معرّفك الخاص للمستخدم. يُخزَّن لكنه لا يُستعمل لإزالة التكرار، ولا تُرجعه أي نقطة.                                                                                                                                                    |
| `source`           | string ≤ 64    |        | تسمية حرة (slug صفحة، حملة…).                                                                                                                                                                                                         |
| `country`          | string (حرفان) |        | ISO 3166-1 alpha-2. نحوّله لأحرف كبيرة.                                                                                                                                                                                               |
| `ip`               | string         |        | يُخزّن مجزَّأً، لا بصيغته الصريحة أبدًا. لا يعيد لك أي فائدة يمكن قراءتها، ومع ذلك يُخرج بيانات شخصية من نظامك — اتركه.                                                                                                               |
| `meta`             | object         |        | أي شيء آخر. يُخزّن JSON.                                                                                                                                                                                                              |
| `nonce`            | 32-hex string  | ✅     | لاستخدام واحد، إلى الأبد. انظر [قواعد إزالة التكرار](#%D9%82%D9%88%D8%A7%D8%B9%D8%AF-%D8%A5%D8%B2%D8%A7%D9%84%D8%A9-%D8%A7%D9%84%D8%AA%D9%83%D8%B1%D8%A7%D8%B1) — نافذة الساعة هي الحارس الخارجي فقط؛ فالـ nonce لا يصلح مرتين أبدًا. |

`nonce` هو الحقل الوحيد الذي تحتاجه المنصة، وإن خلا الجسم منه تُستعمل قيمة ترويسة `X-DZ-Nonce`. وحين ترسله في الجسم، استعمل نفس قيمة الترويسة. والجسم الخالي من أي معرّف يبقى محسوبًا قابلًا للفوترة.

أرسل `email`. فبدونه لا يمكن أن تنطبق إزالة التكرار مدى الحياة لكل بريد الموصوفة أدناه، فتُحسب إعادة الإرسال العرضية لنفس المستخدم مرتين.

كما يجب أن يحمل كل `POST` ترويسة **`Idempotency-Key`** (بحد أقصى 64 حرفًا، من مجموعة المحارف `[A-Za-z0-9_-:.]`). بدونها تحصل على `400 bad_request` ولا يدخل أي شيء الطابور. والـ nonce ذو الـ 32 خانة ست عشرية الذي تولّده أصلًا قيمة صالحة — أعد استعماله.

## الاستجابة 202 Accepted[​](#الاستجابة-202-accepted "رابط مباشر إلى الاستجابة 202 Accepted")

```
{

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

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

}
```

`202` تعني "قبلناه وأدخلناه طابور الكتابة". ويكتمل التخزين خلال 5 ثوانٍ تقريبًا. **لا** تنتظرها. إن احتجت تأكيدًا فسجّل webhook لـ `signup.counted`. وهو ليس فوريًا: يُرسَل بعد تخزين الاشتراك، ولا يُرسَل إلا للاشتراكات المحتسبة (لا المكرَّرة)، ويحمل `key_id` و `source` و `country`، لا معرّفات المستخدم.

## قواعد إزالة التكرار[​](#قواعد-إزالة-التكرار "رابط مباشر إلى قواعد إزالة التكرار")

قاعدتان، كلتاهما تُطبَّق لكل متجر:

1. **احتساب واحد لكل nonce، مدى الحياة** — يحمي من الإعادة العرضية لنفس النداء. (يُرفض الـ nonce المعاد استعماله رفضًا صريحًا لمدة ساعة بـ `401 "Nonce reused"`؛ وبعدها يُقبل بـ `202` ثم يُسقَط بصمت، لأن الـ nonce يُتذكَّر دائمًا.)
2. **احتساب واحد لكل بريد، مدى الحياة** — ولا ينطبق إلا حين ترسل `email`.

حين يصطدم اشتراك بأي من القاعدتين، **لا يُخزَّن شيء**. وما تراه بدلًا من ذلك هو `usage.signup.total` يزيد بمقدار 1 بينما يبقى `usage.signup.billable` ثابتًا. أي أن المكرَّرات **لا تُحاسَب**، لكنها كذلك ليست مسجّلة إفراديًا: لا يوجد ما تبحث عن المكرَّر به.

يمكنك استنتاج المكرَّرات من `GET /v1/usage/history` بحساب `count − billable_count` لـ `endpoint_group = "signup"`. تُرجع تلك النقطة تجميعات ساعية تحت `data.rows` (`period_hour` و `endpoint_group` و `count` و `billable_count`)، وتفترض آخر **7 أيام** افتراضيًا، وترفض أي نافذة أوسع من 90 يومًا بـ `400 bad_request "range too large (max 90 days)"`. استدعِها بمفتاح منصة: فالمفاتيح العامة لا تحمل صلاحية `usage:read`.

الفائدة العملية: قصة Idempotency لديك تلقائية، ما دمت ترسل `email`.

## مثال عملي: Node.js[​](#مثال-عملي-nodejs "رابط مباشر إلى مثال عملي: 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,        // إلزامية في كل 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();

}
```

## مثال عملي: PHP (داخل hook لـ WordPress)[​](#مثال-عملي-php-داخل-hook-لـ-wordpress "رابط مباشر إلى مثال عملي: 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",   // إلزامية في كل POST

            'Content-Type: application/json',

        ],

        CURLOPT_RETURNTRANSFER => true,

    ]);

    curl_exec($ch);

    curl_close($ch);

}
```

## مثال عملي: Python (Django signal)[​](#مثال-عملي-python-django-signal "رابط مباشر إلى مثال عملي: 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'],   # إلزامية في كل POST

        })
```

## الأخطاء[​](#الأخطاء "رابط مباشر إلى الأخطاء")

تعود أخطاء التحقق في \~10 مللي ثانية، قبل أن يدخل أي شيء الطابور. وهي لا تشمل إلا الترويسات والتوقيع وصياغة JSON. أما حقول الجسم فتُفحص بعد `202`: الجسم الذي لا تستطيع المنصة تخزينه، كـ `nonce` في الجسم ليس 32 محرفًا ست عشريًا، يُسقَط بلا خطأ.

| HTTP | الكود / الرسالة                                                         | السبب                                                                                                                                       |
| ---- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| 400  | `bad_request` "Body must be valid JSON"                                 | الجسم ليس JSON                                                                                                                              |
| 400  | `bad_request` "Idempotency-Key header is required for write requests"   | الترويسة مفقودة                                                                                                                             |
| 400  | `bad_request` "Idempotency-Key must be `<=64` chars, `[A-Za-z0-9_-:.]`" | أطول من اللازم، أو يستعمل محارف خارج تلك المجموعة (محارف base64 وهي `+` و `/` و `=` مرفوضة)                                                 |
| 401  | `unauthorized` "Missing signature headers"                              | غاب أحد رؤوس X-DZ-\*                                                                                                                        |
| 401  | `unauthorized` "Invalid timestamp"                                      | `X-DZ-Timestamp` ليس ثوانيَ كاملة (من 1 إلى 12 رقمًا). والقيمة بالمللي ثانية مثل `Date.now()` تفشل هنا                                      |
| 401  | `unauthorized` "Timestamp out of window"                                | انحراف الساعة أكثر من 5 دقائق                                                                                                               |
| 401  | `unauthorized` "Invalid nonce format"                                   | الـ nonce ليس 32 محرفًا ست عشريًا بالضبط                                                                                                    |
| 401  | `unauthorized` "Nonce reused"                                           | نفس الـ nonce مرتين خلال ساعة. وبعد ساعة يُقبل النداء، لكن المكرَّر يُسقَط بصمت                                                             |
| 401  | `unauthorized` "Signature mismatch"                                     | مدخلات HMAC خاطئة                                                                                                                           |
| 401  | `unauthorized` "Invalid or revoked public key"                          | المفتاح ملغى، أو معرّف خاطئ، أو لم يُفعَّل بعد                                                                                              |
| 403  | `forbidden` "API is in pilot mode; key not enrolled"                    | المفتاح غير مُسجَّل في البرنامج التجريبي                                                                                                    |
| 403  | `forbidden` "API access requires an active Enterprise plan"             | متجر المفتاح ليس على خطة Enterprise سارية                                                                                                   |
| 403  | `forbidden` "Missing scope: signups<!-- -->:write<!-- -->"              | ناديت بمفتاح منصة (Bearer)؛ المفاتيح العامة وحدها تحمل هذه الصلاحية (على `/v1/events` تذكر الرسالة `events:write`)                          |
| 429  | `rate_limited`                                                          | تجاوز اندفاع الدقيقة: 600 طلب في الدقيقة لكل متجر، مشتركة بين كل مفاتيح المتجر. انتظر عدد ثواني `Retry-After` ثم أعد المحاولة بـ nonce جديد |

لا يوجد `402` على هذه النقطة. `signups_per_month` يُحسب لكنه لا يُفرض أبدًا، فتجاوزه لا يُفشل أي نداء.

## أفضل الممارسات[​](#أفضل-الممارسات "رابط مباشر إلى أفضل الممارسات")

* **وقّع على خادمك، لا في المتصفح.** لا تُرسل سر التوقيع لمستخدميك.
* **ولّد nonces جديدة** بـ CSPRNG (`crypto.randomBytes`, `random.SystemRandom`, `random_bytes` في PHP). لا تعيد استخدامها أبدًا.
* **`external_user_id` لا يُستعمل لإزالة التكرار.** من حقول المستخدم لا يُستعمل لذلك إلا `email`، فإن أُرسل نفس المستخدم مجددًا بعنوان بريد مختلف يُحتسب مرة أخرى.
* **لا تعالج وتُعد المحاولة** على أخطاء 401 تلقائيًا — هي دائمة. افحص مرة وأصلح الخطأ.
* **أعد المحاولة** على `429` و 5xx بـ exponential backoff، مع nonce جديد وتوقيع جديد في كل مرة. فبمجرد أن يصحّ التوقيع يُستهلَك الـ nonce، حتى لو رُفض النداء بعدها، لذا فإعادة إرسال نفس الطلب الموقّع تُرجع `401 "Nonce reused"`.
* **اشترك في webhook `signup.counted`** إن احتجت تأكيد دخول الصف.
