اتومی OpenAPI

Otomi API — راهنمای برنامه‌نویس (نسخه ۱)

برای سایت یا اپی که ووکامرس نیست. اگر سایت شما وردپرس + ووکامرس است، افزونه را نصب کنید؛ اگر PrestaShop است، ماژول آماده را (در اپ: افزودن فروشگاه ← پرستاشاپ). این راهنما برای بقیه است.

آدرس API: https://my.otomi.app/api (تا راه‌اندازی سرور اصلی). شرح ماشینی همه‌ی درخواست‌ها: openapi.json (OpenAPI 3.1، از همان Schemaهایی که سرور با آن‌ها بررسی می‌کند).

۱. ده دقیقه تا اولین سفارش

  1. مدیر فروشگاه در اپ اتومی: تنظیمات ← اتصال به فروشگاه ← افزودن فروشگاه ← سایت یا اپ دیگر ← مشخصات ← ساخت کلید ← کلید آزمایشی و کلید را به شما می‌دهد.
  2. این را اجرا کنید:
curl https://my.otomi.app/api/v1/orders \
  -H "Authorization: Bearer osk_test_XXXXXXXX" \
  -H "Content-Type: application/json" \
  -d '{"id":"1023","status":"paid","total":"150000","customer":{"mobile":"09121234567"}}'
  1. پاسخ: {"ok":true,"mode":"test","events":["order.created"]} — یعنی با کلید اصلی «سفارش جدید» ثبت می‌شد. همین درخواست در اپ، زیر درخواست‌های اخیر دیده می‌شود.
  2. وقتی همه‌چیز درست بود، کلید اصلی (osk_live_…) را جایگزین کنید و آدرس سایت را ثبت کنید (بخش ۴) تا اطلاعات قبلی خودکار بیاید.

کلید آزمایشی همه‌چیز را بررسی می‌کند ولی هیچ‌چیز ذخیره نمی‌کند و هیچ پیامی نمی‌فرستد.

۲. تنها قانونی که باید یادتان بماند

هر بار چیزی ساخته شد یا تغییر کرد، کل آن را بفرستید.

سفارش (ثبت، پرداخت، ارسال، لغو، استرداد)، مشتری (ثبت‌نام، ورود)، محصول (قیمت، موجودی، دسته)، اشتراک (خرید، تمدید، لغو، پرداخت ناموفق) و نظرِ منتشرشده. لازم نیست بگویید «چه اتفاقی افتاد»: اتومی با نسخه‌ی قبلی مقایسه می‌کند و خودش می‌فهمد. دوباره فرستادن هیچ ضرری ندارد (پیام تکراری نمی‌رود).

۳. درخواست‌ها

همه با Authorization: Bearer <کلید> و بدنه‌ی JSON.

درخواست کار
GET /v1/ping کلید درست است؟ نام و ارز فروشگاه را برمی‌گرداند
POST /v1/orders یک سفارش (ساخت یا به‌روزرسانی؛ id تکراری = به‌روزرسانی)
POST /v1/customers یک مشتری
POST /v1/products یک محصول
DELETE /v1/products/{id} محصولی که از سایت حذف شد
POST /v1/reviews نظری که روی سایت منتشر شد
POST /v1/subscriptions یک اشتراک
PUT /v1/hook آدرس سایت: اتومی خودش اطلاعات قبلی و هر ساعت تغییرات را می‌گیرد (بخش ۴)
POST /v1/import فقط اگر آدرس سایت ندارید: داده‌ی قدیمی، تا ۵۰۰ رکورد، بدون پیام

سفارش

