Skip to content

Orders created by apps

Creating orders with the prices the merchant agreed, custom lines, the note, tags and app attributes, the store profile, and product search.

Updated 10 Sept 20269 min read
On this page

This page is for anyone whose app turns a quote, an invoice or a conversation into a real order: price offer tools, B2B ordering, sales assistants. By the end you know how to create an order with the prices you agreed, what the platform does with it, what the merchant sees, and how to keep the order linked to your own record afterwards. Revision 2026-09-10 of Platform API v1, all additive.

What this unlocks#

  • POST /orders creates an order with the unit prices you send, for catalog variants and for custom lines that exist nowhere in the catalog. The platform recomputes the totals, reduces stock, links the customer, notifies the merchant and emits order.created with source: "app", exactly like a storefront order.
  • PATCH /orders/{id} updates the order note, adds or removes tags, and merges attributes that belong to your app alone. The reference attribute becomes a chip on the merchant's order page.
  • GET /store gives you the store's currencies, the FX table checkout applies, the enabled payment methods and the province vocabulary, with no scope.
  • GET /products?q= searches the catalog and GET /products/variants?ids= re-validates the variants behind your lines before you commit.

Scopes#

Scope Sentence the merchant sees Unlocks
orders:create Create orders with prices the app sets POST /orders
orders:write Update order status, note and tags PATCH /orders/{id}, PATCH /orders/{id}/status
products:read Read products and their variants GET /products, GET /products/variants

orders:create is deliberately separate from orders:write: a merchant who consented to status, note and tag updates never silently gains order creation. It does not include reading orders either; the 201 body is the only read it grants, so request orders:read as well if you list or fetch orders later. The consent screen shows the caption "These orders appear in your admin marked with the app name. Stock is reduced like any other order." under the scope. The full table is in the scope reference.

Create an order#

Requires orders:create and an Idempotency-Key. Derive the key from your own record, the quote or invoice id, so a retried job replays the recorded order instead of creating a second one.

Shell
curl -X POST https://dukkan.one/platform-api/v1/orders \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Idempotency-Key: quote-Q-1042-accepted" \
  -H "Content-Type: application/json" \
  -d @order.json
order-create-request.jsonJSON
{
  "currency": "SYP",
  "status": "placed",
  "items": [
    {
      "variant_id": "c0ffee00-0001-4e8f-9b70-2d1c3e4f5a6b",
      "quantity": 2,
      "unit_price_minor": 225000
    },
    {
      "variant_id": null,
      "name": "تغليف هدية مع بطاقة إهداء",
      "quantity": 1,
      "unit_price_minor": 15000
    }
  ],
  "discount": {
    "amount_minor": 15000,
    "title": "خصم عرض السعر Q-1042"
  },
  "shipping": {
    "amount_minor": 25000,
    "method": "delivery"
  },
  "customer": {
    "name": "ليان الحلبي",
    "phone": "+963944123456",
    "email": null
  },
  "shipping_address": {
    "address": "شارع بغداد، بناء 22، الطابق الثالث",
    "province": "damascus",
    "city": "دمشق",
    "notes": null
  },
  "payment_method": "cod",
  "exchange_rate": null,
  "note": "عرض السعر Q-1042 مقبول عبر واتساب",
  "tags": [
    "عرض سعر",
    "جملة"
  ],
  "attributes": {
    "reference": {
      "kind": "quote",
      "number": "Q-1042",
      "url": "https://offers.example.com/quotes/Q-1042"
    },
    "quote_id": "q_8f3a"
  },
  "inventory": "decrement"
}

The 201 response is the full order, with kind on every line, source: "app", your note, tags and attributes, and the customer details you supplied. It echoes those details regardless of clients:read, because you sent them.

