ELMATAJER
Developer guide

Connect your platform
to your customers’ stores

The merchant approves once, and you get a scoped token for their store alone — plus signed, real-time event notifications.

  • OAuth 2.0 + PKCE
  • HMAC-signed webhooks
  • Per-store scoped tokens

Fastest · one install link, no registration

No registration, no waiting for approval. Build this link and put it in your own dashboard — the merchant approves, and we POST the key to your server.

The merchant will see “Unverified app”
Because you described your own app, the screen tells the merchant so and shows them the host the key will be sent to. This works with zero involvement from us — contact us to register and get a Verified badge instead.
Every store gives you its own api_key and webhook_secret — store them per store, since one merchant may connect several.
GET https://elmatajer.com/install-app
  ?app_name=Your App
  &app_description=What it does, in one line
  &app_icon=https://your-app.com/logo.png
  &permissions=orders.read,orders.write,webhooks
  &callback_url=https://your-app.com/callback   // we POST the key here
  &webhook_url=https://your-app.com/webhook     // events go here
  &redirect_url=https://your-app.com/connected  // merchant lands here

→ after approval we POST to your callback_url:
{
  "api_key":        "19|xxxxxxxxxxxxxxxx",
  "store_id":       42,
  "scope":          "orders.read orders.write webhooks",
  "webhook_secret": "...",
  "api_base":       "https://elmatajer.com/api/v1/vendor"
}

Important · your first connection goes through review

The first time a merchant clicks your link, your app is registered and enters our review queue. Until we approve it, every install attempt returns 403 and the merchant sees an “app under review” page.

1 A merchant clicks
2 Your app is queued
3 We review it
4 It works for everyone
One time only
Review is per APP, not per merchant. Once approved, every merchant connects instantly. To clear review quickly, use a clear app_name and app_description and request only the scopes you actually need.
Changing your callback_url or app name creates a NEW app that needs its own review — an app’s identity here is its callback host plus its name.

Or · the full OAuth flow

If you want the Verified badge and redirect URIs locked to your app, register and use this flow instead.

A standard OAuth 2.0 authorization-code flow with PKCE. If you have integrated with any platform before, this will look familiar.

1 Send the merchant
2 They approve
3 Exchange the code
4 Use the API

1 · The authorization URL

Redirect the merchant here. If they are not signed in they will log in first and come straight back.

The redirect_uri must match the one registered with us exactly — no prefix matching. Always verify the returned state equals the one you sent.
GET https://elmatajer.com/oauth/authorize
  ?client_id=YOUR_CLIENT_ID
  &redirect_uri=https://your-app.com/callback
  &scope=orders.read orders.write webhooks
  &state=RANDOM_ANTI_CSRF_VALUE
  &code_challenge=BASE64URL(SHA256(verifier))
  &code_challenge_method=S256

2 · Exchange the code for a token

From your server, never the browser. The code is single-use and expires in 5 minutes.

POST https://elmatajer.com/oauth/token
Content-Type: application/json

{
  "grant_type":    "authorization_code",
  "client_id":     "YOUR_CLIENT_ID",
  "client_secret": "YOUR_CLIENT_SECRET",
  "code":          "THE_CODE_FROM_THE_REDIRECT",
  "redirect_uri":  "https://your-app.com/callback",
  "code_verifier": "THE_ORIGINAL_VERIFIER"
}

→ 200 OK
{
  "access_token":   "17|xxxxxxxxxxxxxxxx",
  "token_type":     "Bearer",
  "scope":          "orders.read orders.write webhooks",
  "store":          { "vendor_id": 42, "name": "..." },
  "webhook_secret": "store this — it signs your webhooks",
  "api_base":       "https://elmatajer.com/api/v1/vendor"
}

→ 401 { "error": "invalid_client" }   wrong client_id / client_secret, or the app is not approved
→ 400 { "error": "invalid_grant", "error_description": "code_expired" }
       error_description: code_expired · code_already_used · redirect_uri_mismatch · invalid_code_verifier

3 · Scopes

