Skip to content

Canonical Contract (v1)

The single, provider-agnostic contract between an external commerce system (e.g. Magento) and HKJ. Your connector translates your system's data to and from the canonical shapes below. HKJ never learns your system's internals; you never learn HKJ's. The same contract serves any platform — Magento, Shopify, WooCommerce, …

  • Inbound (you → HKJ): catalog, stock, price, and order-status updates.
  • Outbound (HKJ → you): orders the chatbot places, and cancellations.

Conventions

  • Prices are integer fils1 JOD = 1000 fils (so 5000 = 5.000 JOD). If your system stores decimal JOD instead, send *_jod (e.g. price_jod: "5.000") and HKJ converts it for you. When both are sent, the explicit *_fils value wins.
  • All text is bilingual: *_en / *_ar — and that includes category paths.
  • The contract uses natural keys onlysku, external_id, barcode, category path strings. Never HKJ database IDs.

1. Keys & authentication

The two keys you'll get

The shop owner provisions your integration once (in the HKJ dashboard) and hands you two secrets. They protect opposite directions of traffic, so a full two-way integration uses both:

Credential Direction What it does How it travels HKJ stores
API key (ik_live_…) you → HKJ authenticates your calls to us you send it as the X-API-Key header on every request only its SHA-256 hash
Signing secret HKJ → you lets you verify our webhooks are genuine never sent — both sides hold it; we sign with it, you recompute to compare the secret itself (needed to sign)

In short: the API key secures everything you send us (/events, /bulk); the signing secret lets you trust everything we send you (outbound order events). They are independent and rotate independently.

Both are shown exactly once at provisioning and cannot be retrieved afterwards. There is one API key and one signing secret per shop — not multiple.

If a credential is lost, rotate it

There is no "retrieve". The owner re-provisions with a rotate flag, which mints a fresh value and immediately invalidates the old one (auth matches on the new hash; the old key stops working at once):

  • rotate_key → new API key
  • rotate_secret → new signing secret

Rotating the signing secret will break your outbound verification until you update your stored copy, so coordinate the swap.

Disabling is a hard revoke

When the owner disables the integration in the dashboard, both the API key and the signing secret are permanently deleted (not merely paused). The connector stops working at once — inbound calls get 401. Re-enabling issues brand-new credentials (shown once); the old ones never come back, so you'll update your connector with the new values.

Calling us: your API key

Send your API key in the X-API-Key header on every call:

POST /api/integration/events
X-API-Key: ik_live_xxxxxxxx
Content-Type: application/json

A missing or unknown key returns 401. (A disabled integration has no key at all — see the hard-revoke note above — so its old key also returns 401.)

Trusting our webhooks: the signature

Your outbound_url must be a public https:// endpoint — HKJ rejects non-https URLs and any host that resolves to a private, loopback, or link-local address, and never follows redirects.

When HKJ POSTs to the outbound_url you registered, it signs the request so you can prove it came from us and wasn't tampered with:

POST <your outbound_url>
Content-Type: application/json
X-Signature: <hex HMAC-SHA256(signing_secret, raw_request_body)>
X-Event-Id: order_placed:10231

To verify: recompute HMAC-SHA256(signing_secret, raw_bytes_as_received) and compare it to X-Signature in constant time. The signature is over the exact bytes we sent — compute it on the raw body before any JSON re-serialization. Also dedupe on X-Event-Id; we retry delivery on non-2xx responses or network errors.

$raw = file_get_contents('php://input');           // raw bytes, do not json_decode first
$expected = hash_hmac('sha256', $raw, $signingSecret);
if (!hash_equals($expected, $_SERVER['HTTP_X_SIGNATURE'] ?? '')) {
    http_response_code(401);
    exit;
}
$event = json_decode($raw, true);                   // safe to parse now
import hmac, hashlib
expected = hmac.new(signing_secret.encode(), raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, request.headers.get("X-Signature", "")):
    abort(401)
import crypto from "node:crypto";
const expected = crypto.createHmac("sha256", signingSecret).update(rawBody).digest("hex");
const sig = req.get("X-Signature") || "";
const ok = expected.length === sig.length &&
           crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
if (!ok) return res.sendStatus(401);                // rawBody must be the unparsed body

How often you can call us

Inbound endpoints are rate-limited per API key. Exceeding a limit returns 429; back off and retry (your event_id makes retries idempotent).

Endpoint Limit
POST /events 1200 requests / minute
POST /bulk 120 requests / hour