فیلد اجباری توضیح مثال
id ✔ شناسه‌ی سفارش در سیستم شما: حرف، رقم، _ . -، تا ۶۴ "SF-1023"
status ✔ یکی از: pending_payment paid processing shipped delivered completed cancelled refunded "paid"
total ✔ مبلغ به ارزی که هنگام اتصال انتخاب شد (تومان یا ریال)؛ عدد یا متن "150000"
customer ✔ دست‌کم mobile یا email (جدول مشتری)
paid اگر نفرستید از status حساب می‌شود true
refunded جمع مبلغ مسترد تا الان "50000"
discountTotal جمع تخفیف این سفارش "20000"
coupons کدهای تخفیف استفاده‌شده ["YALDA"]
items [{ "productId": "55", "quantity": 1 }] — همان id محصول در /products
paymentMethod یک کلمه، بی‌فاصله؛ در اپ برای شرط «روش پرداخت» فهرست می‌شود "zarinpal"
shippingMethod مثل بالا؛ با آن گزینه‌های ارسال در اپ روشن می‌شود "post"
source خریدار از کجا آمد (utm_source) "instagram"
currency اگر بفرستید باید ارز فروشگاه باشد: IRT یا IRR "IRT"
createdAt، paidAt، updatedAt ISO 8601 با منطقه‌ی زمانی. updatedAt = آخرین تغییر در سیستم شما "2026-10-24T10:15:00+03:30"
subscription سفارشی که اشتراک فروخته: { "plan": "monthly", "periodEnd": "…" }

مشتری (داخل سفارش و در /customers)

فیلد توضیح
id شناسه‌ی کاربر در سیستم شما (در سفارش مهمان نفرستید؛ در /customers اجباری)
mobile هر شکلی: 09121234567، +989121234567، با ارقام فارسی
email، firstName، lastName، city، province
registeredAt فقط در /customers: زمان ثبت‌نام
lastLoginAt فقط در /customers: آخرین ورود. هر بار دیرتر شود، اتوماسیون «ورود مشتری» اجرا می‌شود
loginCount فقط در /customers: تعداد ورودها تا الان (۱ = خود ثبت‌نام)
source فقط در /customers: حساب از کجا ساخته شد (utm_source)

محصول (/products)

فیلد اجباری توضیح مثال
id ✔ شناسه‌ی محصول در سیستم شما "font-12"
name ✔ "فونت ایران‌سنس"
url صفحه‌ی محصول؛ باید روی دامنه‌ی خود سایت باشد (پیام‌ها فقط به سایت شما لینک می‌دهند)
price قیمت بدون حراج 450000
salePrice قیمت حراج، وقتی حراج است 390000
stock موجودی؛ اگر موجودی نگه نمی‌دارید نفرستید. با آن «هشدار موجودی کم» و گزارش انبار روشن می‌شود 3
active false = پنهان یا پیش‌نویس true
categories [{ "id": "fonts", "name": "فونت‌ها", "url": "https://…/c/fonts" }] — url برای لینک به دسته
attributes [{ "name": "رنگ", "value": "قرمز" }]
updatedAt آخرین تغییر

تغییر قیمت یا موجودی محصولی که قبلاً فرستاده‌اید خودش رویداد می‌شود. حذف: DELETE /v1/products/font-12.

نظر (/reviews)

وقتی نظری روی سایت منتشر شد بفرستید. فقط نظرِ کمتر از یک روز اتوماسیون «ثبت نظر» را اجرا می‌کند.

فیلد اجباری توضیح
id ✔ شناسه‌ی نظر
productId ✔ همان id محصول
rating ۱ تا ۵؛ بدون ستاره نفرستید
customer ✔ id، mobile یا email — پیام به نویسنده فقط اگر مشتری شناخته‌شده باشد
createdAt

اشتراک (/subscriptions)

برای اپ یا سایتی که اشتراک می‌فروشد. اتومی از روی آن «یادآوری تمدید»، «پرداخت تمدید ناموفق»، «تشکر از تمدید» و «برگشت بعد از تمام شدن» را می‌فرستد.