Request the least you need — every scope is shown to the merchant by name, and a short list gets approved faster.

Scope Grants
Orders
orders.read View orders
orders.write Modify orders
Catalog
products.read View products
products.write Modify products
categories.read View categories
Customers
customers.read View customers
carts.read View abandoned carts
Shipping
shipping.read View shipping zones
Marketing
coupons.read View coupons
Reports
reports.read View reports
Webhooks
webhooks Receive event webhooks

4 · Calling the API

The store is derived from the token itself — you never send a vendor_id, and you cannot reach another store.

GET https://elmatajer.com/api/v1/vendor/orders
Authorization: Bearer YOUR_ACCESS_TOKEN
Accept: application/json

→ 200 OK
{
  "status": "ok",
  "data": [ {
    "id": 1042, "order_number": "AB1234",
    "status": 1, "status_type": 1, "status_label": "...",
    "payment_status": 1, "payment_type": "cod",
    "customer": { "name": "...", "email": "...", "mobile": "..." },
    "totals": { "sub_total": 1200, "delivery_charge": 50, "discount_amount": 0,
                "grand_total": 1250, "currency": "...", "currency_code": "EGP" },
    "items": [ ... ], "created_at": "2026-10-08T12:00:00+03:00"
  } ],
  "meta": { "current_page": 1, "last_page": 3, "per_page": 20, "total": 52, "has_more": true }
}

→ 403 if the merchant did not grant that scope
{ "code": "insufficient_scope", "required_scope": "orders.read" }
Pagination and filters
Lists are paginated: ?page=N&per_page=5–50 (default 20), with a meta block. Orders: status (1 pending … 6 returned) and q (order number, customer name or phone). Products: status (all · active · hidden · draft · out · low), cat (category id) and q.
PUT replaces the whole product
PUT /vendor/products/{id} rewrites the product: any optional field you leave out (such as sku or description) is cleared. Send the full product; to change only stock or visibility use /stock and /availability.

Endpoint reference

Everything an installed app can reach, grouped by the scope that unlocks it. All paths are under https://elmatajer.com/api/v1

Method Path
orders.read  View orders
GET /vendor/orders
GET /vendor/orders/statuses
GET /vendor/orders/summary
GET /vendor/orders/{id}
orders.write  Modify orders
POST /vendor/orders/{id}/status
products.read  View products
GET /vendor/products
GET /vendor/products/brands
GET /vendor/products/counts
GET /vendor/products/variant-options
GET /vendor/products/{id}
products.write  Modify products
POST /vendor/categories
DELETE /vendor/categories/{id}
PUT /vendor/categories/{id}
POST /vendor/categories/{id}/status
POST /vendor/products
POST /vendor/products/ai-description
POST /vendor/products/translate
POST /vendor/products/variant-options
DELETE /vendor/products/{id}
PUT /vendor/products/{id}
POST /vendor/products/{id}/availability
POST /vendor/products/{id}/images
DELETE /vendor/products/{id}/images/{imageId}
POST /vendor/products/{id}/stock
categories.read  View categories
GET /vendor/categories
GET /vendor/products/categories
customers.read  View customers
GET /vendor/customers
shipping.read  View shipping zones
GET /vendor/shipping
coupons.read  View coupons
GET /vendor/coupons
reports.read  View reports
GET /vendor/reports

Request bodies and responses

Send JSON (or multipart/form-data when uploading files). * = required. Anything not listed is ignored — such as vendor_id or id.