Batching helps: /events accepts a JSON array of events in one request (each still counts as a single request against the limit), and a full catalog belongs in /bulk, not thousands of /events.


2. Every message looks the same

Every event, in both directions, shares one envelope:

{
  "event_id": "b2c1a9f4-7e3d-4c2a-9f10-8a1b2c3d4e5f",
  "event_type": "product.upserted",
  "occurred_at": "2026-06-20T12:00:00Z",
  "shop": "pk_live_xxx",
  "data": {}
}
field required notes
event_id A unique id you generate per event (a UUID). Your idempotency key — see below.
event_type One of the types below.
occurred_at ISO-8601 timestamp.
shop The shop public key (pk_live_…).
data Event-specific payload.

Why event_id, and who creates it

event_id is an idempotency key — a unique id for one event, used to make retries safe. The rule is symmetric: whoever sends an event creates its event_id; the receiver dedupes on it. So you create it on the events you send us, and we create it (e.g. order_placed:123) on the orders we send you — which you dedupe via the X-Event-Id header.

Why the sender owns it: the sender is the one who retries. If your request times out, you don't know whether we got it — so you resend with the same id, and we apply it just once:

sequenceDiagram
    participant You as Your connector
    participant HKJ
    You->>HKJ: POST /events (event_id: abc123)
    Note over HKJ: applies it · remembers abc123
    HKJ--xYou: response lost ✗ (timeout)
    You->>HKJ: retry — same event_id: abc123
    HKJ-->>You: "status": "duplicate" (nothing re-applied)

If you'd minted a new id on the retry, the "timed-out-but-actually-succeeded" call would apply twice. Rule of thumb: one logical change = one event_id, reused on every retry; a fresh id for the next change. (We dedupe on (shop, event_id).)

Don't have an id handy? It's optional.

If you omit event_id, we generate one and return it in the response. The call still works — but a server-minted id is always unique, so retries won't be deduplicated. Send your own stable id whenever you want safe retries.

The inbound endpoint accepts one envelope or a JSON array of envelopes (a batch). Each event is processed and committed independently — one bad event never fails the rest — and the response reports a per-event status:

{
  "results": [
    { "event_id": "e1", "status": "applied" }
  ],
  "summary": {
    "received": 1,
    "applied": 1,
    "skipped": 0,
    "duplicate": 0,
    "error": 0
  }
}
status meaning
applied The event was processed.
skipped No-op — e.g. a stock update for a product HKJ doesn't have yet.
duplicate This event_id was already processed.
error Validation failed; see the error field on the result.

3. Sending us your catalog & updates

How to send one event

To send one event: generate an event_id, wrap your data in the envelope, and POST it to /api/integration/events with your X-API-Key. The endpoint accepts a single object or an array (a batch), and replies with a per-event results list.

$event = [
  'event_id'   => bin2hex(random_bytes(16)),     // unique per event; reuse on retry
  'event_type' => 'product.upserted',
  'data'       => ['sku' => 'HNY-500', 'title_en' => 'Honey', 'title_ar' => 'عسل',
                   'price_fils' => 5000, 'stock_qty' => 12],
];
$ch = curl_init('https://hkjshopping-production.up.railway.app/api/integration/events');
curl_setopt_array($ch, [
  CURLOPT_POST           => true,
  CURLOPT_HTTPHEADER     => ['Content-Type: application/json', 'X-API-Key: ik_live_xxx'],
  CURLOPT_POSTFIELDS     => json_encode($event),
  CURLOPT_RETURNTRANSFER => true,
]);
$result = json_decode(curl_exec($ch), true);       // { "results": [...], "summary": {...} }
import uuid, requests

event = {
    "event_id": str(uuid.uuid4()),                 # unique per event; reuse on retry
    "event_type": "product.upserted",
    "data": {"sku": "HNY-500", "title_en": "Honey", "title_ar": "عسل",
             "price_fils": 5000, "stock_qty": 12},
}
r = requests.post("https://hkjshopping-production.up.railway.app/api/integration/events",
                  json=event, headers={"X-API-Key": "ik_live_xxx"})
print(r.json())                                    # {"results": [...], "summary": {...}}
import { randomUUID } from "node:crypto";

const event = {
  event_id: randomUUID(),                          // unique per event; reuse on retry
  event_type: "product.upserted",
  data: { sku: "HNY-500", title_en: "Honey", title_ar: "عسل",
          price_fils: 5000, stock_qty: 12 },
};
const res = await fetch("https://hkjshopping-production.up.railway.app/api/integration/events", {
  method: "POST",
  headers: { "Content-Type": "application/json", "X-API-Key": "ik_live_xxx" },
  body: JSON.stringify(event),
});
console.log(await res.json());                     // { results: [...], summary: {...} }