فیلد اجباری توضیح مثال
id ✔ شناسه‌ی اشتراک "sub_77"
customer ✔ مثل مشتری سفارش
plan ✔ نام یا شناسه‌ی پلن "یک‌ساله"
status ✔ trialing active past_due cancelled expired "active"
currentPeriodEnd ✔ تا کی پرداخت شده "2027-10-24T10:00:00+03:30"
startedAt، autoRenew، price، currency، updatedAt
  • تمدید = currentPeriodEnd دیرتر شد. تمام شدن: اگر پایان گذشت و تمدیدی نرسید، اتومی خودش یک روز بعد آن را تمام‌شده حساب می‌کند.
  • راه ساده‌تر: اگر «اشتراک» جدا ندارید، داخل سفارش subscription بفرستید؛ سفارش بعدیِ همان پلن تمدید حساب می‌شود.

۴. آدرس سایت: همه‌چیز خودکار (پیشنهادی)

یک آدرس روی سایت خودتان بسازید و یک بار ثبت کنید. از آن به بعد:

  • اطلاعات قبلی (مشتری‌ها، محصولات، سفارش‌ها، اشتراک‌ها) را اتومی خودش صفحه‌به‌صفحه می‌گیرد — به‌عنوان تاریخچه، بدون هیچ پیامی به مشتری‌های قدیمی.
  • هر ساعت هر چه عوض شده را می‌پرسد؛ اگر درخواستی از سایت گم شده باشد (قطعی شبکه، خطا)، همین‌جا پیدا می‌شود و اتوماسیونش اجرا می‌شود. چیزی که قبلاً رسیده بود تکرار نمی‌شود.
  • اگر بخواهید، همین آدرس کد تخفیف اختصاصی هم می‌سازد (بخش ۵).

ثبت

سایت یک کلید (ok_ + ۲۲ نویسه‌ی URL-safe) و یک راز (۳۲ بایت تصادفی، base64url = ۴۳ نویسه) می‌سازد، اول نزد خودش ذخیره می‌کند، بعد با کلید اصلی ثبت می‌کند:

curl -X PUT https://my.otomi.app/api/v1/hook \
  -H "Authorization: Bearer osk_live_..." -H "Content-Type: application/json" \
  -d '{"url":"https://shop.example/otomi","keyId":"ok_…","secret":"…43 chars…"}'
  • url باید HTTPS و روی دامنه‌ی سایتی باشد که در اپ ثبت شده (یا زیردامنه).
  • اتومی همان لحظه health و یک changes امضاشده می‌فرستد؛ فقط اگر درست جواب دهد ذخیره می‌کند. خطاها: hook_url_not_allowed، hook_unreachable، hook_bad_signature، hook_bad_reply.
  • با کلید آزمایشی امتحان می‌شود ولی ذخیره نمی‌شود. با ثبت، مدیر اعلان می‌گیرد و در اپ ردیف «دریافت خودکار» وضعیت را نشان می‌دهد. برای عوض کردن راز، دوباره بفرستید.
  • برای کد تخفیف: "coupons": { "hourlyLimit": 600, "percentCap": true } را هم بفرستید.

امضا (هر دو طرف)

هر درخواست اتومی POST با بدنه‌ی {"command": "...", "params": {...}} و چهار هدر است: X-Otomi-Key، X-Otomi-Timestamp (ثانیه‌ی یونیکس)، X-Otomi-Nonce، X-Otomi-Signature.

canonical = "OTOMI-HMAC-SHA256\nPOST\n/otomi/v1/command\n" + timestamp + "\n" + nonce + "\n" + hex(sha256(body))
signature = base64url(HMAC-SHA256(راز ۳۲ بایتی, canonical))

مسیر امضاشده همیشه /otomi/v1/command است، هر آدرسی که داشته باشید. بررسی کنید: کلید همان است، اختلاف زمان حداکثر ۵ دقیقه، امضا با مقایسه‌ی زمان‌ثابت، nonce را ۱۰ دقیقه نگه دارید و تکراری را رد کنید. هر خطا = 401 خالی.

