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.
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.
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 · The authorization URL
Redirect the merchant here. If they are not signed in they will log in first and come straight back.
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" }
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. |
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);
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);
Ready to start?
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 usAny merchant can see your app, its scopes and last use, and revoke it at any time from Connected Apps. The token dies immediately.