To send several at once, post a JSON array of envelopes — each is applied independently and reported in results, so one bad event never fails the rest.

How we match a product to yours

Every entity HKJ syncs has a stable identity that links it to the record in your system. For products it's the field named below; for orders it's external_order_id (§4). Get this right and everything else follows.

Each shop picks one product match keysku (default), external_id, or barcode — and HKJ finds the existing product by that field on every event. Whichever you pick, you must send it under its canonical name (we don't rename identity fields):

external_id is the identity — send it on every product

external_id is your system's stable product id (Magento's entity_id, a shared UUID, …). If the shop matches on external_id, an event without it is rejected ("Event is missing the match key 'external_id'") — never silently mismatched.

  • Streaming: put it in the external_id field of every product.upserted. Your connector maps your id → external_id; HKJ does not rename it.
  • Bulk file: name the column/tag external_id (or map it once — see Column mapping).
  • It never changes for the life of the product. sku can change; external_id should not.

product.upserted

Full product state. Creates or updates; idempotent.

field type notes
sku string required, unique per shop
external_id string your system's stable product id — the recommended match key. Send it on every product.
barcode string optional
title_en, title_ar string required
desc_en, desc_ar string optional
price_fils int optional — integer fils
price_jod string | number optional — decimal JOD, converted to fils (*_fils wins if both sent)
discount_price_fils int optional — integer fils
discount_price_jod string | number optional — decimal JOD alternative
stock_qty int optional
track_stock bool default true
is_active bool default true
category_paths array see Category paths below
brand string | object "Nike" or { "en": "Nike", "ar": "نايكي" }
img_urls string[] optional
metadata object free-form; surfaced keys are per-shop configurable
{
  "event_id": "e1",
  "event_type": "product.upserted",
  "data": {
    "sku": "HNY-500",
    "external_id": "MAG-8842",
    "title_en": "Wildflower Honey",
    "title_ar": "عسل زهور برية",
    "price_fils": 5000,
    "stock_qty": 12,
    "brand": { "en": "BeeCo", "ar": "بي كو" },
    "category_paths": ["Food > Honey"],
    "metadata": { "origin": "Ajloun" }
  }
}

Category paths

A product's categories are sent as path strings, not IDs. HKJ creates any missing nodes in the hierarchy. Two accepted shapes:

{
  "category_paths": [
    "Food > Honey",
    "Gifts > Hampers"
  ]
}
{
  "category_paths": [
    { "en": ["Food", "Honey"], "ar": ["طعام", "عسل"] }
  ]
}

Validation

Each entry must resolve to at least one non-empty English segment. Empty strings, blank segments, or an empty en array are rejected with an error result (the product is not changed). Arabic names are optional — when omitted, the English name is reused.

product.deleted

Soft-deletes the product (is_active = false). An unknown product returns skipped.

{
  "event_id": "e2",
  "event_type": "product.deleted",
  "data": { "sku": "HNY-500" }
}

stock.changed

Lightweight, high-frequency. Requires stock_qty. Updates existing products only and never re-runs embeddings. An unknown product returns skipped — the next product.upserted will carry current stock.

{
  "event_id": "e3",
  "event_type": "stock.changed",
  "data": { "sku": "HNY-500", "stock_qty": 7 }
}

price.changed

Lightweight, existing-only, no re-embed. Omit a field to leave it unchanged; send null to clear it. Accepts price_jod / discount_price_jod (decimal JOD) just like product.upserted.

{
  "event_id": "e4",
  "event_type": "price.changed",
  "data": {
    "sku": "HNY-500",
    "price_fils": 5500,
    "discount_price_fils": 4900
  }
}

order.status_changed

Reflects an HKJ-placed order's fulfillment status back, so the shopper can track it in chat. Reference the order by HKJ's order_ref (from order.placed) or your external_order_id.

The order's identity works like a product's: HKJ assigns order_ref on order.placed; you reply with your own external_order_id, which HKJ stores once and accepts as a match key thereafter. No mapping or per-shop config is needed for orders — this linkage is automatic.

{
  "event_id": "e5",
  "event_type": "order.status_changed",
  "data": {
    "external_order_id": "MAG-ORD-7741",
    "order_ref": "HKJ-10231",
    "status": "dispatched"
  }
}