پاسخ شما 200 و JSON است و خودش امضا می‌شود: همان چهار هدر، با RESPONSE به‌جای متد و nonce درخواست به‌جای مسیر — "OTOMI-HMAC-SHA256\nRESPONSE\n" + nonce درخواست + "\n" + timestamp شما + "\n" + nonce تازه‌ی شما (۱۶ تا ۶۴ نویسه) + "\n" + hex(sha256(بدنه‌ی پاسخ)). نمونه‌ی کامل در بخش ۸.

فرمان changes

params: { "kind": "customers" | "products" | "orders" | "subscriptions", "since": "2026-10-24T10:00:00Z", "limit": 100 }

پاسخ: { "items": [ … ], "hasMore": true } — رکوردهای آن نوع که updatedAtشان برابر یا بعد از since است، قدیمی‌ترین اول، حداکثر limit تا، دقیقاً به همان شکل درخواست‌های بخش ۳، هرکدام با updatedAt. اولین بار since = 1970-01-01T00:00:00.000Z است (همه‌چیز). رکوردی که سرور رد کند (مثلاً لینک خارج از سایت) کنار گذاشته می‌شود و بقیه ادامه دارد. health: هر شیء JSON، مثلاً {"ok":true}.

۵. کد تخفیف (اختیاری)

پیام‌ها کد تخفیف اختصاصی دارند اگر فروشگاه در اپ یکی از دو راه را روشن کند. کد ثابت برای همه هیچ‌کدام را لازم ندارد.

راه ۱ — بانک کد (بدون برنامه‌نویسی): مدیر فایل Excel/CSV کدهای یک‌بارمصرف را در اپ بارگذاری می‌کند. شما فقط کدهای استفاده‌شده را در coupons سفارش بفرستید.

راه ۲ — ساخت در سایت (مثل ووکامرس): آدرس سایت با coupons ثبت شود و این فرمان‌ها را هم جواب دهد. قیمت‌ها به ارز فروشگاه و رشته‌اند؛ expiresAt = زمان ISO. هر کد باید منقضی شود، با تخفیف‌های دیگر جمع نشود، و کلیدش (sendKey / key) را نگه دارد تا درخواست تکراری همان کد قبلی را برگرداند.

فرمان params پاسخ
create_coupon code، sendKey (۶۴ hex)، type (percent/fixed/free_shipping)، amount، maxAmount?، minPurchase?، excludeSaleItems، expiresAt {"ok":true,"couponId":123} یا {"ok":false,"reason":"..."}
create_coupons coupons: [{code, key}] (حداکثر ۱۰۰) + همان تخفیف {"ok":true,"made":[{code,couponId}],"taken":[code]}
create_shared_coupon code، key، automationId، تخفیف، totalLimit?، expiresAt مثل create_coupon؛ هر مشتری یک بار
extend_coupon code، key، expiresAt {"ok":true} — فقط دیرتر
revoke_coupon code {"ok":true} — فقط اگر استفاده نشده
coupon_stats — {"used":0,"unused":0,"expired":0,"purge":null}
purge_used_coupons — {"ok":true,"total":N} — در پس‌زمینه و دسته‌ای

دلیل‌های رد: invalid، limit_percent، limit_amount، limit_hourly، code_taken. سقف‌های خودتان (درصد، مبلغ، تعداد در ساعت) را همیشه در سایت نگه دارید: اگر کلیدی لو برود، تخفیف بی‌حد ساخته نمی‌شود.

۶. پاسخ‌ها و تکرار

کد معنی دوباره بفرستم؟
200 رسید. events می‌گوید چه چیزی ثبت شد ([] = چیز تازه‌ای نبود) —
400 داده ایراد دارد. issues[0].path می‌گوید کجا نه، اول درستش کنید
401 کلید اشتباه یا باطل‌شده نه
403 از مرورگر فرستاده‌اید نه
429 بیش از ۲۰ در ثانیه بله، بعد از Retry-After
5xx یا قطع شبکه بله، با فاصله‌ی بیشتر هر بار
{ "code": "validation", "message": "invalid input", "requestId": "…",
  "issues": [{ "path": ["customer"], "code": "mobile_or_email_required" }] }

