Manova Developers

Custom CMS guide

Connect a shop built on your own platform, from key to go-live, with a complete PHP example.

This guide builds what the WooCommerce plugin does, on any platform. You need a server-side language that can make HTTPS requests and one public route. The examples use plain PHP; the same steps work in Laravel, Django, Node.js or Go.

What you build

Store the key

Create a connection key in the assistant's فروشگاه section and keep it in your server's environment, for example MANOVA_STORE_KEY. Never put it in templates or front-end code.

Connect once

Call POST /store/connect from an admin action or a deploy script. Save the publicKey it returns.

Embed the widget

Print the widget tag in your layout with that key:

<script src="https://manova.ir/widget.js" data-bot="<?= htmlspecialchars($publicKey) ?>" async></script>

Sync products

A full sync from a scheduled job (nightly is plenty), plus a single-product update from your product save and delete hooks. See Product sync.

Answer order lookups

Add POST {apiUrl}/order. See Order lookup.

A small client

ManovaStore.php
<?php

final class ManovaStore
{
    private const BASE = 'https://manova.ir/api/v1/external/store';

    public function __construct(private string $key) {}

    public function connect(string $siteUrl, string $apiUrl): array
    {
        return $this->call('POST', '/connect', ['siteUrl' => $siteUrl, 'apiUrl' => $apiUrl,
            'currency' => 'IRT', 'pluginVersion' => 'my-cms-1.0']);
    }

    /** Sends every product, 100 at a time, then removes what was not sent. */
    public function fullSync(iterable $products): array
    {
        $syncId = 'sync_' . bin2hex(random_bytes(8));
        $this->call('POST', '/sync', ['syncId' => $syncId, 'phase' => 'start']);
        $batch = [];
        foreach ($products as $product) {
            $batch[] = $product;
            if (count($batch) === 100) { $this->call('POST', '/products', ['syncId' => $syncId, 'products' => $batch]); $batch = []; }
        }
        if ($batch) $this->call('POST', '/products', ['syncId' => $syncId, 'products' => $batch]);
        return $this->call('POST', '/sync', ['syncId' => $syncId, 'phase' => 'finish']);
    }

    /** One changed product, or ids of deleted ones. */
    public function update(array $products, array $deleted = []): array
    {
        return $this->call('POST', '/products', ['products' => $products, 'deleted' => $deleted]);
    }

    private function call(string $method, string $path, ?array $body = null): array
    {
        $ch = curl_init(self::BASE . $path);
        curl_setopt_array($ch, [
            CURLOPT_CUSTOMREQUEST => $method,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT => 30,
            CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $this->key, 'Accept: application/json', 'Content-Type: application/json'],
            CURLOPT_POSTFIELDS => $body === null ? null : json_encode($body, JSON_UNESCAPED_UNICODE),
        ]);
        $response = curl_exec($ch);
        $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        curl_close($ch);
        $data = json_decode((string) $response, true) ?? [];
        if ($status === 429) throw new RuntimeException('Rate limited, retry later');
        if ($status >= 400) throw new RuntimeException(($data['code'] ?? 'HTTP_' . $status) . ': ' . ($data['message'] ?? ''));
        return $data;
    }
}

Map your product rows to Manova's fields:

function toManova(array $row): array
{
    $onSale = $row['sale_price'] && $row['sale_price'] < $row['price'];
    return [
        'id' => (int) $row['id'],
        'name' => $row['title'],
        'url' => 'https://shop.example.com/p/' . $row['slug'],
        'image' => $row['image_url'],
        'sku' => $row['sku'],
        'price' => $onSale ? $row['sale_price'] : $row['price'],
        'price_text' => number_format($onSale ? $row['sale_price'] : $row['price']) . ' تومان',
        'regular_price_text' => $onSale ? number_format($row['price']) . ' تومان' : null,
        'in_stock' => $row['stock'] > 0,
        'stock_quantity' => (int) $row['stock'],
        'categories' => $row['category_names'],
        'summary' => $row['description_html'],
        'cart_url' => 'https://shop.example.com/cart/add/' . $row['id'],
        'total_sales' => (int) $row['sold'],
        'visible' => $row['status'] === 'published',
    ];
}

Test the order route locally

Sign a request yourself to see that your route accepts it:

SECRET="${MANOVA_STORE_KEY##*_}"
BODY='{"number":"10457","phone":"09121234567"}'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')

curl -i -X POST https://shop.example.com/api/manova/order \
  -H "Content-Type: application/json" \
  -H "X-Manova-Timestamp: $TS" \
  -H "X-Manova-Signature: $SIG" \
  -d "$BODY"

Also check the failures: a changed body or a timestamp older than five minutes must get 401, and a wrong phone must get the same 404 {"found": false} as an unknown order.

Go-live checklist

  • The key is only in server configuration.
  • connect returned "active": true, and the widget appears on the shop.
  • A full sync finished, and the panel's فروشگاه section shows the right product count.
  • Saving a product in your admin updates it in the panel within a minute.
  • The order route verifies the signature and the timestamp, and answers in well under 10 seconds.
  • In the widget on your shop, "پیگیری سفارش 10457 09121234567" returns the real order.

On this page