status is one of placed, preparing, dispatched, delivered, cancelled.


4. Receiving the orders we place

When the chatbot places an order, HKJ pushes it to you automatically — no polling. The moment the order is placed, we POST order.placed to your outbound_url, signed. You create the order, reply with your id, and send status back as you fulfill:

sequenceDiagram
    participant Shopper
    participant HKJ
    participant You as Your connector
    Shopper->>HKJ: places an order in chat
    HKJ->>You: POST order.placed (order_ref HKJ-123, signed)
    You->>You: verify signature · create order MAG-ORD-7
    You-->>HKJ: 200 OK
    You->>HKJ: order.status_changed (order_ref HKJ-123, external_order_id MAG-ORD-7)
    Note over HKJ: links HKJ-123 ⇄ MAG-ORD-7
    You->>HKJ: order.status_changed: dispatched, delivered…
    HKJ->>Shopper: shows each new status in chat

When does an order get sent to you?

Automatically, whenever both are true: the integration is enabled, and an outbound_url is set. If either is missing, the order is still placed in HKJ — it just isn't pushed to you. Delivery is best-effort: a failure never blocks the order; we retry, and failed deliveries are visible in the dashboard for one-click redrive.

order.placed

Create the order in your system. To link your order id to ours, send an order.status_changed that references our order_ref and includes your external_order_id — a first event with status: "placed" is perfect. HKJ stores the link on first contact; after that you can reference the order by either id.

We read the status code, not the body

HKJ only checks the HTTP status code of your order.placed response (2xx = received) — it does not read the response body. So your external_order_id must come back via an order.status_changed event, not in the webhook's HTTP reply.

{
  "event_id": "order_placed:10231",
  "event_type": "order.placed",
  "data": {
    "order_ref": "HKJ-10231",
    "customer": {
      "name": "Sami",
      "phone": "+962790000000",
      "area": "Amman",
      "channel": "whatsapp"
    },
    "items": [
      {
        "sku": "HNY-500",
        "external_id": "MAG-8842",
        "qty": 2,
        "agreed_price_fils": 5000
      }
    ],
    "subtotal_fils": 10000,
    "delivery_fee_fils": 1500,
    "total_fils": 11500,
    "payment_method": "cod"
  }
}
field values
payment_method cod (cash on delivery), visa_pos, cliq
customer.channel whatsapp, telegram, web
items[].agreed_price_fils the price the shopper agreed to, in fils (may differ from catalog price)

order.cancelled

{
  "event_id": "order_cancelled:10231",
  "event_type": "order.cancelled",
  "data": {
    "order_ref": "HKJ-10231",
    "external_order_id": "MAG-ORD-7741"
  }
}

5. Loading your whole catalog at once

For the initial load or a periodic full re-sync, upload a file instead of streaming events. Supported containers: canonical JSON array (default), CSV, and XML — each is just a batch of product.upserted records, so every field from §3 applies.

POST /api/integration/bulk?format=csv
X-API-Key: ik_live_xxxxxxxx
Content-Type: multipart/form-data

Send the file as multipart file, or as the raw request body. Your shop has a configured default format, used when you don't specify one — but you can override it on any single upload with the format query parameter (json | csv | xml).

Limits

A single upload is capped at 25 MB and 50,000 rows (413 / 400 if exceeded). For a larger catalog, split it into multiple uploads — each batch is processed and reported independently, and product upserts are idempotent so overlap is safe.

An array of product objects (the data shape from product.upserted), so every field from §3 applies — including bilingual category_paths, brand, and price_jod. metadata is a nested object; img_urls is an array.

[
  {
    "sku": "HNY-500",
    "external_id": "MAG-8842",
    "title_en": "Wildflower Honey",
    "title_ar": "عسل زهور برية",
    "price_jod": "5.000",
    "stock_qty": 12,
    "brand": { "en": "BeeCo", "ar": "بي كو" },
    "category_paths": [
      { "en": ["Food", "Honey"], "ar": ["طعام", "عسل"] }
    ],
    "img_urls": [
      "https://cdn.example/hny-500-a.jpg",
      "https://cdn.example/hny-500-b.jpg"
    ],
    "metadata": { "origin": "Ajloun", "weight_g": 500 }
  }
]

One product per row. Brand is brand_en / brand_ar; categories are category_paths (A > B, comma-separated for multiple) with an optional parallel category_paths_ar column for Arabic names (paired by position). img_urls holds pipe-separated URLs (|, not comma). Prices may be price_fils or price_jod.