فیلدی که در جدول‌ها نیست رد می‌شود (unrecognized_keys) — عمداً، تا داده‌ی شخصی اضافه به ما نرسد.

۷. امنیت

  • کلید را فقط در سرور نگه دارید (متغیر محیطی)، نه در اپ موبایل، نه در جاوااسکریپت سایت، نه در گیت. درخواستی که از مرورگر بیاید رد می‌شود.
  • کلید لو رفت؟ در اپ کلید تازه بسازید و قبلی را باطل کنید (فوری).
  • ارسال را در صف/Job انجام دهید تا با قطع شبکه چیزی گم نشود (با آدرس سایت، هر چه جا بماند هم ساعتی پیدا می‌شود).

۸. نمونه‌کد

همه‌ی نمونه‌ها یک سفارش می‌فرستند و برای 429، 5xx و قطع شبکه تا ۵ بار با فاصله‌ی ۱، ۲، ۴، ۸ ثانیه دوباره تلاش می‌کنند. برای مشتری، محصول و… فقط مسیر عوض می‌شود.

PHP

function otomi_send(string $path, array $body): array {
    for ($try = 0; $try < 5; $try++) {
        $ch = curl_init('https://my.otomi.app/api/v1/' . $path);
        curl_setopt_array($ch, [
            CURLOPT_POST => true,
            CURLOPT_HTTPHEADER => [
                'Authorization: Bearer ' . getenv('OTOMI_API_KEY'),
                'Content-Type: application/json',
            ],
            CURLOPT_POSTFIELDS => json_encode($body, JSON_UNESCAPED_UNICODE),
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT => 10,
        ]);
        $res = curl_exec($ch);
        $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);
        if ($res !== false && $status === 200) return json_decode($res, true);
        if ($res !== false && $status < 500 && $status !== 429) {
            throw new InvalidArgumentException("otomi $status: $res"); // داده را درست کنید
        }
        sleep(2 ** $try);
    }
    throw new RuntimeException('otomi unreachable'); // صف شما بعداً دوباره می‌فرستد
}

otomi_send('orders', ['id' => '1023', 'status' => 'paid', 'total' => '150000',
    'customer' => ['mobile' => '09121234567']]);

Laravel

use Illuminate\Support\Facades\Http;

class SendToOtomi implements \Illuminate\Contracts\Queue\ShouldQueue
{
    use \Illuminate\Bus\Queueable, \Illuminate\Queue\InteractsWithQueue;

    public int $tries = 5;
    public array $backoff = [1, 2, 4, 8];

    public function __construct(private string $path, private array $body) {}

    public function handle(): void
    {
        $res = Http::withToken(config('services.otomi.key'))
            ->timeout(10)
            ->post('https://my.otomi.app/api/v1/' . $this->path, $this->body);
        if ($res->status() === 429 || $res->serverError()) {
            $this->release(max(1, (int) $res->header('Retry-After')));
            return;
        }
        if ($res->clientError()) {
            $this->fail(new \RuntimeException('otomi: ' . $res->body())); // داده را درست کنید
        }
    }
}

// در Observer سفارش: هر بار ساخته یا ذخیره شد
SendToOtomi::dispatch('orders', [
    'id' => (string) $order->id, 'status' => $order->status, 'total' => (string) $order->total,
    'customer' => ['id' => (string) $order->user_id, 'mobile' => $order->mobile],
    'updatedAt' => $order->updated_at->toIso8601String(),
]);

Node.js