order-create-response.jsonJSON
{
  "data": {
    "id": "6f1d2c3b-4a59-4e8f-9b70-2d1c3e4f5a6c",
    "order_number": "1043",
    "status": "placed",
    "payment_status": "unpaid",
    "source": "app",
    "line_pricing": "gross",
    "subtotal": {
      "amount_minor": 465000,
      "currency": "SYP",
      "decimals": 0
    },
    "discount": {
      "amount_minor": 15000,
      "currency": "SYP",
      "decimals": 0
    },
    "shipping": {
      "amount_minor": 25000,
      "currency": "SYP",
      "decimals": 0
    },
    "tax": {
      "amount_minor": 0,
      "currency": "SYP",
      "decimals": 0
    },
    "total": {
      "amount_minor": 475000,
      "currency": "SYP",
      "decimals": 0
    },
    "paid": {
      "amount_minor": 0,
      "currency": "SYP",
      "decimals": 0
    },
    "refunded": {
      "amount_minor": 0,
      "currency": "SYP",
      "decimals": 0
    },
    "exchange_rate": null,
    "tags": [
      "عرض سعر",
      "جملة"
    ],
    "created_at": "2026-09-10T09:30:00.412Z",
    "updated_at": "2026-09-10T09:30:00.412Z",
    "items": [
      {
        "id": "a1b2c3d4-0002-4e8f-9b70-2d1c3e4f5a6b",
        "variant_id": "c0ffee00-0001-4e8f-9b70-2d1c3e4f5a6b",
        "kind": "variant",
        "name": "عطر العود الملكي — 50 مل",
        "quantity": 2,
        "unit_price": {
          "amount_minor": 225000,
          "currency": "SYP",
          "decimals": 0
        },
        "total": {
          "amount_minor": 450000,
          "currency": "SYP",
          "decimals": 0
        }
      },
      {
        "id": "a1b2c3d4-0003-4e8f-9b70-2d1c3e4f5a6b",
        "variant_id": null,
        "kind": "custom",
        "name": "تغليف هدية مع بطاقة إهداء",
        "quantity": 1,
        "unit_price": {
          "amount_minor": 15000,
          "currency": "SYP",
          "decimals": 0
        },
        "total": {
          "amount_minor": 15000,
          "currency": "SYP",
          "decimals": 0
        }
      }
    ],
    "fulfillments": [],
    "refunds": [],
    "note": "عرض السعر Q-1042 مقبول عبر واتساب",
    "attributes": {
      "reference": {
        "kind": "quote",
        "number": "Q-1042",
        "url": "https://offers.example.com/quotes/Q-1042"
      },
      "quote_id": "q_8f3a"
    },
    "customer": {
      "first_name": "ليان",
      "last_name": "الحلبي",
      "email": null,
      "phone": "+963944123456"
    },
    "shipping_address": {
      "address": "شارع بغداد، بناء 22، الطابق الثالث",
      "province": "damascus",
      "city": "دمشق",
      "notes": null
    }
  }
}

The body#

Field Meaning
currency One of the store's allowed storefront currencies (GET /store, storefront_currencies.allowed). Otherwise 422 with code currency_not_allowed and details.allowed.
status placed (default) lets the merchant review the order in the admin; approved records an explicit acceptance the customer already gave you. Both follow the normal lifecycle afterwards.
items[] 1 to 200 lines. variant_id names a catalog variant; null makes a custom line and then name is required. quantity is 1 to 100,000 and unit_price_minor is the agreed unit price in minor units of currency; zero is allowed.
discount Order-level amount with an optional title, at most the sum of the lines.
shipping Amount and method: delivery or pickup.
customer Name, full international phone number (stored normalised), optional email. The platform never invents an email when you leave it null.
shipping_address Required for delivery. province is one of the values GET /store lists under provinces.
payment_method cod or shamcash_manual; it must be enabled in the store, otherwise 409 with code payment_method_disabled.
exchange_rate Required when currency differs from the pricing currency, forbidden otherwise.
note, tags, attributes The same fields PATCH /orders/{id} edits, see below.
inventory decrement (default) or skip, see Inventory and status.

Totals and currency#

Every money field is an integer in minor units of the order currency, scaled by that currency's Dukkan exponent (SYP 0, USD 2, JOD 3). You send unit prices and the discount and shipping amounts; the platform recomputes everything else:

  • subtotal = Σ quantity × unit_price_minor
  • total = subtotal − discount + shipping
  • tax = 0

When the order is not in the store's pricing currency, send the rate you applied as { "scaled", "scale" } where rate = scaled ÷ 10^scale and 1 unit of the pricing currency = rate units of the order currency. Take it from fx.rates in GET /store; that table already includes the merchant's adjustments and is exactly what checkout applies. Round each unit price after conversion, not the total, so your figures match what the same cart would cost on the storefront. The SDK does this for you with exchangeRateFor and buildCreateOrderLines; see the SDK page. Money rules in general are in Money in minor units.

Inventory and status#

  • inventory: "decrement" reduces stock for every catalog line under the store's stock policy. When a line is short the whole request fails with 409 and nothing is written. inventory: "skip" writes no movement at all, for orders whose goods never sat in this store's stock.
  • Custom lines (kind: "custom") have no catalog link: they never touch inventory, and a refund on them is by amount only, never with restock. See Inventory movements.
  • status: "placed" is the right default when the merchant should confirm before anything ships. Use approved only when your app has collected an explicit acceptance from the customer; the admin shows "Status at creation: Approved" on the timeline so the merchant knows why the review step was skipped.