Endpoint Body fields Notes
POST
/vendor/products
name* · price* · category_ids*[] · name_en · description · description_en · compare_price · sku · video_url · brand_ids[] · is_available · manage_stock · stock_qty · low_qty · variants[]{color, size, price*, qty, sku} · images[] 201 + the product. is_available=false hides it. images are files (jpg/png/webp up to 4 MB, max 10) — then send multipart/form-data.
PUT
/vendor/products/{id}
same as POST Full replace: optional fields left out are cleared, and a missing manage_stock turns stock tracking off.
POST
/vendor/products/{id}/stock
qty* · low_qty Turns stock tracking on. Dropping qty below low_qty fires product.low_stock.
POST
/vendor/products/{id}/availability
— No body: toggles shown ↔ hidden and returns the product.
POST
/vendor/products/{id}/images
image* (file) multipart/form-data. 201 + { id, url }.
DELETE
/vendor/products/{id}
— Permanent — cannot be undone.
POST
/vendor/categories
name* · name_en · image (file) 201 + the category. With image, send multipart/form-data. PUT /categories/{id} takes the same fields.
POST
/vendor/categories/{id}/status
is_available* true shows the category, false hides it.
DELETE
/vendor/categories/{id}
— Permanent, and it is removed from all its products — product_count in GET /categories tells you how many.
POST
/vendor/orders/{id}/status
status* · type* status = an id from GET /orders/statuses, type = its type (1–6). Runs the same side effects as the dashboard: the customer is messaged/emailed and stock is restored on cancel.

Response fields

Every success: { "status": "ok", "data": … } — paginated lists also carry meta.

Resource Fields
order (GET /orders, /orders/{id}) id, order_number, status, status_type, status_label, payment_status, payment_type, order_type, customer{name, email, mobile}, address{line, building, landmark} (when set), totals{sub_total, delivery_charge, discount_amount, tips, grand_total, currency, currency_code}, items_count, items[], notes, created_at
order status (GET /orders/statuses?order_type=1) id, type, name
product (GET /products, /products/{id}) id, name, name_en, slug, sku, image, images[]{id, url} (single product), price, original_price, discount_pct, stock{state: in|low|out|untracked, qty, tracked}, category_ids, is_available, is_draft, has_variants, variants[]{group, options}, category, views, video_url, description, created_at
GET /products/counts all, active, hidden, draft, out, low
GET /products/brands id, name, image, is_available
GET /products/variant-options colors[], sizes[]
category (GET /categories) id, name, name_en, slug, image, is_available, product_count
GET /products/categories id, name, image, is_available
customer (GET /customers?q=&per_page=) id, name, email, mobile, orders_count, total_spent, currency, currency_code, last_order_at, joined_at
coupon (GET /coupons — not paginated) id, name, code, type: percent|amount, amount, min_amount, usage_limit, start_date, exp_date, is_active, is_expired
GET /shipping zones[]{id, name, charge, is_active}, free_shipping_amount, currency, currency_code
GET /reports?range=week|month|year (month) range, currency, currency_code, revenue, orders, avg_order, series[]{label, total}, top_products[]{name, sold, revenue}

Errors and limits

Every error returns JSON with a stable `code`. Branch on the code, never the message — the message changes with the language.

HTTP code Meaning and what to do
401 unauthenticated Missing or revoked token — the merchant disconnected. Ask them to reconnect.
402 subscription_expired The merchant’s Elmatajer subscription lapsed — every store endpoint answers 402 until they renew. Pause syncing and tell them.
403 insufficient_scope The merchant did not grant this scope (the body names required_scope), or the endpoint is not available to apps at all.
403 app_disabled Elmatajer switched your app off or withdrew its approval — the token will not work until it is restored. Contact us.
403 — On /install-app: your app is still under review, or was rejected.
404 not_found The resource or endpoint does not exist in this merchant’s store (another store’s id is a 404 too).
405 method_not_allowed Wrong HTTP method for this path.
422 validation_failed Invalid input — the body lists errors per field. An unavailable order status returns invalid_status.
429 rate_limited 60 requests per minute per store (shared by every app connected to that store and the merchant’s own app). Watch X-RateLimit-Remaining; on 429 wait Retry-After seconds.
Treat 401 as final
There is no refresh token. If the merchant revokes access the token dies immediately — stop syncing that store and tell your user to reconnect rather than retrying.

5 · Webhooks

We POST every event to you and retry with backoff if your endpoint does not answer. Return any 2xx.