async function otomiSend(path, body) {
  for (let attempt = 0; attempt < 5; attempt++) {
    try {
      const res = await fetch(`https://my.otomi.app/api/v1/${path}`, {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${process.env.OTOMI_API_KEY}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(body),
        signal: AbortSignal.timeout(10_000),
      });
      if (res.ok) return res.json();
      if (res.status !== 429 && res.status < 500) {
        throw Object.assign(new Error(await res.text()), { final: true }); // داده را درست کنید
      }
    } catch (err) {
      if (err.final) throw err;
    }
    await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt));
  }
  throw new Error('otomi unreachable'); // صف شما بعداً دوباره می‌فرستد
}

await otomiSend('orders', {
  id: '1023', status: 'paid', total: '150000', customer: { mobile: '09121234567' },
});

Python / Django

import os, time, requests

def otomi_send(path: str, body: dict) -> dict:
    for attempt in range(5):
        try:
            res = requests.post(
                f"https://my.otomi.app/api/v1/{path}",
                json=body,
                headers={"Authorization": f"Bearer {os.environ['OTOMI_API_KEY']}"},
                timeout=10,
            )
            if res.status_code == 200:
                return res.json()
            if res.status_code != 429 and res.status_code < 500:
                raise ValueError(f"otomi {res.status_code}: {res.text}")  # داده را درست کنید
        except requests.RequestException:
            pass
        time.sleep(2 ** attempt)
    raise RuntimeError("otomi unreachable")  # Celery/RQ دوباره اجرا می‌کند

# Django: در signal بعد از ذخیره‌ی سفارش (بهتر: در یک task صف)
from django.db.models.signals import post_save
from django.dispatch import receiver

@receiver(post_save, sender=Order)
def order_saved(sender, instance, **kwargs):
    otomi_send("orders", {
        "id": str(instance.pk), "status": instance.status, "total": str(instance.total),
        "customer": {"id": str(instance.user_id), "mobile": instance.mobile},
        "updatedAt": instance.updated_at.isoformat(),
    })

C# / .NET

using System.Net;
using System.Net.Http.Json;

public sealed class OtomiClient(HttpClient http)
{
    // http.BaseAddress = new("https://my.otomi.app/api/v1/");
    // http.DefaultRequestHeaders.Authorization = new("Bearer", Environment.GetEnvironmentVariable("OTOMI_API_KEY"));

    public async Task<string> SendAsync(string path, object body, CancellationToken ct = default)
    {
        for (var attempt = 0; attempt < 5; attempt++)
        {
            try
            {
                using var res = await http.PostAsJsonAsync(path, body, ct);
                if (res.IsSuccessStatusCode) return await res.Content.ReadAsStringAsync(ct);
                if (res.StatusCode != HttpStatusCode.TooManyRequests && (int)res.StatusCode < 500)
                    throw new InvalidOperationException(await res.Content.ReadAsStringAsync(ct)); // داده را درست کنید
            }
            catch (HttpRequestException) { }
            catch (TaskCanceledException) when (!ct.IsCancellationRequested) { }
            await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), ct);
        }
        throw new InvalidOperationException("otomi unreachable"); // صف شما دوباره می‌فرستد
    }
}

// await otomi.SendAsync("orders", new { id = "1023", status = "paid", total = "150000",
//     customer = new { mobile = "09121234567" } });

Go

package otomi

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"time"
)

var client = &http.Client{Timeout: 10 * time.Second}

// Send posts one record (path: "orders", "customers", "products", …) with retries.
func Send(path string, body any) ([]byte, error) {
	payload, err := json.Marshal(body)
	if err != nil {
		return nil, err
	}
	for attempt := 0; attempt < 5; attempt++ {
		req, _ := http.NewRequest("POST", "https://my.otomi.app/api/v1/"+path, bytes.NewReader(payload))
		req.Header.Set("Authorization", "Bearer "+os.Getenv("OTOMI_API_KEY"))
		req.Header.Set("Content-Type", "application/json")
		res, err := client.Do(req)
		if err == nil {
			out, _ := io.ReadAll(res.Body)
			res.Body.Close()
			if res.StatusCode == 200 {
				return out, nil
			}
			if res.StatusCode != 429 && res.StatusCode < 500 {
				return nil, fmt.Errorf("otomi %d: %s", res.StatusCode, out) // داده را درست کنید
			}
		}
		time.Sleep(time.Duration(1<<attempt) * time.Second)
	}
	return nil, fmt.Errorf("otomi unreachable") // صف شما دوباره می‌فرستد
}