Errors#

Status Code Meaning
422 invalid_request A field failed validation; details.fields lists the paths, for example items.1.name or shipping_address.
422 currency_not_allowed currency is not one the store sells in; details.allowed lists the accepted codes.
404 variant_not_found A variant_id does not exist in this store; details.variant_id names it.
409 variant_unavailable The variant is discontinued; details.variant_id names it.
409 payment_method_disabled The chosen payment method is off in this store.
409 insufficient_stock One or more catalog lines exceed the available quantity; details.lines[] names every short line with variant_id, plus requested and available when the shortfall was measured up front. A concurrent order that wins the row lock yields variant_id alone. Nothing was written.
403 forbidden The install lacks an effective orders:create, or the store is a marketplace demo store.
429 rate_limited The shared write bucket, or the daily caps below. Honour Retry-After.
order-insufficient-stock.jsonJSON
{
  "error": {
    "code": "insufficient_stock",
    "message": "Not enough stock for one or more lines",
    "request_id": "7d0c2b1a-5e4f-4a3b-8c9d-0e1f2a3b4c5e",
    "details": {
      "lines": [
        {
          "variant_id": "c0ffee00-0001-4e8f-9b70-2d1c3e4f5a6b",
          "requested": 2,
          "available": 1
        }
      ]
    }
  }
}

Read the whole lines list, re-quote or split the order, and send a new request with a new key. A 409 never records the idempotency key, so the same key is free to reuse once the cause is gone.

What the merchant sees#

  • Orders list: the order carries a "From app" chip, and the merchant can filter by type App and by tag.
  • Order page: an "App reference" card with the chip from your reference attribute (for example "Quote Q-1042"), linking to the URL you gave; an "Order note" card; "Custom item" on every custom line; and an "Order history" timeline whose first row reads "Created by " with the caption "Prices set by the app. Status at creation: Placed".
  • Editing: prices, discount and shipping on an app-created order are locked in the admin ("Managed by the app and cannot be edited here"); quantities stay editable.
  • Notifications: the merchant gets the same push and email as for a storefront order, and subscribed apps receive order.created with source: "app".
  • If your app is later uninstalled, the order keeps everything; the reference card says "App removed. Data kept."

Update note, tags and attributes#

Requires orders:write and an Idempotency-Key. Money, lines, customer and address are immutable after creation; this endpoint edits the three fields around them.

Shell
curl -X PATCH https://dukkan.one/platform-api/v1/orders/ORDER_ID \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Idempotency-Key: invoice-INV-2207-linked" \
  -H "Content-Type: application/json" \
  -d @update.json
order-update-request.jsonJSON
{
  "note": "تم تأكيد الدفع عند الاستلام هاتفياً",
  "tags": {
    "add": [
      "مؤكد"
    ],
    "remove": [
      "جملة"
    ]
  },
  "attributes": {
    "reference": {
      "kind": "invoice",
      "number": "INV-2207",
      "url": "https://offers.example.com/invoices/INV-2207"
    },
    "quote_id": null
  }
}
  • note replaces the order note; null clears it. Up to 2,000 characters.
  • tags is a pair of set operations, never a replacement, so the merchant's tags and other apps' tags survive. A tag is 1 to 40 characters of Unicode letters, digits, spaces, _ and -; the platform normalises it (NFC, trimmed) and deduplicates case-insensitively; an order holds at most 10. remove matches case-insensitively. GET /orders?tag= filters by one tag.
  • attributes is merged into your app's own namespace. Other apps never see your keys and you never see theirs; GET /orders/{id} returns only yours. Keys match [a-z0-9_.-]{1,64}, at most 20 per app, scalar values up to 1,000 UTF-8 bytes, 8 KB in total (a byte limit, so an Arabic value reaches it in about 500 characters). In a PATCH, null deletes the key.

The response is the updated order, with the same 200 shape as GET /orders/{id}.

The reference attribute#

One attribute has a fixed shape and a fixed place in the admin: reference links the order back to the record it came from in your app.

Field Meaning
kind The record type in your app, up to 40 characters, for example quote or invoice.
number The human-readable number, up to 80 characters, for example Q-1042. Shown on the chip.
url A deep link into your app, or null. It must be https on your app's registered origin (the origin of a redirect URI on the app version); anything else is refused with 422. The merchant opens it in a new tab.

Send it at creation or add it later; replacing it with a new object swaps the chip.

The store profile#

No scope needed; everything in it is public on the storefront already.

