# GINI E-Commerce as a Service — API Reference

**Application-Scoped Third-Party API · v1.1**

Any application can sell what GINI's merchants sell without building a marketplace. A partner receives a curated slice of the GINI catalog through signed, read-only APIs, hands the sale to GINI through an encrypted checkout token, and is told about every order that results — by webhook and by a scoped orders API.

| | |
|---|---|
| **Base URL** | `https://<api-host>/api/third-party/v1/applications` |
| **Protocol** | REST / JSON. All endpoints are `GET` except `POST /checkout-tokens`. |
| **Authentication** | HMAC-SHA256 request signing on every call — see [§2](#2-authentication) |
| **Scope** | Everything is filtered by the application's **merchant whitelist** — see [§4](#4-visibility-model) |
| **Errors** | `401` for authentication, `400 { key, message }` for validation and business rules, `404` for anything outside the partner's scope |
| **Tooling** | [Developer console & sandbox](../README.md#developer-console-playground), [MCP server](../README.md#mcp-server) |

---

## Contents

**Part I — Foundations**
1. [Integration model](#1-integration-model)
2. [Authentication](#2-authentication)
3. [Conventions and errors](#3-conventions-and-errors)
4. [Visibility model](#4-visibility-model)

**Part II — Catalog** *(read-only, stateless, usable on its own)*
5. [List merchants](#5-list-merchants) — `GET /merchants`
6. [List categories](#6-list-categories) — `GET /categories`
7. [List sub-categories](#7-list-sub-categories) — `GET /sub-categories`
8. [List products](#8-list-products) — `GET /products`
9. [Product details](#9-product-details) — `GET /products/{slug}`

**Part III — Checkout and orders**
10. [Create checkout token](#10-create-checkout-token) — `POST /checkout-tokens`
11. [Orders](#11-orders) — `GET /orders/{id}`, `GET /orders`
12. [Order webhooks](#12-order-webhooks)

**Part IV — Governance**
13. [Security rules](#13-security-rules)
14. [Operational notes](#14-operational-notes)
15. [Change log and migration notes](#15-change-log-and-migration-notes)

**Appendices**
- [A. Reference signers](#appendix-a-reference-signers) (PHP · Node.js · cURL)
- [B. Error catalog](#appendix-b-error-catalog)
- [C. Enumerations](#appendix-c-enumerations)
- [D. Endpoint index](#appendix-d-endpoint-index)

---

# Part I — Foundations

## 1. Integration model

GINI provisions an **application** for the partner. It consists of:

| Provisioned item | Purpose |
|---|---|
| `app_id` | Public identifier. Sent on every request as `X-API-KEY`. It identifies; it does not authorize. |
| `app_secret` | Signs every request and every webhook. Never leaves the partner's server, never ships in client-side code. |
| **Merchant whitelist** | The merchants (clients) the application may see. The boundary of every response and every checkout. |
| **Enabled payment methods** | The `payment_method` values the application may put in a checkout token. `post_paid` must be granted explicitly. |
| `webhook_url` | Where GINI POSTs order events. |
| **Checkout URL / deeplink format** | How the partner hands a token to GINI's checkout. |

The partner builds its own storefront. GINI owns the merchants, inventory, pricing, the checkout, payment collection, fulfillment status, and merchant settlement.

### Lifecycle of a sale

```
 Partner app                         Partner backend                    GINI
 ───────────                         ───────────────                    ────
 browse catalog  ─────────────────▶  signed GET /merchants|/categories|/products
                 ◀─────────────────  whitelisted, visible catalog
 shopper fills a cart (lives in the app)
 "Checkout"      ─────────────────▶  signed POST /checkout-tokens ───▶  seal intent → token, cart_id
                 ◀─────────────────  token
 open GINI checkout with the token ───────────────────────────────▶  token bound to this customer,
                                                                        cart filled, address/shipping/
                                                                        payment collected, token spent
                                     ◀── webhook order.created (cart_id, external_id) ──
                                     ◀── webhook order.status_updated … ──────────────
                                     signed GET /orders/{id} on demand ───────────────▶
```

Onboarding happens once; browse → buy → track repeats for every sale with the same credentials.

### Division of responsibility

| Responsibility | Partner | GINI | Merchant |
|---|:-:|:-:|:-:|
| Storefront UI and browsing experience | **owns** | – | – |
| Merchant whitelist, enabled payment methods, application scope | – | **owns** | contributes |
| Product content, price and stock of record | – | **owns** (system of record) | **owns** (authors) |
| Checkout experience and order capture | – | **owns** | – |
| Delivery address when the partner already knows it | **owns** (values sent in the token) | enforces | – |
| Payment collection from the shopper | – | **owns** | – |
| Fulfillment and delivery | – | contributes | **owns** |
| Order status as source of truth | – | **owns** | contributes |
| Merchant settlement | – | **owns** | – |
| First-line support to the shopper | **owns** | contributes | – |
| Credential custody, request signing, webhook verification | **owns** | contributes | – |

The value served to the partner and enforced at checkout is always **GINI's resolved value** — never a number the application supplied. Prices are never sent to GINI; the checkout intent carries product ids and quantities only.

## 2. Authentication

Every request **must** carry three headers:

| Header | Description |
|---|---|
| `X-API-KEY` | Your `app_id`. |
| `X-TIMESTAMP` | Current Unix time in **seconds**, UTC. |
| `X-SIGNATURE` | Hex-encoded HMAC-SHA256 of the *string to sign*. |

TLS is required. Signing replaces neither transport encryption nor the obligation to keep the secret server-side.

### String to sign

```
<METHOD><PATH><CANONICAL_QUERY><BODY><TIMESTAMP>

X-SIGNATURE = hex( HMAC_SHA256( app_secret, stringToSign ) )
```

- `METHOD` — uppercase HTTP method.
- `PATH` — the request path **including** the `/api/…` prefix, e.g. `/api/third-party/v1/applications/products`.
- `CANONICAL_QUERY` — the canonical query string prefixed with `?`, or the empty string when the request has no query parameters (omit the `?` entirely in that case).
- `BODY` — the raw request body. Empty for `GET`. For `POST /checkout-tokens`, sign **exactly the bytes you send**.
- `TIMESTAMP` — the exact value sent in `X-TIMESTAMP`.

### Canonical query string

The query string you *sign* must be canonical. The URL you *send* need not be — the server normalizes before verifying.

1. Sort parameters by key, byte-wise ascending (`max_price` before `page`).
2. Percent-encode keys and values per RFC 3986 — a space is `%20`, never `+`.
3. Join as `key=value` pairs with `&`.
4. Index array parameters the way PHP's `http_build_query` does: `categories[]=1&categories[]=2` canonicalizes to `categories%5B0%5D=1&categories%5B1%5D=2`.

In PHP this is exactly:

```php
parse_str($rawQuery, $params);
ksort($params);
$canonical = http_build_query($params, '', '&', PHP_QUERY_RFC3986);
```

Full signers in PHP, Node.js and cURL are in [Appendix A](#appendix-a-reference-signers).

### Verification and failure

| Condition | Response |
|---|---|
| Any of the three headers missing | `401 { "message": "Missing authentication headers" }` |
| Unknown or inactive `app_id` | `401 { "message": "Invalid API key" }` |
| `\|now − X-TIMESTAMP\| > 300` seconds | `401 { "message": "Request expired" }` |
| Signature mismatch | `401 { "message": "Invalid signature" }` |

> **Debugging an unexpected `401 Invalid signature`**, in order of frequency: the signed query was not canonical (keys unsorted, `+` used for spaces, or the leading `?` present when there are no parameters); `X-TIMESTAMP` differs between what was signed and what was sent; clock skew above five minutes; a secret that does not belong to the `app_id`.

> **Migration note.** Signatures computed under the previous scheme — `<METHOD><PATH><BODY><TIMESTAMP>`, query string excluded — are still accepted during a transition window, but that scheme leaves query parameters unauthenticated and **will be turned off**. Migrate signers to include the canonical query string. The sandbox reports which scheme matched in an `X-Signature-Scheme: canonical | legacy` response header.

## 3. Conventions and errors

### Response envelope

Every list endpoint returns the same shape:

```json
{
  "data":       [ /* array of resources */ ],
  "hasMore":    true,
  "totalPages": 12,
  "totalCount": 230
}
```

`GET /products` additionally returns `search_id`, reserved for search analytics and currently always `null`. Ignore it.

### Conventions

- All catalog endpoints are `GET` and return JSON. Only `/checkout-tokens` is a `POST`.
- Pagination is `page` (default 1) and `page_size`. Defaults differ per endpoint — 20 for merchants and orders, 7 for products, 100 for categories, 4 for sub-categories — and the maximum is 100.
- **Naming gotcha:** the merchants endpoint's search parameter is `q`; every other endpoint uses `query`.
- Standard API throttling applies. On `429`, back off.
- Category and sub-category responses are cached server-side; catalog changes can take a few minutes to appear.
- Conditional fields are emitted only when present on the underlying row: treat every field except `id` as possibly absent.

### Error responses

Authentication and authorization failures return `401` with `{ "message": "…" }` — the four messages in [§2](#verification-and-failure).

Validation and business errors return **`400`** (not `422`) with:

```json
{ "key": "<error key>", "message": "<localized message>" }
```

Every invalid parameter — a bad `sort_by`, `page_size` out of range, non-CSV `categories`, an unknown `category_id`, a product outside the whitelist — comes back as a `400` in this shape. Some business errors add fields (for example `available` on an over-stock checkout token). Do not test for `422`.

Anything outside the partner's scope — a product of a non-whitelisted merchant, another application's order — is a `404` **indistinguishable from a missing resource**. The full catalog of keys and messages is in [Appendix B](#appendix-b-error-catalog).

## 4. Visibility model

A merchant on your whitelist is still hidden from every response unless **all** of the following hold on the server side:

- `is_active = 1`
- `is_miniapp_live = 1`
- `domain IS NOT NULL`

Products appear only when they are **sellable**: visible in the catalog and in stock or available for pre-order. Categories and sub-categories appear only when at least one whitelisted, visible merchant has a sellable product under them.

If your whitelist is empty, or every whitelisted merchant is hidden, every list endpoint returns `200` with an empty envelope:

```json
{ "data": [], "hasMore": false, "totalPages": 0, "totalCount": 0 }
```

**Absent, not forbidden.** A merchant identifier sent in a query to widen scope (`client_id`, `client_ids`) is silently dropped — neither honoured nor rejected — so a partner cannot widen its own visibility by guessing ids.

---

# Part II — Catalog

Read-only, stateless, side-effect free. An application can integrate this part alone to display GINI products without ever placing an order.

## 5. List merchants

```
GET /merchants
```

Returns the whitelisted, visible merchants, ordered alphabetically by `business_name` — or **nearest-first** when a location is given.

| Parameter | Type | Required | Notes |
|---|---|---|---|
| `q` | string | no | Partial match over `business_name` (max 255). **`q`, not `query`.** |
| `category_id` | int | no | Restrict to merchants linked to this category. Must exist — an unknown id is a `400`, not an empty list. |
| `lat` | numeric | no* | Latitude of the search point (−90…90). *Required together with `lng`. |
| `lng` | numeric | no* | Longitude of the search point (−180…180). *Required together with `lat`. |
| `distance` | numeric | no | Radius in **kilometers** (0.1–1000). Requires `lat`+`lng`. Only merchants within the radius are returned. |
| `page` | int | no | Default 1. |
| `page_size` | int | no | Default 20, max 100. |

With `lat`+`lng`, results are ordered nearest-first, each item gains `distance_km` (rounded to two decimals), and merchants without stored coordinates are excluded. Without a location, `distance_km` is absent and ordering is alphabetical.

**Response item — `ClientResource`**

```json
{
  "id": 12,
  "name": "Acme Store",
  "business_name": "Acme Store",
  "business_phone": "+9647000000000",
  "logo": "https://…/logo.png",
  "cover": "https://…/cover.png",
  "state": "BAGHDAD",
  "state_label": "بغداد",
  "district": "KARRADA",
  "district_label": "الكرادة",
  "latitude": 33.31,
  "longitude": 44.36,
  "domain": "acme",
  "is_verified": 1,
  "funding_type": "self",
  "rating": 4.6,
  "rating_count": 120,
  "avg_response_time": 8,
  "has_pre_order": false,
  "discount_percentage": 0,
  "distance_km": 1.42
}
```

## 6. List categories

```
GET /categories
```

The full category tree in one call: every category that has at least one sellable product from a whitelisted, visible merchant, with its sub-categories nested inside. A sub-category with zero such products is omitted; a category whose products are all gone is omitted entirely. A partner whose merchants sell only electronics never sees a Groceries branch. This endpoint supersedes calling `/categories` and `/sub-categories` separately.

| Parameter | Type | Required | Notes |
|---|---|---|---|
| `query` | string | no | Free-text search over the category name (max 255). |
| `with_products` | bool | no | `1` → embed up to `products_size` products *per sub-category*, from whitelisted merchants only. |
| `products_size` | int | no | Products per sub-category when `with_products=1`. Default 7, max 20. |
| `page` | int | no | Default 1. |
| `page_size` | int | no | Default 100, max 100. Pagination applies to main categories; sub-categories are never paginated — a category always arrives with its full sub-category set. |

**Response item — `CategoryResource`**

```json
{
  "id": 3,
  "name": "Electronics",
  "slug": "electronics",
  "is_active": 1,
  "image": "https://…/cat.png",
  "icon": "https://…/icon.png",
  "description": "…",
  "products_count": 42,
  "subcategories": [
    {
      "id": 17,
      "category_id": 3,
      "name": "Phones",
      "slug": "phones",
      "is_active": 1,
      "image": "https://…/sub.png",
      "description": "…",
      "products_count": 12,
      "products": []
    }
  ]
}
```

`products_count` on both levels counts **only** sellable products owned by whitelisted merchants — these are the partner's numbers, not GINI's global ones. With `with_products=1`, each sub-category's `products` array is filled with its own top products (newest first, respecting the merchant's configured sort order).

> **The tree changes when the whitelist changes**, and responses are served from a cache with a few minutes' delay. Do not cache the tree indefinitely on your side.

## 7. List sub-categories

```
GET /sub-categories
```

> `/categories` returns the same sub-categories nested under their parent, so most integrations only need that endpoint. This flat listing remains available for paginating sub-categories directly.

Returns the sub-categories that have at least one sellable product owned by a whitelisted, visible merchant (`products_count >= 1` is enforced server-side).

| Parameter | Type | Required | Notes |
|---|---|---|---|
| `query` | string | no | Free-text search over the sub-category name. |
| `categories` | int[] | no | Repeat / array param restricting to parent categories: `?categories[]=1&categories[]=3`. (Array form here; CSV on `/products`.) |
| `with_products` | bool | no | `1` → include a handful of nested products from whitelisted merchants. |
| `page` | int | no | Default 1. |
| `page_size` | int | no | Default 4, max 100. |

**Response item — `SubcategoryResource`** — as the nested object in §6, plus a `category: { id, name, slug }` summary of the parent.

## 8. List products

```
GET /products
```

Products belonging to whitelisted, visible merchants. Only sellable products appear: in stock (or pre-order) and visible in the catalog.

| Parameter | Type | Required | Notes |
|---|---|---|---|
| `query` | string | no | Full-text + semantic search over product and merchant name. Multilingual — `iphone` and `ايفون` find the same products — with handling for common misspellings and synonyms. |
| `categories` | string | no | Comma-separated category ids, e.g. `1,4,7`. **CSV only** — the array form `categories[]=1` is rejected with a `400`. |
| `sub_categories` | string | no | Comma-separated sub-category ids. CSV only. |
| `min_price` | int | no | Inclusive price floor. |
| `max_price` | int | no | Inclusive price ceiling. |
| `sort_by` | string | no | One of `trending`, `newest`, `lowest_price`, `highest_price`, `old`, `max_review`. Omitted, a typed `query` ranks by relevance and browsing follows the merchant's configured order. |
| `page` | int | no | Default 1. |
| `page_size` | int | no | Default 7, max 100. |

`client_id` / `client_ids` in the query are silently dropped — visibility cannot be widened beyond the whitelist.

**Response — `200 OK`**

```json
{
  "search_id": null,
  "data": [
    {
      "id": 266401,
      "name": "iPhone 15 Pro 256GB",
      "slug": "iphone-15-pro-256gb",
      "price": 1750000,
      "discounted_price": 1650000,
      "discount_percentage": 6,
      "currency": "IQD",
      "quantity": 12,
      "in_stock": true,
      "has_pre_order": false,
      "thumbnail": "https://…/1.webp",
      "images": ["https://…/1.webp", "https://…/2.webp"],
      "rating": 4.9,
      "rating_count": 410,
      "category_id": 3,
      "sub_category_id": 17,
      "installment": { "aqsati": true, "ratibi": true },
      "variants": [
        { "id": 27884, "label": "Natural Titanium", "price": 1650000, "quantity": 7, "in_stock": true }
      ],
      "client": { "id": 42, "business_name": "SoundWave", "domain": "soundwave" },
      "created_at": "2026-01-19T00:00:00.000Z"
    }
  ],
  "hasMore": true,
  "totalPages": 12,
  "totalCount": 138
}
```

`ProductResource` is the same resource the GINI storefront uses; the detail response below is a superset of a list item, so parse both with one model.

> **Prices here are for display only.** Everything in Part II is a read of current state, not a quote. The binding price is the one GINI resolves when the checkout token is redeemed.

## 9. Product details

```
GET /products/{slug}
```

Full payload for one product, addressed by its **slug** (not its id) — the `slug` field returned by [§8](#8-list-products).

| Path parameter | Type | Notes |
|---|---|---|
| `slug` | string | URL-safe product slug, e.g. `iphone-15-pro-256gb`. |

**Response — `200 OK`**: `{ "data": ProductResource }` (see §8).

| Status | Body | Meaning |
|---|---|---|
| `404` | `{ "message": "Product not found" }` | No such slug **or** the product belongs to a merchant outside your whitelist. The two are deliberately indistinguishable. |

---

# Part III — Checkout and orders

## 10. Create checkout token

```
POST /checkout-tokens
```

Encrypts a checkout intent — products, payment method, promo code, optional address and shipping method, your own reference — into an **opaque token** and returns it. The partner opens the GINI checkout with the token; only GINI's servers can decrypt it. Tokens expire after 60 minutes by default.

The cart is never passed as URL parameters, and **prices are never sent**: the intent carries product ids and quantities, GINI resolves price and stock itself at redemption, so a manipulated cart cannot change what is charged.

Because this is a `POST`, the raw JSON body is part of the string to sign — sign exactly the bytes you send.

### Request body

| Field | Type | Required | Notes |
|---|---|---|---|
| `products` | object[] | **yes** | 1–100 items. Every product must belong to one of your whitelisted merchants — otherwise the whole request is rejected with a `400`. |
| `products.*.product_id` | int | **yes** | Existing product id. |
| `products.*.variant_id` | int | no | A variant of **that** product — send it when the product has variants. A variant belonging to a different product is a `400`. |
| `products.*.quantity` | int | no | 1–1000, default 1. Checked against available stock at issue time — the variant's stock when `variant_id` is sent, the product's otherwise. Duplicate lines are summed; pre-order products skip the check. Over-stock requests get a `400` carrying the `available` count. |
| `payment_method` | string | **yes** | One of `e_payment`, `aqsati`, `cash_on_delivery`, `ratibi`, `dots`, `qi_payment`, `post_paid` — **and** it must be enabled for your application. A valid but not-enabled method is `400 Payment method not enabled for this application`. |
| `promocode` | string | no | Max 64. Stored as-is in the token; validated against campaigns at checkout, not at issue. |
| `shipping_method` | string | no | One of `free`, `delivery`, `pickup`. Returned to the storefront on redeem so it pre-selects and locks the choice. Omit to let the customer pick. |
| `external_id` | string | no | Max 255. **Your own reference** — an order id, cart id or basket key from your system. Opaque to GINI: stored as sent, echoed on every `order.*` webhook, filterable on `GET /orders?external_id=`. Not required to be unique; reuse it and every matching order comes back. |
| `address` | object | no | The delivery address **as values**, from your system. When supplied it becomes the order's delivery address and the customer is never asked for one. See [Supplying the delivery address](#supplying-the-delivery-address). |
| `address.name` | string | with `address` | Recipient name (max 255). Shown to the customer and passed to the courier — who receives the parcel, not necessarily the account holder. |
| `address.contact_phone` | string | with `address` | Recipient phone (max 32). The number the courier calls. |
| `address.address` | string | with `address` | Street line (max 1000) — building, street, landmark. |
| `address.state` | int | with `address` | GINI state id. **Load-bearing:** shipping cost is priced from it. A wrong id gives a wrong delivery fee or a `self_delivery_shipping_cost_not_found` error at checkout. |
| `address.state_label` | string | no | Display name of the state (max 255). Shown, not used for pricing. |
| `address.city` | int | no | GINI city id. |
| `address.city_label` | string | no | Display name of the city (max 255). |
| `address.latitude` / `address.longitude` | float | no | −90…90 / −180…180. Map pin only. |

```json
{
  "products": [
    { "product_id": 266401, "variant_id": 27884, "quantity": 2 },
    { "product_id": 4 }
  ],
  "payment_method": "cash_on_delivery",
  "promocode": "SUMMER10",
  "shipping_method": "delivery",
  "address": {
    "name": "Ali",
    "contact_phone": "0780000000",
    "address": "Street 14, House 7",
    "state": 1,
    "state_label": "Baghdad",
    "city": 12,
    "city_label": "Karrada",
    "latitude": 33.3152,
    "longitude": 44.3661
  },
  "external_id": "ORD-9001"
}
```

### Response — `201 Created`

```json
{
  "token": "eyJpdiI6IjF…opaque…",
  "cart_id": 4821993017,
  "external_id": "ORD-9001",
  "shipping_method": "delivery",
  "has_address": true,
  "expires_at": "2026-07-30T15:34:19+00:00"
}
```

- **`token`** is opaque — do not parse or modify it; store it and pass it back whole.
- **`cart_id`** is a random integer minted with the token and sealed inside it. Store it against the cart you just created: when the customer completes the order, the same value arrives as a top-level `cart_id` on the **`order.created`** webhook only (`order.status_updated` carries `cart_id: null`).
- **`external_id`** is echoed verbatim on **every** `order.*` webhook and is the value to filter on at `GET /orders?external_id=`. Prefer it over `cart_id` when your system already has its own reference — it is yours, durable, and present on every event.
- **`has_address`** and **`shipping_method`** confirm what was recorded. `has_address: false` on a request where you *did* send an address means it was dropped — that only happens when the object was empty.

### Token lifecycle

A token is **single-use and bound to one customer**:

1. The customer opens the link. GINI validates the token and fills their cart with exactly the basket you signed. The token is bound to that customer at this point.
2. The customer checks out, sending the token back. GINI re-verifies that the cart, payment method and promo code still match the token, then **spends** it.

Design around the consequences:

- **One token = at most one order.** Re-using a spent token is rejected. Issue a fresh token per checkout attempt; do not cache or share them.
- **The first customer to open the link owns it.** A token opened by customer A cannot be redeemed by customer B. Treat the link as a per-customer secret and deliver it over a private channel.
- **Editing the cart invalidates it.** Adding, removing or re-quantifying an item, swapping a variant, or changing the payment method or promo code all fail the match check at checkout.

### Supplying the delivery address

`address` is optional. Send it when **you** already know where the order should go; omit it and the customer picks from their own address book at checkout.

When you send it:

- the storefront **hides its address picker** and shows your values read-only — the customer cannot change them;
- the order's delivery address is built from exactly these values;
- shipping cost is priced from `address.state`;
- `shipping_method: pickup` ignores the address entirely — a pickup order is not delivered anywhere.

**GINI does not accept an address id.** A checkout token is not bound to a customer at the moment it is issued — the customer is whoever opens the link afterwards — so there is nobody to check an id against. Sending values means the address is yours, validated on its own terms, and nothing of GINI's can be read through this endpoint. Consequences:

- **You own correctness.** GINI does not verify that the address exists, is deliverable, or matches the state id. A wrong `state` produces a wrong shipping fee.
- **It is frozen at issue.** The values are sealed into the token. To change the address, issue a new token.
- **`name` and `contact_phone` reach the courier.** Send the recipient's details, not the account holder's, when they differ.

### What the token actually fixes

| Field | Behaviour at checkout |
|---|---|
| `products` | **Enforced.** The cart must still equal the signed basket or the order is rejected (`errors.checkout_token_mismatch`). |
| `payment_method` | **Enforced.** A different method is rejected. |
| `promocode` | **Enforced.** Adding, dropping or changing it is rejected. |
| `address` | **Overridden.** When you supply one, anything the storefront sends is discarded in favour of yours. Ignored for `pickup`. |
| `shipping_method` | **Advisory.** Pre-selected and locked in the storefront, but a different value is *not* currently rejected server-side. Do not rely on it as a hard guarantee. |
| `external_id` | Not validated. Stored, echoed on every webhook, filterable. |

### `post_paid`

`post_paid` collects nothing at checkout — your application is billed later. It is therefore **token-only**: a customer can never select it themselves, and it must be explicitly present in your application's enabled payment methods (a blank configuration does **not** grant it). Ask GINI to enable it before use.

### Redemption errors

Returned to the customer's client, not to you — but essential when supporting an integration:

| Key | Meaning |
|---|---|
| `errors.invalid_token` | Not decryptable, tampered with, or issued before the current token format. |
| `errors.token_has_expired` | Older than the TTL (60 minutes by default). |
| `errors.checkout_token_already_used` | Already spent on an order. |
| `errors.checkout_token_not_yours` | Opened by a different customer. |
| `errors.checkout_token_mismatch` | Cart, payment method or promo code no longer match the token. |
| `errors.checkout_token_products_unavailable` | A product went out of stock or invisible since the token was issued. |

## 11. Orders

Orders are scoped to the application the same way the catalog is: an order is visible only when it was created through **your** application (`reference_type = "app"` and `reference_number` = your application id). Anything else — another app's order, an organic GINI order, an unknown id — is a plain `404 Order not found`, indistinguishable from a missing order.

The payload intentionally excludes customer PII (name, phone, address).

### Order details

```
GET /orders/{id}
```

`{id}` is the numeric order id. No query parameters.

```json
{
  "data": {
    "id": 265552,
    "code": "107546",
    "status": "accepted",
    "is_pre_order": false,
    "payment":  { "method": "cash_on_delivery", "status": "pending" },
    "shipping": { "method": "delivery", "cost": "5000" },
    "amounts":  { "total": "280000", "discount_total": null, "promo_code": null },
    "promocode": null,
    "merchant": { "id": 1519, "name": "الجواد للاجهزة الكهربائية" },
    "items": [
      {
        "product_id": 285209,
        "variant_id": null,
        "name": "بلي شتيشن 4",
        "quantity": 1,
        "unit_price": "280000",
        "discount_unit_price": "0",
        "total_price": "280000",
        "discount_total_price": "0"
      }
    ],
    "placed_at": "2026-06-17T13:12:55+00:00",
    "accepted_at": "2026-06-17T14:02:11+00:00",
    "shipped_at": null,
    "delivered_at": null,
    "cancelled_at": null,
    "refunded_at": null
  }
}
```

`cancelled_at` covers cancellation, rejection and expiry; `refunded_at` covers both return flows. Monetary amounts are strings.

An order belongs to one merchant. A checkout whose cart spans several merchants produces one order per merchant, each with its own `order.created` event.

### List orders

```
GET /orders
```

Orders created through your application, newest first, in the standard envelope. Items use the same shape as order details.

| Parameter | Type | Required | Notes |
|---|---|---|---|
| `status` | string | no | One of the [order statuses](#order-statuses). |
| `customer_id` | string | no | One customer's orders. This is the customer's **SuperQi id** (`sguid`), not GINI's internal user id — the identifier you already hold. Customers who signed up without SuperQi have none. Applied inside your application scope. |
| `external_id` | string | no | Orders whose checkout token carried this `external_id`. Resolved inside your application scope; returns an empty page if nothing matches. |
| `from_date` | date | no | Placed on/after this date (`YYYY-MM-DD`). |
| `to_date` | date | no | Placed on/before this date. Must be ≥ `from_date`. |
| `page` | int | no | Default 1. |
| `page_size` | int | no | Default 20, max 100. |

### Order statuses

| Status | Meaning |
|---|---|
| `pending` | Placed; awaiting the merchant. Payment may still be pending (cash on delivery collects at handover; Aqsati/Ratibi schedule installments). |
| `accepted` | The merchant accepted the order. |
| `rejected` | The merchant declined it. Terminal; sets `cancelled_at`. |
| `received` | Handed to the delivery operation. |
| `on_the_way` | Out for delivery, or ready for pickup. Sets `shipped_at`. |
| `delayed` | Delivery postponed. |
| `delivered` | Received by the customer. Terminal; sets `delivered_at`. |
| `cancelled` | Cancelled before delivery. Terminal; sets `cancelled_at`. |
| `expired` | Not acted on in time. Terminal; sets `cancelled_at`. |
| `rwa` | Returned while attempting delivery. |
| `returned` | Returned after delivery. Terminal; sets `refunded_at`. |
| `refunded` | Refund completed. Terminal; sets `refunded_at`. |

## 12. Order webhooks

If your application has a `webhook_url`, GINI POSTs to it whenever an order created through your application changes. Polling `GET /orders` is supported but should not be the primary mechanism.

| Event | Fired when |
|---|---|
| `order.created` | An order tagged with your application is created from a redeemed checkout token. |
| `order.status_updated` | Its `status` changes (any transition). |

### Payload

```json
{
  "event": "order.status_updated",
  "previous_status": "accepted",
  "external_id": "ORD-9001",
  "cart_id": null,
  "order": { /* same shape as GET /orders/{id} */ },
  "sent_at": "1785500000"
}
```

`external_id` and `cart_id` sit **beside** `order`, not inside it — they identify the checkout you opened, not the order row:

| Field | On which events | Notes |
|---|---|---|
| `external_id` | every event | Your reference, exactly as sent when the token was created. `null` when the token carried none. Also filterable at `GET /orders?external_id=`. |
| `cart_id` | `order.created` only | The correlation id returned with the token. `null` on `order.status_updated`. |
| `previous_status` | `order.status_updated` | The status before this transition; `null` on `order.created`. |

Record the `external_id → order.id` pairing when `order.created` arrives, then match later events on `order.id`.

### Signature

Headers: `X-TIMESTAMP` (Unix seconds) and

```
X-SIGNATURE = hex( HMAC_SHA256( app_secret, raw_body + timestamp ) )
```

Verify with a constant-time comparison over the **raw** request body, and reject stale timestamps (±300 s) — exactly as GINI verifies your inbound requests. The same `app_secret` signs both directions.

```js
// Node.js
import crypto from 'node:crypto';
export function verifyGiniWebhook(rawBody, headers, appSecret) {
  const ts = headers['x-timestamp'];
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const expected = crypto.createHmac('sha256', appSecret).update(rawBody + ts).digest('hex');
  const given = Buffer.from(headers['x-signature'] || '', 'hex');
  return given.length === 32 && crypto.timingSafeEqual(given, Buffer.from(expected, 'hex'));
}
```

```php
// PHP
$expected = hash_hmac('sha256', $rawBody . $_SERVER['HTTP_X_TIMESTAMP'], $appSecret);
$valid = hash_equals($expected, $_SERVER['HTTP_X_SIGNATURE'])
      && abs(time() - (int) $_SERVER['HTTP_X_TIMESTAMP']) <= 300;
```

### Delivery contract

- Any `2xx` from you marks the webhook delivered. Anything else — or a timeout; GINI waits up to **15 s** — counts as a failure.
- Failures are retried up to **5 times** with growing backoff: 1 m, 5 m, 15 m, 30 m, 1 h. After that the delivery is marked failed and surfaced to the partner.
- **Deliveries can arrive out of order** under retry. Use `sent_at` and `previous_status` to detect stale events.
- **Delivery is at-least-once.** Endpoints must be idempotent.
- Acknowledge first and process asynchronously — long processing inside the request causes false retries.

> **Webhooks are notifications, not authority.** Anything financially meaningful — a refund, a release of goods, a settlement entry — is confirmed against `GET /orders/{id}` before the partner acts on it.

---

# Part IV — Governance

## 13. Security rules

Each rule closes a real hole.

| Rule | What it prevents |
|---|---|
| **Merchant scope is server-derived.** Filtering comes from the application behind the signed `app_id`; merchant identifiers in queries are silently dropped. | A partner widening its own catalog. |
| **Prices are never accepted from the client.** The checkout intent carries product ids and quantities only; GINI resolves price and stock at issue and re-validates at confirmation. | Charging a manipulated amount. |
| **The checkout intent is opaque, encrypted, single-use and customer-bound.** Only GINI can decrypt it; tampering is rejected; it expires after 60 minutes; it is spent on one order; the first customer to open it owns it. | Replay, sharing, and cart edits after signing. |
| **Every request is signed** — HMAC-SHA256 over method, path, canonical query, body and timestamp, inside a five-minute window. The secret never leaves the partner's server. | Forged or replayed requests; unauthenticated query parameters. |
| **Outbound events are signed and verified** with the same HMAC-SHA256 scheme over `raw_body + timestamp`. | Forged webhooks. |
| **No address ids, no PII in orders.** Addresses are supplied as values; order payloads exclude customer name, phone and address. | Reading GINI's data through the partner surface. |
| **Scope failures are 404s.** Products and orders outside the whitelist are indistinguishable from missing ones. | Probing for entitlements. |

## 14. Operational notes

- **Cache.** Category and sub-category responses are cached server-side; expect up to a few minutes' delay before catalog changes appear.
- **Rate limits.** Standard API throttling applies. On `429`, slow down and back off.
- **Rotating secrets.** When an `app_secret` is rotated, every in-flight request signed with the old secret fails immediately. Plan deployments accordingly.
- **Clocks.** Keep partner servers within a few seconds of UTC; the timestamp window is ±300 s for both inbound requests and outbound webhooks.
- **Idempotency.** Webhook endpoints must tolerate duplicates; token creation is not idempotent — each call mints a new token and `cart_id`.

## 15. Change log and migration notes

**v1.1 (this document) — aligned with the current platform behaviour.** Differences from the earlier v1.0 specification page:

| Area | v1.0 draft | v1.1 (current) |
|---|---|---|
| Merchant search param | `query` | **`q`** (every other endpoint keeps `query`) |
| Merchant geo search | — | `lat`, `lng`, `distance` → nearest-first with `distance_km` |
| Product details | `GET /products/{id}` | **`GET /products/{slug}`** |
| Sub-categories | "retired flat endpoint" | `GET /sub-categories` **retained** for direct pagination; `/categories` nests them |
| Categories params | `depth`, `parent_id`, `merchant_id` | removed — the tree is always full depth |
| Checkout body | `items`, `reference` | **`products`**, **`external_id`**, plus `address`, `shipping_method` |
| Checkout response | `token`, `expires_at` | + **`cart_id`**, `external_id`, `shipping_method`, `has_address` |
| Payment methods | six | + **`post_paid`** (token-only, explicitly enabled); per-application enablement enforced |
| Token semantics | opaque, 60 min | + **single-use, customer-bound**, cart-match enforced; documented redemption errors |
| Address | collected at checkout | may be **supplied as values** in the token (address ids withdrawn before release) |
| Order shape | `reference`, `customer`, `fulfillment`, `totals`, `timeline` | `code`, `payment{}`, `shipping{}`, `amounts{}`, per-event timestamps; **no customer PII** |
| Order statuses | 7 | **12** (`pending … expired`, incl. `received`, `rwa`, `delayed`) |
| Order filters | status, reference, dates | `status`, **`customer_id` (SuperQi sguid)**, **`external_id`**, `from_date`, `to_date` |
| Webhook events | 10 (`order.*`, `payment.*`, `installment.due`) | **2**: `order.created`, `order.status_updated` |
| Webhook signature | separate webhook secret over the body | **`app_secret`** over `raw_body + timestamp` |
| Webhook retries | backoff over 24 h, 10 s timeout | **5 attempts** (1 m, 5 m, 15 m, 30 m, 1 h), **15 s** timeout |
| Validation errors | `400 { key, message }` | unchanged — the `422` shape mentioned in an earlier draft is **not** used; do not test for it |
| Signature scheme | canonical query included | unchanged; legacy scheme (query excluded) accepted only during a transition window |

---

# Appendices

## Appendix A. Reference signers

### PHP

```php
$method    = 'GET';
$path      = '/api/third-party/v1/applications/products';
$query     = 'page_size=20&page=1';
$body      = '';
$timestamp = (string) time();

parse_str($query, $params);
ksort($params);
$canonicalQuery = http_build_query($params, '', '&', PHP_QUERY_RFC3986);

$stringToSign = $method . $path
    . ($canonicalQuery === '' ? '' : '?' . $canonicalQuery)
    . $body . $timestamp;
$signature = hash_hmac('sha256', $stringToSign, $appSecret);

$headers = [
    'X-API-KEY: '   . $appId,
    'X-TIMESTAMP: ' . $timestamp,
    'X-SIGNATURE: ' . $signature,
    'Accept: application/json',
];
$url = "https://{$host}{$path}?{$query}";
```

### Node.js

```js
import crypto from 'node:crypto';

// params: flat values or arrays, e.g. { page: '1', categories: ['3', '5'] }
function canonicalQuery(params) {
  const enc = (s) => encodeURIComponent(s).replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
  const pairs = [];
  for (const key of Object.keys(params).sort()) {
    const value = params[key];
    if (Array.isArray(value)) value.forEach((v, i) => pairs.push(`${enc(`${key}[${i}]`)}=${enc(v)}`)); // PHP-style indexing
    else pairs.push(`${enc(key)}=${enc(value)}`);
  }
  return pairs.join('&');
}

const method = 'GET';
const path = '/api/third-party/v1/applications/products';
const body = '';
const timestamp = Math.floor(Date.now() / 1000).toString();
const query = canonicalQuery({ page_size: '20', page: '1' });

const stringToSign = method + path + (query ? '?' + query : '') + body + timestamp;
const signature = crypto.createHmac('sha256', appSecret).update(stringToSign).digest('hex');

const headers = { 'X-API-KEY': appId, 'X-TIMESTAMP': timestamp, 'X-SIGNATURE': signature, Accept: 'application/json' };
```

### cURL

```bash
APP_ID="<your app_id>"
APP_SECRET="<your app_secret>"
HOST="https://api.example.com"

METHOD="GET"
PATH_="/api/third-party/v1/applications/categories"
QUERY="page=1&page_size=10"          # already canonical: keys sorted, RFC 3986 encoded
BODY=""
TS=$(date +%s)

STRING_TO_SIGN="${METHOD}${PATH_}?${QUERY}${BODY}${TS}"
SIG=$(printf '%s' "$STRING_TO_SIGN" | openssl dgst -sha256 -hmac "$APP_SECRET" -hex | awk '{print $2}')

curl -sS "${HOST}${PATH_}?${QUERY}" \
  -H "X-API-KEY: $APP_ID" -H "X-TIMESTAMP: $TS" -H "X-SIGNATURE: $SIG" \
  -H 'Accept: application/json'
```

The [developer console](../README.md#developer-console-playground) generates these for any request, prefilled with your credentials, and shows the exact string to sign next to the server's verdict.

## Appendix B. Error catalog

**`401` — authentication** (`{ "message": … }`)

| Message | Cause |
|---|---|
| `Missing authentication headers` | One of `X-API-KEY`, `X-TIMESTAMP`, `X-SIGNATURE` absent. |
| `Invalid API key` | Unknown or inactive `app_id`. |
| `Request expired` | Timestamp outside ±300 s. |
| `Invalid signature` | HMAC mismatch under both the canonical and the legacy scheme. |

**`400` — validation and business rules** (`{ "key", "message", … }`)

| Key (pattern) | When |
|---|---|
| `validation.<field>` | Any invalid query or body parameter: type, range, enum, CSV vs array form, unknown `category_id`, malformed `address.*`, product outside the whitelist, variant of another product, quantity out of 1–1000. |
| `errors.insufficient_stock` | Requested quantity exceeds stock at token issue. Carries `available` and `product_id`. |
| `errors.payment_method_not_enabled` | Valid `payment_method` not enabled for this application (message: `Payment method not enabled for this application`). |

**`404` — outside scope** (`{ "message": … }`)

| Message | When |
|---|---|
| `Product not found` | Unknown slug, or the product's merchant is not whitelisted. |
| `Order not found` | Unknown id, or the order was not created through your application. |

**Redemption errors** (shown to the customer at checkout, see [§10](#redemption-errors)): `errors.invalid_token`, `errors.token_has_expired`, `errors.checkout_token_already_used`, `errors.checkout_token_not_yours`, `errors.checkout_token_mismatch`, `errors.checkout_token_products_unavailable`, `self_delivery_shipping_cost_not_found`.

## Appendix C. Enumerations

| Enumeration | Values |
|---|---|
| `payment_method` | `e_payment`, `aqsati`, `cash_on_delivery`, `ratibi`, `dots`, `qi_payment`, `post_paid` (token-only) |
| `shipping_method` | `free`, `delivery`, `pickup` |
| Order `status` | `pending`, `accepted`, `rejected`, `received`, `on_the_way`, `delivered`, `cancelled`, `refunded`, `returned`, `delayed`, `rwa`, `expired` |
| `sort_by` | `trending`, `newest`, `lowest_price`, `highest_price`, `old`, `max_review` |
| Webhook `event` | `order.created`, `order.status_updated` |

## Appendix D. Endpoint index

| Method | Path | Purpose | § |
|---|---|---|---|
| GET | `/merchants` | Whitelisted merchants, with search and geo ordering | 5 |
| GET | `/categories` | Category tree with nested sub-categories | 6 |
| GET | `/sub-categories` | Flat, paginated sub-categories | 7 |
| GET | `/products` | List, search, filter and sort products | 8 |
| GET | `/products/{slug}` | Single product detail | 9 |
| POST | `/checkout-tokens` | Seal a checkout intent into a token | 10 |
| GET | `/orders/{id}` | One order created through your application | 11 |
| GET | `/orders` | List and filter your orders | 11 |
| POST | `{webhook_url}` | Outbound — `order.created`, `order.status_updated` | 12 |

All paths are relative to `https://<api-host>/api/third-party/v1/applications`.
