· External Data API Docs

External Data API

A secure, read-only REST API for pulling EqomOS master data — categories, brands, products, SKUs, prices, inventory and orders — into your own systems. Built for reliable, incremental, server-to-server integration.

API version v1

Overview

The External Data API exposes EqomOS catalogue and order master data to authorised partner systems ("clients"). It is read-only (HTTP GET only), returns JSON, and is designed for both full extracts and lightweight incremental syncs.

This documentation uses sample data and placeholder credentials only. Your real tokenId and signing secret are issued privately during onboarding and must never be shared or embedded in client-side code.

Base URL & environments

All API endpoints are served under a single versioned base path:

BASE  https://mdm.eqomos.com/api/external/v1
ItemValue
ProtocolHTTPS only (HTTP is not served)
API base URLhttps://mdm.eqomos.com/api/external/v1
Content typeapplication/json; charset=UTF-8
MethodsGET only
API versionv1 (in the path)
This documentation site (mdmexternalapi.eqomos.com) is informational only — API calls are made against the API base URL above.

Onboarding & credentials

Access is granted per client. During onboarding you receive two values:

CredentialSent asDescription
tokenIdHeader X-Client-IdYour public client identifier. Safe to log. Example: eqc_exampleClientId123.
secretNever sentYour private HMAC signing key. Used only to compute the request signature. Store securely; it is shown only once at issue time.

You are also told which APIs you are granted, and your per-API limits (page size, max records per fetch, requests per minute). To request access, additional APIs, or a secret rotation, contact your EqomOS account manager.

Keep your secret safe. Anyone with your secret can call the API as you. Never put it in browsers, mobile apps, or public repositories. Rotate immediately if exposed.

Authentication — HMAC request signing

Every request must be signed with HMAC-SHA256. The server recomputes the signature and rejects any request that does not match, is too old, or replays a previous request. This protects against tampering and replay attacks even though your secret is never transmitted.

Required headers

HeaderRequiredDescription
X-Client-IdrequiredYour tokenId.
X-TimestamprequiredCurrent time as Unix epoch seconds (e.g. 1780805000). Must be within ±300 seconds of server time.
X-NoncerequiredA unique random string per request (e.g. 16 random bytes hex-encoded). Each nonce may be used only once within the timestamp window.
X-SignaturerequiredBase64-encoded HMAC-SHA256 of the canonical string (below), keyed with your secret.

The canonical string

Build a string by joining these six elements with the newline character \n, in this exact order:

HTTP_METHOD            (uppercase, e.g. GET)
REQUEST_PATH           (e.g. /api/external/v1/categories — path only, no host, no query)
CANONICAL_QUERY        (see below; empty string if no query params)
SHA256_HEX(body)       (lowercase hex SHA-256 of the request body)
X-Timestamp            (the same value sent in the header)
X-Nonce                (the same value sent in the header)

Notes:

Signing steps

  1. Generate X-Timestamp (epoch seconds) and a fresh random X-Nonce.
  2. Build the canonical string as above.
  3. Compute signature = Base64( HMAC_SHA256( canonicalString, secret ) ).
  4. Send the request with the four headers; put signature in X-Signature.
Tip: Sign the exact query string you send. The server canonicalises the parameters it receives, so as long as you sign and send the same parameters, the signatures will match regardless of their order.

Signing — code samples

The samples below call GET /api/external/v1/categories?isFullList=1&page=0&pageSize=50. Replace the placeholder credentials with the ones issued to you.

cURL + OpenSSL (bash)

TOKEN_ID="eqc_exampleClientId123"
SECRET="EXAMPLE_SECRET_DO_NOT_USE"
METHOD="GET"
PATH="/api/external/v1/categories"
QUERY="isFullList=1&page=0&pageSize=50"     # already sorted by encoded key=value
TS=$(date +%s)
NONCE=$(openssl rand -hex 16)
BODY_HASH="e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"  # sha256("")
CANON=$(printf '%s\n%s\n%s\n%s\n%s\n%s' "$METHOD" "$PATH" "$QUERY" "$BODY_HASH" "$TS" "$NONCE")
SIG=$(printf '%s' "$CANON" | openssl dgst -sha256 -hmac "$SECRET" -binary | base64)