Shell
curl https://dukkan.one/platform-api/v1/store \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
store.jsonJSON
{
  "data": {
    "id": "0d9e8f7a-6b5c-4d3e-2f1a-0b9c8d7e6f5a",
    "slug": "sham-perfumes",
    "name": "عطور الشام",
    "description": "عطور شرقية أصيلة من دمشق",
    "logo_url": "https://cdn.dukkan.one/stores/sham-perfumes/logo.png",
    "banner_urls": [],
    "storefront_url": "https://shamperfumes.com",
    "whatsapp_number": "+963944000000",
    "phone": null,
    "address": "دمشق، الحميدية",
    "accent_color": "#7A1F2B",
    "locale": "ar",
    "timezone": "Asia/Damascus",
    "country": "SY",
    "pricing_currency": {
      "code": "SYP",
      "decimals": 0
    },
    "storefront_currencies": {
      "allowed": [
        "SYP",
        "USD"
      ],
      "default": "SYP"
    },
    "fx": {
      "base": "SYP",
      "as_of": "2026-09-10T06:00:00Z",
      "rates": {
        "SYP": {
          "scaled": 100000000,
          "scale": 8
        },
        "USD": {
          "scaled": 7700,
          "scale": 8
        }
      }
    },
    "payment_methods": [
      {
        "id": "cod"
      },
      {
        "id": "shamcash_manual",
        "shamcash_id": "0944000000"
      }
    ],
    "provinces": [
      {
        "value": "damascus",
        "ar": "دمشق",
        "en": "Damascus"
      },
      {
        "value": "aleppo",
        "ar": "حلب",
        "en": "Aleppo"
      }
    ]
  }
}
  • pricing_currency is the currency catalog prices are stored in, with its exponent. storefront_currencies.allowed are the currencies POST /orders accepts.
  • fx is the store-adjusted rate table from the pricing currency, null when the store has none. Each rate is scaled ÷ 10^scale, the base is included at identity, and as_of tells you how fresh it is.
  • payment_methods lists the enabled manual methods; shamcash_manual carries the shamcash_id shown at checkout so you can show the same one.
  • provinces are the accepted shipping_address.province values with Arabic and English labels.
  • storefront_url is the public origin; a connected custom domain wins over the platform subdomain.

Cache it per install for a few minutes; the rates move at most a few times a day.

Find products and variants#

Both need products:read.

Shell
curl "https://dukkan.one/platform-api/v1/products?q=oud&status=active" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
curl "https://dukkan.one/platform-api/v1/products/variants?ids=c0ffee00-0001-4e8f-9b70-2d1c3e4f5a6b" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
variants-response.jsonJSON
{
  "data": [
    {
      "id": "c0ffee00-0001-4e8f-9b70-2d1c3e4f5a6b",
      "product_id": "b0b0b0b0-0001-4e8f-9b70-2d1c3e4f5a6b",
      "sku": "OUD-50",
      "price": {
        "amount_minor": 250000,
        "currency": "SYP",
        "decimals": 0
      },
      "status": "active",
      "name": "50 مل",
      "attributes": {
        "الحجم": "50 مل"
      },
      "barcode": null,
      "compare_at_price": null,
      "image_url": "https://cdn.dukkan.one/stores/sham-perfumes/products/oud-50.jpg",
      "weight_gram": 180,
      "available_quantity": 12,
      "product_name": "عطر العود الملكي",
      "product_status": "active"
    }
  ]
}
  • GET /products?q= matches 2 to 80 characters, case-insensitively, against product name, variant name, SKU, DSIN and barcode, in Arabic or Latin script, and ranks the result. A search answers one ranked page and offers no cursor; narrow the query rather than paging. status and ids (up to 100, comma-separated) filter, and combine with updated_at_min for sync.
  • GET /products/variants?ids= takes up to 100 ids and returns the variants that exist, each with product_name, product_status, the current price, compare_at_price and image_url. Unknown ids are omitted, never an error, so compare the returned ids with the ones you asked for. available_quantity is present only when the install also holds inventory:read.

Re-validate the variants behind a quote right before POST /orders: a variant discontinued since the quote answers 409 at creation, and a price change is something you may want to show the customer first.

Idempotency and limits#

  • Every write carries an Idempotency-Key. When the platform replays a recorded response, it says so with the Idempotency-Replayed: true header; the SDK surfaces it as replayed.
  • Writes share the install's bucket of 60 per minute. Order creation additionally counts against 500 orders per install per day and 2,000 per store per day. Both answer 429 with Retry-After.
  • Marketplace demo stores refuse order creation with 403.

Next steps#