آدرس سایت — PHP

یک فایل؛ $store را به جدول‌های خودتان وصل کنید. OTOMI_HOOK_KEY و OTOMI_HOOK_SECRET همان کلید و رازی است که با PUT /v1/hook ثبت کردید.

<?php // https://shop.example/otomi
$key = getenv('OTOMI_HOOK_KEY');
$secret = sodium_base642bin(getenv('OTOMI_HOOK_SECRET'), SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING);
$b64 = fn ($raw) => rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
$sign = fn ($method, $path, $ts, $nonce, $body) => $b64(hash_hmac('sha256',
    "OTOMI-HMAC-SHA256\n$method\n$path\n$ts\n$nonce\n" . hash('sha256', $body), $secret, true));

$body = file_get_contents('php://input');
$ts = $_SERVER['HTTP_X_OTOMI_TIMESTAMP'] ?? '';
$nonce = $_SERVER['HTTP_X_OTOMI_NONCE'] ?? '';
$ok = ($_SERVER['HTTP_X_OTOMI_KEY'] ?? '') === $key
    && abs(time() - (int) $ts) <= 300
    && hash_equals($sign('POST', '/otomi/v1/command', $ts, $nonce, $body), $_SERVER['HTTP_X_OTOMI_SIGNATURE'] ?? '')
    && nonce_is_new($nonce); // در جدول یا Redis، ۱۰ دقیقه نگه دارید
if (!$ok) { http_response_code(401); exit; }

$cmd = json_decode($body, true);
$p = $cmd['params'] ?? [];
$reply = match ($cmd['command'] ?? '') {
    'health' => ['ok' => true],
    // رکوردهای تغییرکرده از $p['since']، قدیمی‌ترین اول، $p['limit'] + 1 تا برای hasMore
    'changes' => (function () use ($p, $store) {
        $rows = $store->changedSince($p['kind'], $p['since'], $p['limit'] + 1); // به شکل بخش ۳ + updatedAt
        return ['items' => array_slice($rows, 0, $p['limit']), 'hasMore' => count($rows) > $p['limit']];
    })(),
    default => null,
};
if ($reply === null) { http_response_code(400); exit; }

$out = json_encode($reply, JSON_UNESCAPED_UNICODE);
$myTs = (string) time();
$myNonce = $b64(random_bytes(18));
header('Content-Type: application/json');
header("X-Otomi-Key: $key");
header("X-Otomi-Timestamp: $myTs");
header("X-Otomi-Nonce: $myNonce");
header('X-Otomi-Signature: ' . $sign('RESPONSE', $nonce, $myTs, $myNonce, $out));
echo $out;

نمونه‌ی کامل‌تر با کد تخفیف: plugin/prestashop/otomiconnector/classes/OtomiSigner.php و controllers/front/coupons.php.

۹. چک‌لیست پیش از کلید اصلی

  • کلید در متغیر محیطی سرور است
  • ارز درست است (تومان یا ریال، همان که در اپ انتخاب شد)
  • ارسال در صف با تکرار است
  • سفارش جدید، پرداخت، لغو و استرداد را با کلید آزمایشی امتحان کرده‌اید
  • شماره موبایل مشتری فرستاده می‌شود
  • محصولات (با stock اگر موجودی دارید) و نظرهای منتشرشده فرستاده می‌شوند
  • آدرس سایت ثبت شده و در اپ ردیف «دریافت خودکار» سبز است (یا داده‌ی قدیمی را یک‌بار با /import فرستاده‌اید — نه با /orders، که برای سفارش‌های تازه پیام می‌فرستد)