Always include external_id (the identity column) unless the shop matches on sku.

Metadata is flexible: metadata.* columns fold into the metadata object, and any column that isn't a canonical field is automatically stored as metadata — so you don't have to prefix custom columns, nothing is silently dropped.

sku,external_id,title_en,title_ar,price_jod,category_paths,category_paths_ar,img_urls,brand_en,origin
HNY-500,MAG-8842,Wildflower Honey,عسل زهور برية,5.000,Food > Honey,طعام > عسل,https://cdn.example/a.jpg|https://cdn.example/b.jpg,BeeCo,Ajloun

(origin above isn't a canonical field, so it lands in metadata.origin automatically.)

Forward-fill (one image per row). Leave sku blank to continue the product above — its img_urls and category_paths accumulate, scalars come from the lead row only:

sku,title_en,title_ar,price_fils,img_urls
HNY-500,Wildflower Honey,عسل زهور برية,5000,https://cdn.example/a.jpg
,,,,https://cdn.example/b.jpg
,,,,https://cdn.example/c.jpg

One <product> per product. <metadata> children fold into the metadata object; <img_urls> holds <url> children (or a single pipe-delimited text value). Categories use <category_paths> with an optional parallel <category_paths_ar> for Arabic. As with CSV, any non-canonical child tag is stored as metadata automatically.

<?xml version="1.0" encoding="UTF-8"?>
<products>
  <product>
    <sku>HNY-500</sku>
    <external_id>MAG-8842</external_id>
    <title_en>Wildflower Honey</title_en>
    <title_ar>عسل زهور برية</title_ar>
    <price_jod>5.000</price_jod>
    <stock_qty>12</stock_qty>
    <category_paths>Food &gt; Honey</category_paths>
    <category_paths_ar>طعام &gt; عسل</category_paths_ar>
    <brand_en>BeeCo</brand_en>
    <brand_ar>بي كو</brand_ar>
    <img_urls>
      <url>https://cdn.example/a.jpg</url>
      <url>https://cdn.example/b.jpg</url>
    </img_urls>
    <metadata>
      <origin>Ajloun</origin>
      <weight_g>500</weight_g>
    </metadata>
  </product>
</products>

Images, categories & metadata across formats

img_urls bilingual category_paths metadata
JSON array of strings { "en": [...], "ar": [...] } objects nested object
CSV pipe-separated cell or forward-fill (blank-sku rows) category_paths + parallel category_paths_ar columns metadata.<key> columns or any non-canonical column
XML <img_urls> with <url> children, or pipe text <category_paths> + parallel <category_paths_ar> <metadata> children or any non-canonical tag

Column mapping

For bulk files whose headers/tags don't match the canonical names, the shop can configure a column_map ({ canonical_field: source_column }) — e.g. { "title_en": "Product Name", "metadata.origin": "Country of Origin" }. It renames descriptive attributes only; the identity field (external_id, sku, or barcode) is matched by name, so send it under its canonical name rather than mapping it.

The upload returns a batch_id; processing is asynchronous. Poll GET /api/integration/bulk/{batch_id} for progress:

{
  "batch_id": 42,
  "status": "done",
  "total_rows": 1200,
  "processed_rows": 1198,
  "error_rows": 2,
  "error_kind": null,
  "error_summary": "1198 of 1200 rows imported; 2 skipped — see the errors below.",
  "retriable": false,
  "errors": [
    { "row": 17, "error": "'title_ar' is required and must be a non-empty string." }
  ]
}

Status moves pending → started → done (or failed). A few rows failing is not a failure — the good rows import and the batch is done with error_rows > 0.

stateDiagram-v2
    [*] --> pending: upload accepted
    pending --> started: worker picks it up
    started --> done: file processed (some rows may be skipped)
    started --> failed: file unusable
    failed --> pending: retry — system errors only
    done --> [*]

When status is failed, error_kind tells you what to do:

error_kind meaning retriable what to do
invalid_data every row was rejected false fix the rows listed in errors and upload again
system a transient error on our side true retry the same batch — POST .../batches/{id}/retry (dashboard), or re-upload

A bad file never partially imports silently: rows that fail validation are reported per-row in errors; prices and stock must be non-negative, sku and both titles are required.


6. Who's in charge of stock?

Your system remains the stock master. When the chatbot makes a sale, HKJ optimistically decrements its local copy and relies on your stock.changed feed to reconcile. If a sale can't be fulfilled, reject it via order.status_changedcancelled.