curl -sS "https://mdm.eqomos.com${PATH}?${QUERY}" \
  -H "X-Client-Id: $TOKEN_ID" \
  -H "X-Timestamp: $TS" \
  -H "X-Nonce: $NONCE" \
  -H "X-Signature: $SIG"

Python 3

import time, os, hmac, hashlib, base64, requests
from urllib.parse import quote, unquote

BASE   = "https://mdm.eqomos.com"
TOKEN  = "eqc_exampleClientId123"
SECRET = "EXAMPLE_SECRET_DO_NOT_USE"
EMPTY  = hashlib.sha256(b"").hexdigest()

def canonical_query(q):
    if not q: return ""
    pairs = []
    for part in q.split("&"):
        k, _, v = part.partition("=")
        pairs.append(quote(unquote(k), safe="-._~") + "=" + quote(unquote(v), safe="-._~"))
    return "&".join(sorted(pairs))

def get(path, query=""):
    ts    = str(int(time.time()))
    nonce = os.urandom(16).hex()
    canon = "\n".join([ "GET", path, canonical_query(query), EMPTY, ts, nonce ])
    sig   = base64.b64encode(hmac.new(SECRET.encode(), canon.encode(), hashlib.sha256).digest()).decode()
    headers = { "X-Client-Id": TOKEN, "X-Timestamp": ts, "X-Nonce": nonce, "X-Signature": sig }
    url = BASE + path + (("?" + query) if query else "")
    return requests.get(url, headers=headers)

resp = get("/api/external/v1/categories", "isFullList=1&page=0&pageSize=50")
print(resp.status_code, resp.json())

Node.js

const crypto = require("crypto");