Event When Requires data
order.created A new order was placed orders.read + webhooks order{id, order_number, sub_total, tax, discount, grand_total, payment_type, order_type, order_from, status}, customer{id, name, email, phone} + vendor_id
order.paid An order was paid orders.read + webhooks order_id, order_number, grand_total, payment_type, customer{id, name, email, phone} + vendor_id
order.status_changed An order status changed orders.read + webhooks order_id, order_number, from_status, to_status, customer{id, name, email, phone} + vendor_id
customer.created A customer registered customers.read + webhooks customer{id, name, email, phone}, source + vendor_id
product.low_stock A product is low on stock products.read + webhooks product{id, name, sku, price}, qty_remaining, threshold + vendor_id
cart.abandoned A cart was left without checkout (opaque cart_ref, never a session id) carts.read + webhooks cart{cart_ref, user_id, item_count, subtotal, items[], last_activity}, idle_minutes + vendor_id
POST https://your-app.com/webhook
X-Elmatajer-Event: order.created
X-Elmatajer-Event-Id: 9f1c...            // use it to de-duplicate
X-Elmatajer-Signature: sha256=<hmac>

{ "event": "order.created", "event_id": "9f1c...", "vendor_id": 42,
  "created_at": "2026-10-08T12:00:00+03:00", "data": { ... } }

// Verify before trusting anything — PHP
$expected = hash_hmac('sha256', $rawBody, $webhookSecret);
if (!hash_equals($expected, str_replace('sha256=', '', $header))) abort(401);
Always verify the signature
The HMAC is computed over the raw request body using the secret you received at install time — it differs per merchant. Without this check, anyone who learns your URL can send you forged orders.
At-least-once delivery
If your server does not answer 2xx we retry with growing gaps (up to 8 attempts), so the same event can reach you more than once — X-Elmatajer-Event-Id is identical across retries; use it to ignore duplicates. Ordering between events is not guaranteed.

A complete working example

The two pieces every partner must get right: receiving the key, and verifying a webhook before trusting it.

// 1 — receive the merchant's credentials (your callback_url)
POST /elmatajer/callback

$body = json_decode(file_get_contents('php://input'), true);

saveStore([
    'store_id'       => $body['store_id'],        // unique per merchant
    'api_key'        => $body['api_key'],         // use as Bearer token
    'webhook_secret' => $body['webhook_secret'],  // verifies their events
]);

http_response_code(200);  // answer 2xx or we treat it as failed


// 2 — call the API for that store
$res = $http->get('https://elmatajer.com/api/v1/vendor/orders', [
    'headers' => [
        'Authorization' => 'Bearer ' . $store['api_key'],
        'Accept'        => 'application/json',
    ],
]);
// no store_id in the request — the token already identifies the store


// 3 — receive events (your webhook_url)
POST /elmatajer/webhook

$raw = file_get_contents('php://input');
$sig = str_replace('sha256=', '', $_SERVER['HTTP_X_ELMATAJER_SIGNATURE'] ?? '');

$store = findStoreById(json_decode($raw, true)['vendor_id']);

// VERIFY FIRST — never trust the body before this passes
if (!hash_equals(hash_hmac('sha256', $raw, $store['webhook_secret']), $sig)) {
    http_response_code(401); exit;
}

$event = json_decode($raw, true);

// de-duplicate: retries resend the same event_id
if (alreadyHandled($_SERVER['HTTP_X_ELMATAJER_EVENT_ID'])) { http_response_code(200); exit; }

match ($event['event']) {
    'order.created'        => onNewOrder($event['data']),
    'order.status_changed' => onStatusChange($event['data']),
    default                => null,
};

http_response_code(200);
Verify before anything else
The HMAC is over the RAW body exactly as received — decoding and re-encoding the JSON will break the match. Without this check, anyone who learns your URL can send you forged orders.

Ready to start?

Request your app credentials

Send us your app name, a short description, logo, redirect URIs and the scopes you need — we will return a client_id and client_secret.

Contact us
The merchant stays in control

Any merchant can see your app, its scopes and last use, and revoke it at any time from Connected Apps. The token dies immediately.