العربية: ملخص سريع
- واجهة NShop البرمجية تتيح لك بناء متجر إلكتروني خاص بك يقرأ المنتجات والإعدادات من حسابك ويرسل الطلبات إليه، بدل الاتصال المباشر بقاعدة البيانات.
- للحصول على مفتاح: سجّل الدخول كمدير، افتح الإضافات ثم المتجر الإلكتروني، ثم تبويب API، واضغط إنشاء مفتاح واختر الصلاحيات.
- المفتاح يبدأ بـ
nsk_live_ويظهر مرة واحدة فقط، فاحفظه فوراً في متغيرات البيئة على الخادم ولا تضعه في كود المتصفح. - أرسل المفتاح في الترويسة:
Authorization: Bearer nsk_live_...(أوX-API-Key). - الصلاحيات:
store:readللإعدادات والمدن،catalog:readللمنتجات والتصنيفات،orders:writeلإنشاء الطلبات،orders:readلتتبعها. - العنوان الأساسي:
https://nshop.naqsh-tech.com/api/v1، والمواصفات الكاملة بصيغة OpenAPI على/api/v1/openapi.json. - عند إنشاء طلب أرسل رقم المنتج والكمية فقط؛ الأسعار والعروض والشحن تُحسب في الخادم، ولا يُقبل أي سعر أو خصم أو ضريبة من العميل.
- رقم الجوال يجب أن يكون فلسطينياً (05XXXXXXXX)، واستخدم الترويسة
Idempotency-Keyعند إعادة المحاولة حتى لا يتكرر الطلب. - لتتبع طلب يلزم رقم الطلب وآخر 4 أرقام من هاتف العميل. وحالة الطلب يغيّرها صاحب المتجر من لوحة NShop.
- للدعم تواصل عبر واتساب على 00972595856529 وأرسل معرّف الطلب
X-Request-Idمن استجابة الخطأ.
Get your API key
- Sign in to NShop as an admin and make sure the E-commerce add-on is enabled.
- Open Add-ons, E-commerce and select the API tab.
- Choose Create API key, name it (for example "Production storefront") and tick only the scopes it needs.
- Copy the key now. It starts with
nsk_live_and is shown only once; NShop stores just a hash.
Each company can hold up to 10 active keys. Revoke a key at any time from the same tab; it stops working immediately.
Authentication
Send the key in the Authorization header (or X-API-Key). The company is resolved from the key, so you never pass a company id. Call the API from your server, never from browser code.
Authorization: Bearer nsk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# or
X-API-Key: nsk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx| Scope | Grants |
|---|---|
store:read | Store configuration, theme, shipping cities and banner statements. |
catalog:read | Categories, products and best sellers. |
orders:write | Place orders. |
orders:read | Track orders (requires the customer's phone_last4). |
Key safety. Store keys in environment variables, never commit them, use one key per application and give it the minimum scopes. A storefront normally needs store:read, catalog:read, orders:write and orders:read. If a key leaks, revoke it and create a new one.
Quickstart
Check the connection (no key needed), then list products with your key.
curl https://nshop.naqsh-tech.com/api/v1/healthcurl "https://nshop.naqsh-tech.com/api/v1/products?limit=12&page=1" \
-H "Authorization: Bearer $NSHOP_API_KEY"The same call from Node.js / Next.js server code:
const res = await fetch("https://nshop.naqsh-tech.com/api/v1/products?limit=12", {
headers: { Authorization: "Bearer " + process.env.NSHOP_API_KEY },
});
if (!res.ok) throw new Error((await res.json()).error.message);
const { data: products, meta } = await res.json();
console.log(products.length, "of", meta.total);Placing an order (see the order guide):
curl -X POST "https://nshop.naqsh-tech.com/api/v1/orders" \
-H "Authorization: Bearer $NSHOP_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 7d9e4f1a-checkout-1042" \
-d '{
"customer_name": "Ahmad Saleh",
"customer_phone": "0599123456",
"customer_city": "Ramallah",
"customer_address": "Al-Masyoun, Street 5, building 12",
"items": [{ "product_id": "6f1c1c1e-5a40-4e0d-9d52-1d1a0b8f3a11", "quantity": 2 }]
}'Prefer a typed client? Copy this single file into your project. It wraps every endpoint, throws a typed NShopApiError and keeps the same cache tags your storefront already uses.
nshop-client.ts (copy-paste)
// nshop-client.ts - typed, server-side client for the NShop Storefront API (v1).
// Env: NSHOP_API_BASE_URL (e.g. https://nshop.naqsh-tech.com) and NSHOP_API_KEY (nsk_live_...).
// Keep the key on the server. Never ship it to the browser.
const BASE_URL = process.env.NSHOP_API_BASE_URL ?? "https://nshop.naqsh-tech.com";
const API_KEY = process.env.NSHOP_API_KEY ?? "";
export type ApiErrorCode =
| "invalid_api_key" | "insufficient_scope" | "ecommerce_disabled" | "rate_limited"
| "validation_error" | "not_found" | "conflict" | "insufficient_stock"
| "product_not_available" | "idempotency_conflict" | "internal_error";
export class NShopApiError extends Error {
constructor(
public status: number,
public code: ApiErrorCode,
message: string,
public details?: Record<string, unknown>,
public requestId?: string,
public retryAfter?: number,
) {
super(message);
this.name = "NShopApiError";
}
}
export interface PageMeta { total: number; limit: number; page: number; has_more: boolean }
export interface Offer { id: string; kind: "percent" | "fixed"; value: number; ends_at: string | null }
export interface Product {
id: string; name: string; sku: string | null; barcode: string | null; description: string | null;
category_id: string | null; category_name: string | null; images: string[];
price: number; compare_at_price: number | null; offer: Offer | null;
in_stock: boolean; stock_quantity: number; updated_at: string;
}
export interface Category {
id: string; name: string; description: string | null; parent_id: string | null;
level: number | null; sort_order: number | null; icon_url: string | null; children?: Category[];
}
export interface ShippingCity { id: string; city_name: string; shipping_cost: number; sort_order: number | null }
export interface BannerStatement { id: string; body: string; sort_order: number | null }
export interface Store {
company: Record<string, unknown>;
settings: Record<string, unknown> | null;
theme: Record<string, unknown> | null;
}
export interface CreateOrderInput {
customer_name: string; customer_phone: string; customer_phone2?: string; customer_email?: string;
customer_city: string; customer_address: string; order_note?: string;
items: Array<{ product_id: string; quantity: number }>;
}
export interface OrderCreated {
id: string; order_number: string; status: string; subtotal: number;
shipping_amount: number; discount_amount: number; total_amount: number;
}
interface RequestOptions {
method?: "GET" | "POST";
body?: unknown;
headers?: Record<string, string>;
/** Next.js fetch cache: seconds to cache and tags for revalidateTag(). */
revalidate?: number | false;
tags?: string[];
}
async function request<T>(path: string, opts: RequestOptions = {}): Promise<{ data: T; meta?: PageMeta }> {
const init: RequestInit & { next?: { revalidate: number; tags?: string[] } } = {
method: opts.method ?? "GET",
headers: {
Authorization: "Bearer " + API_KEY,
...(opts.body ? { "Content-Type": "application/json" } : {}),
...opts.headers,
},
body: opts.body ? JSON.stringify(opts.body) : undefined,
};
if (opts.method === "POST" || opts.revalidate === false) init.cache = "no-store";
else if (opts.revalidate !== undefined) init.next = { revalidate: opts.revalidate, tags: opts.tags };
const res = await fetch(BASE_URL + path, init);
const json = await res.json().catch(() => null);
if (!res.ok) {
const e = json?.error ?? {};
throw new NShopApiError(
res.status,
e.code ?? "internal_error",
e.message ?? "Request failed",
e.details,
res.headers.get("X-Request-Id") ?? undefined,
Number(res.headers.get("Retry-After")) || undefined,
);
}
return json;
}
// Same cache tags / TTLs as the old unstable_cache layer, so /api/revalidate keeps working.
const CONFIG = { revalidate: 3600, tags: ["storefront-config"] };
const CATALOG = { revalidate: 600, tags: ["storefront-catalog"] };
function qs(params: Record<string, string | number | undefined>): string {
const q = new URLSearchParams();
for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== "") q.set(k, String(v));
const s = q.toString();
return s ? "?" + s : "";
}
export const nshop = {
getStore: async () => (await request<Store>("/api/v1/store", CONFIG)).data,
getShippingCities: async () => (await request<ShippingCity[]>("/api/v1/shipping-cities", CONFIG)).data,
getBannerStatements: async () => (await request<BannerStatement[]>("/api/v1/banner-statements", CONFIG)).data,
getCategories: async (tree = false) =>
(await request<Category[]>("/api/v1/categories" + qs({ tree: tree ? "true" : undefined }), CATALOG)).data,
listProducts: (p: { page?: number; limit?: number; categoryId?: string; q?: string; ids?: string[]; updatedSince?: string } = {}) =>
request<Product[]>(
"/api/v1/products" +
qs({ page: p.page, limit: p.limit, category_id: p.categoryId, q: p.q, ids: p.ids?.join(","), updated_since: p.updatedSince }),
CATALOG,
),
getProduct: async (id: string) => (await request<Product>("/api/v1/products/" + id, CATALOG)).data,
getBestSellers: async (limit = 10, days = 90) =>
(await request<Array<Product & { units_sold: number }>>("/api/v1/best-sellers" + qs({ limit, days }), CATALOG)).data,
/** Place an order. Send only product ids and quantities. Reuse idempotencyKey when retrying the same checkout. */
createOrder: async (input: CreateOrderInput, idempotencyKey: string) =>
(await request<OrderCreated>("/api/v1/orders", {
method: "POST",
body: input,
headers: { "Idempotency-Key": idempotencyKey },
})).data,
/** Track an order. The customer must supply the last four digits of their phone. */
getOrder: async (orderNumber: string, phoneLast4: string) =>
(await request<Record<string, unknown>>(
"/api/v1/orders/" + encodeURIComponent(orderNumber) + qs({ phone_last4: phoneLast4 }),
{ revalidate: false },
)).data,
};
Conventions
Envelope. Success is { "data": ... }, plus meta for paginated lists. Errors are always:
{ "error": { "code": "validation_error", "message": "Invalid request.", "details": { "issues": [{ "path": "customer_city", "message": "Unknown city" }] } } }| Code | HTTP | Meaning |
|---|---|---|
invalid_api_key | 401 | Key missing, malformed, unknown, revoked or expired. |
insufficient_scope | 403 | The key lacks the scope this endpoint needs (details.required_scope). |
ecommerce_disabled | 403 | E-commerce is not enabled for the account. |
rate_limited | 429 | Too many requests. Wait Retry-After seconds. |
validation_error | 400 | Bad body, query or path. details.issues lists the fields. |
not_found | 404 | Unknown resource (or wrong phone_last4 for an order). |
insufficient_stock | 409 | Reserved for when stock enforcement is enabled; not returned today. |
conflict | 409 | Order number collision. Retry. |
idempotency_conflict | 409 | Idempotency-Key reused with a different body. |
product_not_available | 422 | A product cannot be sold (inactive or not published). |
internal_error | 500 / 502 | Unexpected failure. Quote X-Request-Id to support. |
Pagination. /products takes limit (1-100, default 24) and page or offset. meta returns { total, limit, page, has_more }.
Caching. Read endpoints send Cache-Control: private, max-age=…: store configuration 300 s, catalog 60 s. Orders are never cached. Cache on your side with fetch tags and revalidate when the shop changes.
Rate limits. 120 requests/minute per key and endpoint (order creation: 20/minute; public endpoints: 60/minute per IP). Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset; a 429 adds Retry-After (seconds).
Request ids. Each response includes X-Request-Id. Log it; support can trace the request with it.
Idempotency. Send Idempotency-Key (8-128 chars) on POST /orders. A retry with the same key and body returns the original response with Idempotent-Replayed: true; the same key with a different body returns 409.
Endpoint reference
Machine-readable spec: /api/v1/openapi.json (OpenAPI 3.1, import it into Postman, Insomnia or a code generator).
GET/api/v1/healthLiveness probe
Public. No API key needed. Rate limited per IP (60/min).
curl "https://nshop.naqsh-tech.com/api/v1/health"{
"data": {
"status": "ok",
"version": "v1",
"time": "2026-10-10T09:00:00.000Z"
}
}Responses: 200429
GET/api/v1/storeStore configurationstore:read
Public company fields, e-commerce settings and the resolved theme. Cached for 300 s. Requires scope `store:read`.
curl "https://nshop.naqsh-tech.com/api/v1/store" \
-H "Authorization: Bearer $NSHOP_API_KEY"Responses: 200401403404429500
GET/api/v1/shipping-citiesShipping cities and their coststore:read
Use `city_name` as `customer_city` when placing an order. Cached 300 s. Requires `store:read`.
curl "https://nshop.naqsh-tech.com/api/v1/shipping-cities" \
-H "Authorization: Bearer $NSHOP_API_KEY"{
"data": [
{
"id": "6f1c1c1e-5a40-4e0d-9d52-1d1a0b8f3a11",
"city_name": "Ramallah",
"shipping_cost": 20,
"sort_order": 0
}
]
}Responses: 200401403429500
GET/api/v1/banner-statementsBanner statementsstore:read
Active announcement texts in display order. Cached 300 s. Requires `store:read`.
curl "https://nshop.naqsh-tech.com/api/v1/banner-statements" \
-H "Authorization: Bearer $NSHOP_API_KEY"Responses: 200401403429500
GET/api/v1/categoriesCategoriescatalog:read
Active categories, flat by default or nested with `tree=true`. Cached 60 s. Requires `catalog:read`.
| Name | In | Type | Description |
|---|---|---|---|
tree | query | string | Return a nested tree (`children`). |
curl "https://nshop.naqsh-tech.com/api/v1/categories" \
-H "Authorization: Bearer $NSHOP_API_KEY"Responses: 200400401403429500
GET/api/v1/productsList productscatalog:read
Active products with live price (offers applied) and an informational stock count, assembled in one database call. Paginate with `page` or `offset`; filter with `category_id`, `q`, `ids` or `updated_since`. Cached 60 s. Requires `catalog:read`.
| Name | In | Type | Description |
|---|---|---|---|
limit | query | integer | |
page | query | integer | 1-based page number. |
offset | query | integer | Alternative to `page`. |
category_id | query | string (uuid) | |
q | query | string | Search text. Case-insensitive substring match on name, SKU and barcode. |
updated_since | query | string (date-time) | Full ISO-8601 datetime, e.g. `2026-10-10T08:00:00Z` or `2026-09-21T06:45:26+00:00`. A date-only value such as `2026-09-21` returns 400 `validation_error`. Returns only products whose `updated_at` is on or after it (name-sorted like any listing). Use it for incremental sync. Products that were unpublished or deleted simply stop appearing and are not reported, so also do a full refresh periodically. An invalid value returns 400 `validation_error`. |
ids | query | string | Comma-separated product UUIDs (max 100). |
curl "https://nshop.naqsh-tech.com/api/v1/products" \
-H "Authorization: Bearer $NSHOP_API_KEY"{
"data": [],
"meta": {
"total": 0,
"limit": 24,
"page": 1,
"has_more": false
}
}Responses: 200400401403429500
GET/api/v1/products/{id}Get one productcatalog:read
Accepts the product id (UUID). Cached 60 s. Requires `catalog:read`.
| Name | In | Type | Description |
|---|---|---|---|
id * | path | string (uuid) |
curl "https://nshop.naqsh-tech.com/api/v1/products/<id>" \
-H "Authorization: Bearer $NSHOP_API_KEY"Responses: 200400401403404429500
GET/api/v1/best-sellersBest-selling productscatalog:read
Ranked by units sold in the last `days` days (cancelled and refunded orders excluded). Each product carries `units_sold`. Cached 60 s. Requires `catalog:read`.
| Name | In | Type | Description |
|---|---|---|---|
limit | query | integer | |
days | query | integer |
curl "https://nshop.naqsh-tech.com/api/v1/best-sellers" \
-H "Authorization: Bearer $NSHOP_API_KEY"Responses: 200400401403429500
POST/api/v1/ordersPlace an orderorders:write
Creates a cash-on-delivery order. Send only product ids and quantities: prices, discounts and tax are never accepted (unknown fields are rejected). Every line is priced server-side from live data including active offers, shipping comes from the city's configured cost. Stock is not checked at this endpoint today: the shop reviews orders in NShop. Rate limited to 20/min. Requires `orders:write`.
| Name | In | Type | Description |
|---|---|---|---|
|
curl -X POST "https://nshop.naqsh-tech.com/api/v1/orders" \
-H "Authorization: Bearer $NSHOP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"customer_name": "Ahmad Saleh",
"customer_phone": "0599123456",
"customer_city": "Ramallah",
"customer_address": "Al-Masyoun, Street 5, building 12",
"order_note": "Call before delivery",
"items": [
{
"product_id": "6f1c1c1e-5a40-4e0d-9d52-1d1a0b8f3a11",
"quantity": 2
}
]
}'{
"data": {
"id": "6f1c1c1e-5a40-4e0d-9d52-1d1a0b8f3a11",
"order_number": "ORD-2026-A1B2C3",
"status": "pending",
"subtotal": 100,
"shipping_amount": 20,
"discount_amount": 0,
"total_amount": 120
}
}Responses: 201400401403409422429500502
GET/api/v1/orders/{orderNumber}Track an orderorders:read
Requires the last four digits of the customer's phone (`phone_last4`). A wrong or missing match answers exactly like an unknown order number (404), so order numbers cannot be probed. The phone is returned masked. Requires `orders:read`.
| Name | In | Type | Description |
|---|---|---|---|
orderNumber * | path | string | |
phone_last4 * | query | string |
curl "https://nshop.naqsh-tech.com/api/v1/orders/<orderNumber>?phone_last4=<phone_last4>" \
-H "Authorization: Bearer $NSHOP_API_KEY"Responses: 200400401403404429500
Object reference
Field-by-field description of every object the API accepts or returns. A field marked required is always present; | null means the value can be null. Ignore fields you do not know: new fields can be added to responses at any time (see versioning).
ProductA sellable product as it should appear in the storefront: live price with offers applied and an informational stock count.
A sellable product as it should appear in the storefront: live price with offers applied and an informational stock count.
| Field | Type | Required | Description | Example |
|---|---|---|---|---|
id | string (uuid) | no | Product id. Use it as `items[].product_id` when ordering. | 6f1c1c1e-5a40-4e0d-9d52-1d1a0b8f3a11 |
name | string | no | Vitamin C cream | |
sku | string | null | no | Stock keeping unit. | CRM-001 |
barcode | string | null | no | 6290000000012 | |
description | string | null | no | Product description (may be null). | |
category_id | string | null (uuid) | no | | |
category_name | string | null | no | Skin care | |
images | string (uri)[] | no | Public image URLs, main image first. Empty array when the product has no images. | ["https://assets.example.com/p/cream-1.jpg"] |
price | number | no | Effective unit price AFTER the best active offer. This is what the customer pays per unit; show and charge this. | 42.5 |
compare_at_price | number | null | no | Original list price, set only when an offer applies (use it for a crossed-out price). Null when there is no offer. | 50 |
offer | Offer | null | no | The offer that produced `price`, or null. | |
in_stock | boolean | no | Informational, for display: `true` when `stock_quantity` is greater than 0. | true |
stock_quantity | integer | no | Informational, for display: the sum of (quantity - reserved_stock) over all of the company's shops and warehouses, never negative. The API does not currently reject orders that exceed stock, so do not rely on it as a guarantee. | 18 |
updated_at | string (date-time) | no | Latest change of the product or of its storefront cache entry. Use it with `updated_since` to sync incrementally. | 2026-09-21T06:45:26.062544+00:00 |
OfferThe best active promotion applied to a product
The best active promotion applied to a product. The discount is already included in `Product.price`.
| Field | Type | Required | Description | Example |
|---|---|---|---|---|
id | string (uuid) | no | 6f1c1c1e-5a40-4e0d-9d52-1d1a0b8f3a11 | |
kind | string | no | `percent`: `value` is a percentage off the list price. `fixed`: `value` is an amount off the list price (never below zero). one of: percent, fixed | percent |
value | number | no | Percent or amount, depending on `kind`. | 15 |
ends_at | string | null (date-time) | no | When the offer ends; null for open-ended offers. Use it for countdowns. | 2026-10-31T21:00:00.000Z |
CategoryA product category
A product category. With `tree=true`, children are nested.
| Field | Type | Required | Description | Example |
|---|---|---|---|---|
id | string (uuid) | no | 6f1c1c1e-5a40-4e0d-9d52-1d1a0b8f3a11 | |
name | string | no | Skin care | |
description | string | null | no | | |
parent_id | string | null (uuid) | no | Parent category id, or null for a top-level category. | |
level | integer | null | no | Depth in the tree (0 = top level). | 0 |
sort_order | integer | null | no | Display order among siblings (ascending). | 1 |
icon_url | string | null (uri) | no | Category icon image URL. | |
children | Category[] | no | Present only with `tree=true`. | |
ShippingCityA city the shop delivers to, with its delivery cost.
A city the shop delivers to, with its delivery cost.
| Field | Type | Required | Description | Example |
|---|---|---|---|---|
id | string (uuid) | no | 6f1c1c1e-5a40-4e0d-9d52-1d1a0b8f3a11 | |
city_name | string | no | Send this exact value (case-insensitive) as `customer_city` when ordering. | Ramallah |
shipping_cost | number | no | Delivery cost in the shop currency. Added to the order total by the server. | 20 |
sort_order | integer | null | no | Display order (ascending). | 0 |
BannerStatementA short announcement line shown above the store.
A short announcement line shown above the store.
| Field | Type | Required | Description | Example |
|---|---|---|---|---|
id | string (uuid) | no | 6f1c1c1e-5a40-4e0d-9d52-1d1a0b8f3a11 | |
body | string | no | The text to display. | Free delivery over 200 ILS |
sort_order | integer | null | no | Display order (ascending). | 0 |
StoreEverything needed to brand and configure a storefront, in one call.
Everything needed to brand and configure a storefront, in one call.
| Field | Type | Required | Description | Example |
|---|---|---|---|---|
company | Company | no | | |
settings | Settings | no | | |
theme | Theme | no | |
CompanyPublic storefront identity of the company (`Store.company`)
Public storefront identity of the company (`Store.company`). Only these whitelisted fields are ever returned; internal data is never exposed.
| Field | Type | Required | Description | Example |
|---|---|---|---|---|
name | string | no | Legal/business name. | Al-Baraka Store |
ecommerce_store_name | string | null | no | Display name of the online store. Prefer this over `name` in the UI. | Al-Baraka Online |
ecommerce_description | string | null | no | Short store description for the home page and SEO. | |
ecommerce_logo_url | string | null (uri) | no | Logo image URL. | |
ecommerce_banner_url | string | null (uri) | no | Main hero banner image URL. | |
ecommerce_small_banner_2_url | string | null (uri) | no | Secondary banner image URL. | |
ecommerce_small_banner_3_url | string | null (uri) | no | Third banner image URL. | |
ecommerce_whatsapp | string | null | no | Store WhatsApp number for customer chat. | 970599123456 |
ecommerce_social_links | object | no | Social profile links keyed by network (e.g. `facebook`, `instagram`). | {"instagram":"https://instagram.com/albaraka"} |
ecommerce_contact_info | object | null | no | Contact details (phone, email, address) as configured by the shop. | |
ecommerce_policies | object | no | Policy texts (shipping, returns, privacy) as configured by the shop. | |
ecommerce_storefront_url | string | null (uri) | no | Public URL of the storefront. | |
ecommerce_currency | string | null | no | Currency code for the online store. Use this for display; fall back to `currency` when null. | ILS |
currency | string | null | no | The company's accounting currency code (ILS, USD or JOD). | ILS |
ecommerce_language | string | null | no | Storefront language code. | ar |
ecommerce_theme | string | null | no | Name of the selected theme (matches `Theme.name`). | modern |
SettingsE-commerce settings row (`Store.settings`)
E-commerce settings row (`Store.settings`). `null` when the shop has not saved any settings yet.
| Field | Type | Required | Description | Example |
|---|---|---|---|---|
theme_config | object | null | no | Per-store theme overrides (colors, fonts, layout options). | |
payment_methods | object | array | null | no | Payment options the shop configured. Orders created through the API are cash on delivery. | |
shipping_options | object | array | null | no | Shipping options configured by the shop. Costs per city come from `/shipping-cities`. | |
currency | string | null | no | ILS | |
language | string | null | no | ar | |
timezone | string | null | no | IANA timezone of the shop. | Asia/Hebron |
business_hours | object | null | no | Opening hours by weekday. | |
social_links | object | null | no | Social links configured in settings. | |
seo_settings | object | null | no | SEO defaults (title, description, keywords). | |
updated_at | string | null (date-time) | no | Last time the settings changed. | |
ThemeThe resolved theme (`Store.theme`)
The resolved theme (`Store.theme`). `null` when the store has no active theme.
| Field | Type | Required | Description | Example |
|---|---|---|---|---|
name | string | no | Theme identifier (matches `Company.ecommerce_theme`). | modern |
display_name | string | no | Human-readable theme name. | Modern |
description | string | null | no | | |
config | object | no | Theme defaults (colors, typography, layout). | |
preview_images | array | null | no | Preview screenshots. | |
is_premium | boolean | null | no | Whether the theme is a premium theme. | |
CreateOrderRequestBody of `POST /api/v1/orders`
Body of `POST /api/v1/orders`. Unknown fields (for example `unit_price`, `total_price`, `discount_amount`, `tax_amount`, `payment_method`) are rejected with 400.
| Field | Type | Required | Description | Example |
|---|---|---|---|---|
customer_name | string | yes | Customer full name. Control characters are stripped and the value is trimmed. 2-200 chars | Ahmad Saleh |
customer_phone | string | yes | Palestinian mobile. Accepts 05XXXXXXXX, +9705XXXXXXXX, +9725XXXXXXXX, 009705XXXXXXXX and Arabic digits; stored normalized as 05XXXXXXXX. | 0599123456 |
customer_phone2 | string | no | Optional second phone, same rule as `customer_phone`. | 0569123456 |
customer_email | string (email) | no | Optional, validated as an email address. 0-320 chars | ahmad@example.com |
customer_city | string | yes | Must match a `city_name` from `/api/v1/shipping-cities` (case and spacing insensitive) when the store configured cities. Shipping is charged from that city's cost. | Ramallah |
customer_address | string | yes | Delivery address; at least 5 non-space characters. 5-2000 chars | Al-Masyoun, Street 5, building 12 |
order_note | string | no | Optional note for the shop. 0-2000 chars | Call before delivery |
items | object[] | yes | 1 to 50 order lines. Send ids and quantities only. 1-50 items | |
items[].product_id | string (uuid) | yes | `Product.id`. | 6f1c1c1e-5a40-4e0d-9d52-1d1a0b8f3a11 |
items[].quantity | integer | yes | Units to buy (1-9999). 1 to 9999 | 2 |
OrderCreatedReturned by `POST /api/v1/orders` (HTTP 201).
Returned by `POST /api/v1/orders` (HTTP 201).
| Field | Type | Required | Description | Example |
|---|---|---|---|---|
id | string (uuid) | no | 6f1c1c1e-5a40-4e0d-9d52-1d1a0b8f3a11 | |
order_number | string | no | Customer-facing order number. Show it on the confirmation page and use it to track the order. | ORD-2026-A1B2C3 |
status | OrderStatus | no | | |
subtotal | number | no | List-price total of the lines before offers and shipping. | 100 |
shipping_amount | number | no | Shipping charged, taken from the city's cost. | 20 |
discount_amount | number | no | Promotional saving computed by the server from active offers (never client supplied). | 0 |
total_amount | number | no | Amount the customer pays on delivery: lines after offers + shipping. The source of truth for the confirmation page. | 120 |
OrderReturned by `GET /api/v1/orders/{orderNumber}`.
Returned by `GET /api/v1/orders/{orderNumber}`.
| Field | Type | Required | Description | Example |
|---|---|---|---|---|
order_number | string | no | ORD-2026-A1B2C3 | |
customer_name | string | no | Ahmad Saleh | |
customer_phone_masked | string | null | no | All but the last four digits replaced by `*`. | ******3456 |
customer_address | object | string | null | no | Delivery address as stored (includes the city). | |
subtotal | number | no | 100 | |
shipping_amount | number | no | 20 | |
tax_amount | number | no | Always 0 for API orders. | 0 |
discount_amount | number | no | 0 | |
total_amount | number | no | 120 | |
status | OrderStatus | no | | |
payment_method | string | no | `cash` for API orders. | cash |
payment_status | PaymentStatus | no | | |
shipping_method | string | null | no | Set by the shop when it ships the order. | |
tracking_number | string | null | no | Carrier tracking number, filled in by the shop. | |
notes | string | null | no | The customer's order note. | |
created_at | string (date-time) | no | 2026-10-10T09:15:00.000Z | |
updated_at | string (date-time) | no | 2026-10-10T10:02:00.000Z | |
items | OrderItem[] | no | |
OrderItemA line of an order, snapshotted at purchase time (later price changes do not alter it).
A line of an order, snapshotted at purchase time (later price changes do not alter it).
| Field | Type | Required | Description | Example |
|---|---|---|---|---|
product_id | string | null (uuid) | no | Null if the product was later deleted. | |
product_name | string | no | Vitamin C cream | |
product_sku | string | null | no | CRM-001 | |
quantity | integer | no | 2 | |
unit_price | number | no | Unit price charged (offer applied). | 42.5 |
total_price | number | no | `unit_price` x `quantity`. | 85 |
product_image_url | string | null (uri) | no | |
OrderStatusOrder lifecycle status
Order lifecycle status. Orders created through this API start as `pending`. The shop owner changes the status in NShop; the API never does. Stock is adjusted by the shop in NShop for now: the API and the status changes do not reserve, deduct or release stock automatically.
one of: pending, confirmed, processing, shipped, delivered, cancelled, refunded
PaymentStatusPayment state, maintained by the shop
Payment state, maintained by the shop. API orders are cash on delivery and start as `pending`.
one of: pending, paid, failed, refunded
PageMetaPagination info returned next to `data` on list endpoints.
Pagination info returned next to `data` on list endpoints.
| Field | Type | Required | Description | Example |
|---|---|---|---|---|
total | integer | yes | Total matching rows across all pages. | 11 |
limit | integer | yes | Page size used. | 24 |
page | integer | yes | 1-based page number returned. | 1 |
has_more | boolean | yes | `true` when another page exists. | false |
ErrorEvery non-2xx response has this shape.
Every non-2xx response has this shape.
| Field | Type | Required | Description | Example |
|---|---|---|---|---|
error | object | yes | | |
error.code | string | yes | Stable machine-readable code. Branch on this, not on `message`. `insufficient_stock` is reserved for when stock enforcement is enabled and is not returned today. one of: invalid_api_key, insufficient_scope, ecommerce_disabled, rate_limited, validation_error, not_found, conflict, insufficient_stock, product_not_available, idempotency_conflict, internal_error | validation_error |
error.message | string | yes | Human-readable English text. May change; do not parse it. | The API key is invalid, revoked or expired. |
error.details | object | no | Extra context, e.g. `product_id`, `required_scope` or `issues` (validation failures). | {"product_id":"6f1c1c1e-5a40-4e0d-9d52-1d1a0b8f3a11"} |
Placing and tracking orders
// 1. Cities and costs (cache 1h) GET /api/v1/shipping-cities
// 2. Live product data for the cart GET /api/v1/products?ids=<uuid>,<uuid>
// 3. Place the order (server-side) POST /api/v1/orders + Idempotency-Key
// 4. Show the confirmation use data.order_number and data.total_amount
// 5. Track later GET /api/v1/orders/{n}?phone_last4=1234Send ids and quantities only. The server prices every line from live data (including active offers), takes the shipping cost from the selected city and applies no tax and no manual discount. Show the cart total as an estimate and the order response as the source of truth. Stock is not enforced yet: the API does not reject an order that exceeds stock, so the shop reviews every order in NShop. stock_quantity and in_stock are for display only.
Request fields and limits
| Field | Rule | Notes |
|---|---|---|
customer_name | required, 2-200 chars | Control characters stripped, trimmed. |
customer_phone | required, Palestinian mobile | 05XXXXXXXX, +9705XXXXXXXX, +9725XXXXXXXX, 009705XXXXXXXX or Arabic digits; stored as 05XXXXXXXX. |
customer_phone2 | optional, same rule | Second contact number. |
customer_email | optional, valid email, max 320 | Validated as an address. |
customer_city | required | Must match a city_name from /shipping-cities (case and spacing insensitive) when the store has cities. Sets the shipping cost. |
customer_address | required, 5-2000 chars | At least 5 non-space characters. |
order_note | optional, max 2000 chars | Shown to the shop. |
items | required, 1-50 lines | Each line: product_id (UUID) and quantity (integer 1-9999). Nothing else. |
unit_price, total_price, discount_amount, tax_amount, shipping_amount, payment_method | not accepted | Unknown fields are rejected with 400 validation_error. The server prices everything. |
Retries. Generate one Idempotency-Key per checkout attempt (for example when the customer opens checkout) and reuse it on retries after timeouts or 502s. You will never create two orders.
Tracking. Ask the customer for their order number and the last four digits of their phone. A wrong combination answers 404 not_found, identical to an unknown order, and the phone is returned masked.
Order lifecycle
status can be pending, confirmed, processing, shipped, delivered, cancelled or refunded; payment_status can be pending, paid, failed or refunded. The shop owner changes them in NShop; the API only reads them. The API creates orders as cash on delivery, so status and payment_status both start as pending and becomes paid when the shop records the payment. tracking_number and shipping_method are filled in by the shop when it ships. Poll GET /orders/{n} (no more than every few minutes) to refresh a tracking page.
| Status | Meaning | Stock effect |
|---|---|---|
pending | Order received. This is the status of every new API order; payment_status is pending too. | Stock is adjusted by the shop in NShop for now. |
confirmed | The shop reviewed and accepted the order. | Same: handled by the shop. |
processing | The shop is preparing the order. | Same: handled by the shop. |
shipped, delivered | On its way / handed over. | Same: handled by the shop. |
cancelled, refunded | Order closed without fulfilment. | Same: the shop restocks manually if needed. |
When something goes wrong
| Situation | What you get | What to do |
|---|---|---|
| Item sold out meanwhile | Not rejected today (no stock enforcement); insufficient_stock is reserved for later | Show stock_quantity as a hint and refresh the cart from GET /products?ids= before checkout; the shop reviews the order. |
| Product unpublished | 422 product_not_available | Remove the line. |
| Unknown city | 400 validation_error (customer_city) | Offer the cities from /shipping-cities. |
| Timeout / 502 | No response or internal_error | Retry with the same Idempotency-Key. |
| 429 | rate_limited + Retry-After | Back off, then retry. |
Keeping your catalog fresh
NShop does not push changes to your site. Your storefront decides how fresh the catalog is, in one of two ways: cache reads with a short TTL, or keep a local copy and sync it incrementally with updated_since.
Option 1: cache with a TTL. Fetch on demand and cache on your side (Next.js: fetch with next: { revalidate: 60, tags: ["storefront-catalog"] }). Suggested TTLs: products and categories 60-600 s, store settings, theme, shipping cities and banners 300-3600 s, orders never. To refresh sooner, call your own revalidate endpoint (for example /api/revalidate with revalidateTag) whenever you need a refresh, such as from a scheduled job. This replaces the old push from the POS.
Option 2: incremental sync. GET /products?updated_since=<ISO-8601> returns only products whose updated_at is on or after that instant (a full ISO-8601 datetime such as 2026-09-21T06:45:26+00:00; a date-only value like 2026-09-21 returns 400 validation_error) (still name-sorted and paginated). Store the timestamp of your last successful sync, subtract a few minutes of overlap, and merge the result by product id. An invalid value returns 400 validation_error.
Limitation. A product that was unpublished, deactivated or deleted stops appearing in listings; it is not reported as deleted. After an incremental sync, drop local products that are missing from a full listing: run a full refresh periodically (for example daily, or at night) by paging through /products without updated_since. Always re-read price (and stock_quantity, which is informational) from the API at checkout time (GET /products?ids=), never from your copy.
// Incremental catalog sync (run every few minutes, on your server)
const OVERLAP_MS = 5 * 60 * 1000; // re-read a 5 minute overlap so nothing is missed
async function syncCatalog(lastSyncIso: string | null) {
const startedAt = new Date();
const since = lastSyncIso ? new Date(new Date(lastSyncIso).getTime() - OVERLAP_MS).toISOString() : undefined;
let page = 1;
for (;;) {
const qs = new URLSearchParams({ limit: "100", page: String(page) });
if (since) qs.set("updated_since", since);
const res = await fetch("https://nshop.naqsh-tech.com/api/v1/products?" + qs, {
headers: { Authorization: "Bearer " + process.env.NSHOP_API_KEY },
});
if (!res.ok) throw new Error("sync failed: " + res.status);
const { data, meta } = await res.json();
for (const product of data) await db.products.upsert(product.id, product); // merge by id
if (!meta.has_more) break;
page += 1;
}
return startedAt.toISOString(); // store this as the new last-sync timestamp
}Retries, timeouts and error handling
Set a client timeout of about 10 seconds. Retry only 429, 500, 502 and network errors, with exponential backoff plus jitter (and honour Retry-After on 429). Never retry other 4xx errors: the request itself is wrong (fix it). When you retry POST /orders, send the same Idempotency-Key so the order can never be created twice. Log the X-Request-Id of failed calls.
| Status | Retry? | Action |
|---|---|---|
| 200 / 201 | - | Done. |
| 400, 404, 422 | No | Fix the request or remove the item. |
| 401, 403 | No | Check the key, its scopes and that e-commerce is enabled. |
| 409 | Only `conflict` | conflict: retry once with the same Idempotency-Key. idempotency_conflict: you reused a key with a different body. |
| 429 | Yes | Wait Retry-After seconds. |
| 500, 502, timeout | Yes | Backoff + jitter, same Idempotency-Key for orders. |
async function withRetry<T>(call: () => Promise<Response>, maxAttempts = 4): Promise<Response> {
for (let attempt = 1; ; attempt++) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 10_000); // 10 s timeout
try {
const res = await call(/* pass controller.signal to fetch */);
if (res.status !== 429 && res.status !== 500 && res.status !== 502) return res; // success or a 4xx: do not retry
if (attempt === maxAttempts) return res;
const retryAfter = Number(res.headers.get("Retry-After")) * 1000;
const backoff = Math.min(8000, 500 * 2 ** attempt) * (0.5 + Math.random()); // exponential + jitter
await new Promise((r) => setTimeout(r, retryAfter || backoff));
} catch (err) {
if (attempt === maxAttempts) throw err; // network error or timeout
await new Promise((r) => setTimeout(r, 500 * 2 ** attempt * (0.5 + Math.random())));
} finally {
clearTimeout(timer);
}
}
}Good to know
Server-to-server only (CORS). CORS is not enabled on the API endpoints (only the public openapi.json file is readable from anywhere), so browsers cannot call the API directly, and they must never hold a key anyway. Call NShop from your server (route handlers, server components, a backend) and expose only the data you need to your front end.
Money and currency. Amounts are JSON numbers with up to 2 decimals, in the shop currency (GET /store returns company.ecommerce_currency, falling back to company.currency). Do not add or multiply prices as floats: compute in integer cents or a decimal library and round only for display. Offers are already inside price; do not subtract them again. Use compare_at_price only for a crossed-out price.
Search. q (max 100 chars) is a case-insensitive substring match on product name, SKU and barcode. It is not fuzzy and does not rank results; listings are sorted by name.
Images. images holds public URLs, main image first, and is an empty array when a product has none. Treat them like any external image: cache them, or serve them through your image optimizer (for example next/image with the host allowed in remotePatterns), and show a placeholder when the array is empty.
Postman and code generators. Import /api/v1/openapi.json into Postman or Insomnia, then create a collection variable apiKey and set the collection authorization to Bearer Token with {{apiKey}}. The same file works with OpenAPI Generator to produce a client in your language.
Versioning and deprecation
The version is in the path (/api/v1). Inside v1 we only make additive changes: new endpoints, new optional query parameters, new response fields and new enum values. Your client must ignore unknown fieldsand tolerate unknown enum values. A breaking change (removing or renaming a field, changing a type or meaning) ships as v2, and v1 keeps working for at least 6 months after v2 is announced. Every change is listed in the changelog below.
Support
Contact NShop on WhatsApp: 00972595856529. Always include the X-Request-Id response header of the failing call (and the time, the endpoint and the HTTP status); it lets us find the exact request. Never send your full API key: the first characters (for example nsk_live_ab12cd) are enough to identify it.
Migrating from direct database calls
Replace each database function with the matching API call and delete the Supabase client. Your server components keep working: only the data functions change. Keep the storefront-config (1 h) and storefront-catalog(10 min) tags on your fetch calls so your existing /api/revalidate hook still busts the cache. The full step-by-step guide is in docs/api/storefront-migration.md.
| Today | Use instead | Notes |
|---|---|---|
getServerSupabase, COMPANY_ID, SUPABASE_SERVICE_ROLE_KEY | NSHOP_API_KEY + NSHOP_API_BASE_URL | Delete the Supabase client and all SUPABASE_* env vars. |
getCompanyData | GET /api/v1/store → company | Whitelisted public fields only. |
getEcommerceSettings | GET /api/v1/store → settings | Same call as above. |
getEcommerceTheme(name) | GET /api/v1/store → theme | Already resolved from the store's theme. |
getEcommerceShippingCities | GET /api/v1/shipping-cities | |
getEcommerceBannerStatements | GET /api/v1/banner-statements | |
getEcommerceProducts | GET /api/v1/products | Server-side pagination; the ~150-request assembly is gone. |
getEcommerceProductById | GET /api/v1/products/{id} | |
getEcommerceCategoryTree | GET /api/v1/categories?tree=true | |
getBestSellerProductIds | GET /api/v1/best-sellers | Aggregated in SQL. |
getEcommerceOrderByNumber | GET /api/v1/orders/{n}?phone_last4= | Now requires the phone's last four digits. |
ecommercePlaceOrderRpc, POST /api/orders | POST /api/v1/orders | Send only product_id + quantity. |
POST /api/revalidate | Keep it (it is yours) | Rebuild the cache with fetch tags; for fresher data use updated_since (see Keeping the catalog fresh). |
getEcommerceCategories, getEcommerceCategoryIconUrls, getStorefrontCategoryNavSkeleton, place-order.ts | Delete | Dead code. |
Changelog
| Version | Date | Changes |
|---|---|---|
| v1.1.0 | 2026-10-10 | GET /products accepts updated_since for incremental sync. Documentation: object reference, order lifecycle and statuses, retries, versioning policy. |
| v1.0.0 | 2026-10-10 | Initial release: store, shipping cities, banners, categories, products, best sellers, order placement (idempotent) and tracking. Key-based authentication with scopes, rate limiting and request ids. |