const BASE = "https://mdm.eqomos.com";
const TOKEN = "eqc_exampleClientId123";
const SECRET = "EXAMPLE_SECRET_DO_NOT_USE";
const EMPTY = crypto.createHash("sha256").update("").digest("hex");
const pct = s => encodeURIComponent(s).replace(/[!*'()]/g, c => "%" + c.charCodeAt(0).toString(16).toUpperCase());

function canonicalQuery(q) {
  if (!q) return "";
  return q.split("&").map(p => {
    const [k, v = ""] = p.split("=");
    return pct(decodeURIComponent(k)) + "=" + pct(decodeURIComponent(v));
  }).sort().join("&");
}

async function get(path, query = "") {
  const ts = Math.floor(Date.now() / 1000).toString();
  const nonce = crypto.randomBytes(16).toString("hex");
  const canon = ["GET", path, canonicalQuery(query), EMPTY, ts, nonce].join("\n");
  const sig = crypto.createHmac("sha256", SECRET).update(canon).digest("base64");
  const url = BASE + path + (query ? "?" + query : "");
  const res = await fetch(url, { headers: {
    "X-Client-Id": TOKEN, "X-Timestamp": ts, "X-Nonce": nonce, "X-Signature": sig } });
  console.log(res.status, await res.json());
}

get("/api/external/v1/categories", "isFullList=1&page=0&pageSize=50");

Java 11+

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.net.URI;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.*;

String base = "https://mdm.eqomos.com", token = "eqc_exampleClientId123", secret = "EXAMPLE_SECRET_DO_NOT_USE";
String path = "/api/external/v1/categories", query = "isFullList=1&page=0&pageSize=50";
String emptyBody = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; // sha256("")
String ts = String.valueOf(System.currentTimeMillis() / 1000L);
String nonce = UUID.randomUUID().toString().replace("-", "");
String canon = String.join("\n", "GET", path, query, emptyBody, ts, nonce); // query already sorted+encoded
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String sig = Base64.getEncoder().encodeToString(mac.doFinal(canon.getBytes(StandardCharsets.UTF_8)));
HttpRequest req = HttpRequest.newBuilder(URI.create(base + path + "?" + query))
    .header("X-Client-Id", token).header("X-Timestamp", ts)
    .header("X-Nonce", nonce).header("X-Signature", sig).GET().build();
HttpResponse<String> res = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString());
System.out.println(res.statusCode() + " " + res.body());

Common parameters

These query parameters are accepted by most endpoints. All are optional.

ParameterTypeDefaultDescription
isFullListinteger (0 or 1)01 = return the full dataset (ignores syncDate). 0 = incremental: when combined with syncDate, returns only records created or updated on/after that time.
syncDateISO-8601 date or datetimeYour last successful sync time, e.g. 2026-06-01 or 2026-06-01T09:30:00. Returns records where the change time (modifiedAt) is on/after this value. Ignored when isFullList=1.
supplierIdintegerRestrict results to a single supplier.
pageinteger (≥0)0Zero-based page index.
pageSizeinteger (≥1)per-client defaultRecords per page. Capped per client (see Pagination & limits).

Endpoint-specific filters (e.g. productid, skuid, supplierskucode, orderRef) are documented under each API.

Pagination & limits

Results are paginated. Iterate by increasing page until hasNext is false.

Recommended pattern: start at page=0, keep your chosen pageSize constant, and stop when hasNext=false. Persist totalRecords if you need a progress indicator.

Incremental sync

To keep a local copy in sync efficiently, do a one-time full load and then poll for changes:

  1. Initial load: call with isFullList=1 and page through all results. Record the time you started the sync.
  2. Incremental: on each later run, call with isFullList=0&syncDate=<last sync time>. You receive only records changed on/after syncDate.
  3. After a successful run, store the new sync time for the next call.
Records are matched on their last-change timestamp (modifiedAt). Use a small safety overlap (e.g. subtract a few minutes from your stored time) to avoid missing records written during the previous run.

Response envelope

All list endpoints return the same paginated envelope:

{
  "data": [ /* array of records for this page */ ],
  "page": 0,
  "pageSize": 50,
  "totalRecords": 124,
  "totalPages": 3,
  "hasNext": true
}
FieldTypeDescription
dataarrayThe records for this page (schema differs per API).
pageintegerZero-based index of the returned page.
pageSizeintegerEffective page size used.
totalRecordsintegerTotal records matching the query across all pages.
totalPagesintegerTotal number of pages.
hasNextbooleantrue if another page is available.

Common field conventions used inside records:

FieldMeaning
supplierIdIdentifier of the supplier the record belongs to.
mapped*Id (e.g. mappedCategoryId)The EqomOS platform's canonical identifier for the entity, once mapped. May be null if not yet mapped.
statusRecord lifecycle state. Only finalised master data is exposed (see FAQ).
modifiedAtLast-change timestamp (ISO-8601), used by syncDate filtering.
fields ending in JsonA nested JSON document passed through as a string; parse client-side if needed.

Errors & status codes

Errors return a JSON body with a machine-readable code and a human error message:

{ "error": "Request signature verification failed.", "code": "INVALID_SIGNATURE" }
HTTPcodeMeaning & how to fix
401MISSING_AUTH_HEADERSOne or more of the four required headers is missing.
401INVALID_CLIENTX-Client-Id is unknown or the client is suspended/revoked.
401BAD_TIMESTAMPX-Timestamp is not valid epoch seconds.
401TIMESTAMP_SKEWX-Timestamp is outside the ±300s window. Sync your clock (NTP).
401NONCE_REPLAYThis X-Nonce was already used. Generate a fresh nonce per request.
401INVALID_SIGNATURESignature did not match. Check the canonical string, query encoding/order, and secret.
403IP_NOT_ALLOWEDYour source IP is not in the client's whitelist.
403DOMAIN_NOT_ALLOWEDThe request Origin is not whitelisted (browser callers only).
403API_NOT_GRANTEDYour client is not granted access to this API. Contact your account manager.
400PAGE_SIZE_EXCEEDEDpageSize is above your client's maximum.
400BAD_SYNC_DATEsyncDate is not valid ISO-8601.
400INVALID_PARAMETERA parameter failed validation (e.g. isFullList not 0/1, negative page).
429RATE_LIMITEDYou exceeded your per-minute request limit. Back off and retry.
503REPLAY_STORE_DOWNTemporary server-side condition. Retry after a short delay.

Rate limiting

Each client has a per-minute request limit per API. Exceeding it returns 429 RATE_LIMITED. Build a backoff (e.g. wait until the next minute, or exponential backoff with jitter) and avoid bursty parallel calls. For large extracts, page sequentially rather than firing many pages concurrently.

API reference

All endpoints are GET, require the four authentication headers, and return the paginated envelope. Only finalised master data is returned.

GET Categories

GET  /api/external/v1/categories

Returns the category catalogue, including each category's hierarchy level and full breadcrumb path.

Query parameters

NameTypeReq?Description
isFullList0/1optFull vs incremental (see Common parameters).
syncDateISO-8601optReturn categories changed on/after this time.
supplierIdintegeroptFilter to one supplier.
page, pageSizeintegeroptPagination.

Response fields (per item)

FieldTypeDescription
idintegerCategory record identifier.
supplierIdintegerOwning supplier.
categoryCodestringSupplier's category code (unique within a supplier).
categoryNamestringDisplay name.
parentCodestringParent category's code; null for root categories.
levelintegerDepth in the hierarchy: 0=root, 1=parent, 2=child, 3=sub-child, and so on.
pathstringBreadcrumb of names from root to this node, e.g. "Electronics > Phones > Smartphones".
mappedCategoryIdintegerEqomOS platform category id (may be null).
statusstringRecord lifecycle state.
modifiedAtdatetimeLast-change timestamp.

Example response

{
  "data": [
    { "id": 124, "supplierId": 7, "categoryCode": "ELEC", "categoryName": "Electronics",
      "parentCode": null, "level": 0, "path": "Electronics",
      "mappedCategoryId": 9001, "status": "SYNCED", "modifiedAt": "2026-06-01T10:15:00" },
    { "id": 127, "supplierId": 7, "categoryCode": "ANDROID", "categoryName": "Android Phones",
      "parentCode": "SMART", "level": 3,
      "path": "Electronics > Phones > Smartphones > Android Phones",
      "mappedCategoryId": 9004, "status": "SYNCED", "modifiedAt": "2026-06-01T10:16:20" }
  ],
  "page": 0, "pageSize": 50, "totalRecords": 6, "totalPages": 1, "hasNext": false
}

GET Brands

GET  /api/external/v1/brands

Returns the brand list, including the brand logo URL where available.

Query parameters

NameTypeReq?Description
isFullList0/1optFull vs incremental.
syncDateISO-8601optReturn brands changed on/after this time.
supplierIdintegeroptFilter to one supplier.
page, pageSizeintegeroptPagination.

Response fields (per item)

FieldTypeDescription
idintegerBrand record identifier.
supplierIdintegerOwning supplier.
brandCodestringSupplier's brand code.
brandNamestringDisplay name.
mappedBrandIdintegerEqomOS platform brand id (may be null).
logoUrlstringBrand logo URL; null if none is available.
statusstringRecord lifecycle state.
modifiedAtdatetimeLast-change timestamp.

Example response

{
  "data": [
    { "id": 51, "supplierId": 7, "brandCode": "ACME", "brandName": "Acme",
      "mappedBrandId": 3300, "logoUrl": "https://cdn.example.com/brands/acme.png",
      "status": "SYNCED", "modifiedAt": "2026-05-30T08:00:00" }
  ],
  "page": 0, "pageSize": 50, "totalRecords": 1, "totalPages": 1, "hasNext": false
}

GET Products

GET  /api/external/v1/products

Returns product master records with descriptive, dimensional and specification attributes.

Query parameters

NameTypeReq?Description
productidintegeroptReturn a single product by its id.
isFullList0/1optFull vs incremental.
syncDateISO-8601optReturn products changed on/after this time.
supplierIdintegeroptFilter to one supplier.
page, pageSizeintegeroptPagination.

Response fields (per item)

FieldTypeDescription
idintegerProduct record identifier (use as productid filter).
supplierIdintegerOwning supplier.
productCodestringSupplier's product code.
productNamestringProduct name.
categoryCodestringSupplier category code this product belongs to.
mappedCategoryIdintegerMapped platform category id (nullable).
brandCodestringSupplier brand code.
mappedBrandIdintegerMapped platform brand id (nullable).
descriptionstringLong description.
warrantystringWarranty text.
weight, length, breadth, heightnumberPhysical dimensions.
specificationJsonstring (JSON)Structured specifications, as a JSON string.
variantMappingJsonstring (JSON)Variant-to-SKU mapping, as a JSON string.
mappedProductIdintegerMapped platform product id (nullable).
statusstringRecord lifecycle state.
modifiedAtdatetimeLast-change timestamp.

GET SKUs

GET  /api/external/v1/skus

Returns SKU master records: identity, pricing, variants, dimensions, compliance and merchandising attributes.

Query parameters

NameTypeReq?Description
skuidintegeroptReturn a single SKU by its id.
supplierskucodestringoptFilter by supplier SKU code.
isFullList0/1optFull vs incremental.
syncDateISO-8601optReturn SKUs changed on/after this time.
supplierIdintegeroptFilter to one supplier.
page, pageSizeintegeroptPagination.

Response fields (selected)

FieldTypeDescription
idintegerSKU record identifier (use as skuid filter).
supplierIdintegerOwning supplier.
skuCodestringSupplier SKU code.
sellerSkuCodestringPlatform-assigned seller SKU code.
skuNamestringSKU name.
parentCodestringParent SKU code (for variants); null if standalone.
isParentinteger1 if this SKU is a variant parent, else 0.
productCode, mappedProductIdstring / integerOwning product reference.
categoryCode, mappedCategoryIdstring / integerCategory reference.
brandCode, mappedBrandIdstring / integerBrand reference.
mappedSkuIdintegerMapped platform SKU id (nullable).
mrp, offerPrice, tp, shippingPricenumberMRP, offer price, transfer price, shipping price.
variantsarrayUp to 4 variant axes, each { "label": "Color", "value": "Red" }.
measureUnit, measureValuestring / numberUnit of measure and value.
hsnCode, taxPercentage, originCountrystring / number / stringTax/compliance attributes.
weight, length, breadth, heightnumberDimensions.
minOrderQty, deliveryTimeinteger / stringOrdering attributes.
isCodAllowed, isPrepaidAllowed, isWebAllowed, isAppAllowedintegerChannel/payment availability flags (0/1).
title, metaTitle, metaKeyword, metaDescriptionstringSEO/merchandising metadata.
specificationJson, variantMappingJsonstring (JSON)Structured attributes as JSON strings.
status, modifiedAtstring / datetimeLifecycle state and last-change time.

GET Price & Inventory

GET  /api/external/v1/price-inventory

Returns offer price, MRP and per-warehouse stock for SKUs, merged into one record per SKU.

Query parameters

NameTypeReq?Description
skuidintegeroptFilter by mapped platform SKU id.
supplierskucodestringoptFilter by supplier SKU code.
isFullList0/1optFull vs incremental.
syncDateISO-8601optReturn records changed on/after this time.
supplierIdintegeroptFilter to one supplier.
page, pageSizeintegeroptPagination.

Response fields (per item)

FieldTypeDescription
supplierIdintegerOwning supplier.
skuCodestringSupplier SKU code.
mappedSkuIdintegerMapped platform SKU id (nullable).
mrpnumberMaximum retail price.
offerPricenumberCurrent selling/offer price.
tpnumberTransfer/cost price.
effectiveFrom, effectiveTodatetimePrice validity window (nullable).
inventoryarrayPer-warehouse stock: { "warehouseCode": "...", "mappedWhId": 0, "qty": 0 }.
totalQtyintegerSum of qty across all warehouses.
modifiedAtdatetimeMost recent change across price and inventory.

Example response

{
  "data": [
    { "supplierId": 7, "skuCode": "ACME-WIDGET-RED", "mappedSkuId": 88012,
      "mrp": 1999.0, "offerPrice": 1499.0, "tp": 1100.0,
      "effectiveFrom": "2026-05-01T00:00:00", "effectiveTo": null,
      "inventory": [
        { "warehouseCode": "WH-MUM", "mappedWhId": 12, "qty": 40 },
        { "warehouseCode": "WH-DEL", "mappedWhId": 15, "qty": 12 }
      ],
      "totalQty": 52, "modifiedAt": "2026-06-02T07:45:00" }
  ],
  "page": 0, "pageSize": 50, "totalRecords": 1, "totalPages": 1, "hasNext": false
}

GET Orders

GET  /api/external/v1/orders

Returns orders with header, customer, shipping/billing addresses and line items. Line items are grouped under each order — an order is never split across pages.

Query parameters

NameTypeReq?Description
orderRefstringoptReturn a single order by its reference.
isFullList0/1optFull vs incremental.
syncDateISO-8601optReturn orders changed on/after this time.
supplierIdintegeroptFilter to one supplier.
page, pageSizeintegeroptPagination (by distinct order).

Response fields (per order)

FieldTypeDescription
supplierIdintegerOwning supplier.
orderRefstringSupplier order reference.
mappedOrderIdintegerMapped platform order id (nullable).
orderTypestringOrder type/category.
orderStatusstringOrder status.
orderDatedatetimeOrder creation time.
currencystringCurrency code.
orderTotalnumberOrder total amount.
totalItemsintegerNumber of items in the order.
customerobject{ firstName, lastName, email, phone }.
shippingAddress, billingAddressobject{ name, company, phone, email, gst, addr1, addr2, city, state, pincode, country }.
itemsarrayLine items: { skuCode, sellerSkuCode, mappedSkuId, name, qty, price, subtotal, lineStatus }.
modifiedAtdatetimeMost recent change across the order's rows.

Example response

{
  "data": [
    { "supplierId": 7, "orderRef": "SO-100245", "mappedOrderId": null,
      "orderType": "ORDER_PUNCH", "orderStatus": "PROCESSING",
      "orderDate": "2026-06-03T11:20:00", "currency": "INR",
      "orderTotal": 2998.0, "totalItems": 2,
      "customer": { "firstName": "Sample", "lastName": "Buyer",
                    "email": "buyer@example.com", "phone": "+91-90000-00000" },
      "shippingAddress": { "name": "Sample Buyer", "company": null, "phone": "+91-90000-00000",
        "email": "buyer@example.com", "gst": null, "addr1": "12 Sample Street", "addr2": null,
        "city": "Mumbai", "state": "MH", "pincode": "400001", "country": "IN" },
      "billingAddress": { "name": "Sample Buyer", "company": null, "phone": "+91-90000-00000",
        "email": "buyer@example.com", "gst": null, "addr1": "12 Sample Street", "addr2": null,
        "city": "Mumbai", "state": "MH", "pincode": "400001", "country": "IN" },
      "items": [
        { "skuCode": "ACME-WIDGET-RED", "sellerSkuCode": "SLR-001", "mappedSkuId": 88012,
          "name": "Acme Widget (Red)", "qty": 2, "price": 1499.0, "subtotal": 2998.0,
          "lineStatus": "CONFIRMED" }
      ],
      "modifiedAt": "2026-06-03T11:21:10" }
  ],
  "page": 0, "pageSize": 50, "totalRecords": 1, "totalPages": 1, "hasNext": false
}

FAQ

Why is my dataset empty even though data exists?

The API only returns finalised master data (records that have completed internal validation/mapping). Records still in earlier processing stages are not exposed. If you expect data and see none, it may not yet be finalised.

My signature keeps failing (401 INVALID_SIGNATURE). What should I check?

Confirm: (1) the canonical string uses real newlines in the exact 6-line order; (2) you used the empty-body SHA-256 constant; (3) query parameters are percent-encoded and sorted identically to what you send; (4) X-Timestamp/X-Nonce in the string match the headers; (5) you are signing with the correct secret.

How fresh is the data?

Use modifiedAt as the source of truth for freshness and drive incremental syncs from it.

Can I call this from a browser?

No. Your secret must never be exposed to a browser or mobile app. Always call server-to-server.

Changelog

VersionNotes
v1Initial release: Categories, Brands, Products, SKUs, Price & Inventory, Orders. HMAC-SHA256 signing, pagination, incremental sync, per-client grants and rate limits.