Reference Developers

Merchant API v1 reference

Every merchant API v1 endpoint under /api/v1: method, path, parameters with validation rules and response shape, with curl, JavaScript, PHP and Python samples for creating, listing and tracking parcels.

Last updated 22 October 2026 · Last verified 22 October 2026

Basics#

The merchant API lets a platform merchant's own system price, create, list, track and cancel parcels, read the account statement and manage webhook endpoints.

  • Base URL: your platform domain plus /api/v1 (the panel shows it as Base URL).
  • Authentication: Authorization: Bearer YOUR_API_KEY, see Authentication and API keys. Who can use it: platform merchants whose plan includes the API feature.
  • Format: JSON in and out. Send Content-Type: application/json with bodies, always send Accept: application/json, and add Accept-Language: en for English messages and labels (without it they are in Arabic; the samples on this page are shown in English).
  • Envelope: {"success": true, "data": ...} (only GET /parcels and GET /statement add meta) and {"success": false, "error": {"code", "message"}}. See the API overview and Errors and status codes.

Endpoints at a glance#

Method Path Purpose Route limit per minute
GET /me Account, plan and wallet balances 120
GET /companies Delivery companies and carriers you can use 120
GET /governorates Governorates 120
GET /pickup-locations Your pickup locations 120
GET /warehouse/stock Your stock in warehouses 120
POST /quote Price a parcel without creating it 120
POST /parcels Create a parcel 60
GET /parcels List your parcels 120
GET /parcels/{tracking} One parcel with its events 120
POST /parcels/{tracking}/cancel Cancel a parcel and refund the fee 120
GET /statement Wallet statement 30
GET /webhooks/events Events you can subscribe to 120
GET /webhooks Your webhook endpoints 120
POST /webhooks Add a webhook endpoint 20
PATCH /webhooks/{id} Edit an endpoint 120
DELETE /webhooks/{id} Delete an endpoint 120
POST /webhooks/{id}/test Send a test ping 10

Route limits are counted per IP address on one shared counter: a POST /parcels request counts against both the group limit of 120 and its own, so these are not separate quotas. Because such a request is counted twice, a route with its own limit allows only about half of it from one IP address: about 30 POST /parcels, 15 GET /statement, 10 POST /webhooks and 5 POST /webhooks/{id}/test per minute, and fewer if the same IP address makes other calls in that minute. A general limit of 60 requests per minute per IP address also applies (see the API overview).

Account and lookups#

GET /me#

Returns your account details. It is a good first call to check the key.

json
{
  "success": true,
  "data": {
    "merchant_id": 12,
    "business_name": "My Shop",
    "plan": { "name": "Gold", "expires_on": "2026-12-01", "active": true },
    "balances": { "recharge": 150.5, "cod_due": 320, "held": 0, "company_due": 0 },
    "currency": "$"
  }
}

plan is null when there is no subscription record. balances shows the wallets recharge (shipping balance), cod_due (cash-on-delivery (COD) dues), held (reserved amounts) and company_due. The balances are in US dollars; currency is only the platform's configured currency symbol ($ by default), while GET /statement returns the code USD.

GET /companies#

Lists the delivery companies and shipping carriers you can use. Send an id as company_id when you quote or create a parcel.

json
{
  "success": true,
  "data": [
    { "id": 13, "name": "Example Delivery", "type": "delivery_company", "pricing_mode": "governorate", "supports_own_account": false, "coverage": null },
    { "id": 16, "name": "Example Carrier", "type": "carrier", "pricing_mode": "carrier_quote", "supports_own_account": true, "coverage": { "SY": [] } }
  ]
}
  • type is delivery_company or carrier.
  • pricing_mode is governorate, per_kg, per_km or km_kg, or carrier_quote for carriers.
  • supports_own_account is true when the carrier allows merchants' own accounts and you have linked yours (menu: My shipping company accounts). Sending use_own_account: true fails with carrier_error when the carrier allows merchants' own accounts and you have not linked yours. If the carrier does not allow them, the flag is ignored and the platform account is used; if the carrier works only with your own account and you omit the flag, you also get carrier_error. The flag has no effect on delivery companies.
  • coverage is null when the company has no restriction, otherwise an object such as {"SY": []} (all governorates) or {"SY": ["..."]} with the governorate names it covers.

GET /governorates#

Returns id, name and name_ar for each active governorate. Send the name or the name_ar in recipient.governorate; if you send the id, send it as a string ("3"), because a JSON number such as 3 is rejected by POST /parcels (see the field table).

GET /pickup-locations#

Returns id, name, phone, address, lat, lng, default, warehouse_id (null unless it is a warehouse location) and active. When you omit pickup_location_id, your default location is used. Add locations in the panel under Pickup locations.

GET /warehouse/stock#

For each active warehouse allocation: warehouse_id, warehouse, pickup_location_id, pallets and items (item_id, sku, name, qty, reserved, available). It returns an empty list when you have no active warehouse allocation.

Pricing and parcels#

POST /quote#

Prices a parcel without creating it. It takes the same fields as POST /parcels, and company_id is required.

bash
curl -X POST https://YOUR_DOMAIN/api/v1/quote \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -H "Accept-Language: en" \
  -d '{ "company_id": 13, "recipient": { "governorate": "Damascus" }, "weight": 2 }'
json
{
  "success": true,
  "data": {
    "pricing_mode": "governorate",
    "pricing_label": "Fixed amount per governorate",
    "governorate": "Damascus",
    "distance_km": 0,
    "weight_kg": 2,
    "delivery_fee": 6,
    "platform_fee": 1,
    "charged_now": 7
  }
}

For a carrier the answer is a different, smaller object: pricing_mode (carrier_quote), carrier, account, carrier_fee, carrier_currency, carrier_fee_usd, platform_fee, charged_now, held_now, required_balance, shipping_paid_by, receiver_pays, net_after_delivery, destination and currency. It has no pricing_label, governorate, distance_km, weight_kg or delivery_fee. If the price cannot be computed you get 422 and quote_failed. The server calculates the price. charged_now is the amount charged at creation: for a delivery company, the delivery fee plus the platform fee, or only the platform fee for your own internal delivery company.

What to send depends on the company's pricing_mode:

pricing_mode Price What you must send
governorate Base fee plus the company's price for the recipient governorate recipient.governorate (if missing, the platform tries the address, then the coordinates)
per_kg Base fee plus weight times the rate per kg weight
per_km Base fee plus distance times the rate per km recipient.lat and recipient.lng; the pickup location needs a map location
km_kg Base fee plus distance and weight charges Coordinates and weight
carrier_quote The carrier's own price: POST /quote requests it from the carrier (carrier_fee), and it is requested again when the parcel is created The same fields, plus use_own_account or shipping_paid_by if you need them

For governorate, per_kg, per_km and km_kg pricing, the weight is rounded up to a whole kilogram (minimum 1), and a company can set a minimum fee that the price never goes below. For carrier_quote neither applies: the platform sends the carrier the weight you gave (default 1) and the carrier prices the parcel.

POST /parcels#

Creates a parcel. The fee is calculated on the server and charged to your wallet. Recipient fields can also be sent flat (customer_name, customer_phone, customer_address, customer_city, lat, long).

Field Required Rules
company_id Yes Integer from GET /companies; the company must be available to you
recipient.name Yes Text, up to 191 characters
recipient.phone Yes Up to 30 characters, matching ^[+0-9\s\-()]{6,30}$
recipient.address Yes Text, up to 1000 characters
recipient.governorate Depends Text up to 120 characters; an id from /governorates sent as a string ("3") is converted to the name, while a JSON number such as 3 fails with 422 on customer_city. Needed for governorate pricing (otherwise the platform tries to find it from the address)
recipient.lat, recipient.lng Depends Numbers from -90 to 90 and from -180 to 180. Required for per_km and km_kg pricing
reference No Text up to 191 characters; your order number, used to make retries safe
cod_amount No Cash on delivery (COD) amount the recipient pays: a number from 0 to 100,000,000 US dollars (a non-USD amount is converted to USD at the platform rate before this limit is checked); default 0 (prepaid)
currency No Currency code of cod_amount; default USD. One of USD, SYP, SAR, AED, QAR, KWD, JOD, IQD, EGP, LBP, TRY, EUR
weight No Kilograms, from 0.01 to 1000; default 1
pickup_location_id No Integer; one of your pickup locations. The default location is used when omitted
warehouse_items If the pickup location is a warehouse List of {"sku": "A1", "qty": 2} or {"item_id": 5, "qty": 1}
note No Text up to 2000 characters
use_own_account No Boolean; ship with your own account at a carrier that supports it
shipping_paid_by No sender, customer or included_in_cod, for carriers that support the choice
wait_for_balance No Boolean; keep the parcel as awaiting_funds instead of failing when the balance is short
json
{
  "success": true,
  "data": {
    "id": 5521,
    "tracking_number": "DS261013AB12CD",
    "reference": "ORDER-1001",
    "stage": "processing",
    "status_code": 5,
    "status_label": "Received Warehouse",
    "company_id": 13,
    "company_name": "Example Delivery",
    "external_tracking_number": null,
    "recipient": {
      "name": "Ahmad Mohammad",
      "phone": "0933123456",
      "governorate": "Damascus",
      "address": "Mazzeh, building 4",
      "lat": 33.5021,
      "lng": 36.2489
    },
    "cod_amount": 25,
    "cod_collected": 0,
    "weight_kg": 1.5,
    "charges": { "delivery_fee": 6, "platform_fee": 1 },
    "delivery_attempts": 0,
    "note": "Call before arriving",
    "tracking_url": "https://YOUR_DOMAIN/t/DS261013AB12CD",
    "created_at": "2026-10-13T10:00:00+03:00",
    "updated_at": "2026-10-13T10:00:00+03:00"
  }
}
  • Success: 201 with the parcel object.
  • Same reference again: 200 with the existing parcel and "duplicate": true, so retries are safe. Parcels that were refunded or expired do not count as duplicates.
  • Not enough balance: 402 and insufficient_balance. With wait_for_balance: true you get 201 and the stage awaiting_funds instead; the parcel is hidden from GET /parcels, an hourly job completes it when the balance is enough and cancels it after 24 hours.
  • Currency: a non-USD amount is converted to US dollars at the platform exchange rate (a missing rate gives 422), so cod_amount in parcel objects is always in USD. A company may accept only some currencies, and then cod_currency fails with a message listing them.
  • External carriers: if the carrier rejects the parcel, nothing is created or charged and you get 422 and carrier_error.
  • Plan: a merchant with no active plan is rejected with 403 and plan_feature before the parcel is processed; a reached parcel quota gives 422 on the field plan.
  • pickup_location_id errors are reported on the field shop_id.

The parcel object has these fields:

Field Meaning
id, tracking_number, reference Parcel id, tracking number and your reference
stage, status_code, status_label Simplified state, exact status code and its label in the request language
company_id, company_name, external_tracking_number Delivery company, and the carrier's tracking number if any
recipient name, phone, governorate, address, lat, lng
cod_amount, cod_collected Amount to collect and amount collected, in USD
weight_kg, charges Weight and delivery_fee and platform_fee
delivery_attempts, note, tracking_url Attempts, note, and the public tracking page link
created_at, updated_at ISO 8601 times
stage Status codes Meaning
awaiting_funds 1, while waiting for funds Saved, waiting for your balance
pending 1 Pending
processing Every other status, such as pickup, warehouse and hubs With the delivery company
out_for_delivery 7, 8 With a delivery courier
delivered 9 Delivered
partially_delivered 32 Partly delivered; cod_collected has the collected amount
returning 11, 12, 24, 26, 27 Delivery failed, on the way back
returned 13, 30 Back with you

GET /parcels#

Lists your parcels, newest first, without events. Query parameters: reference, from and to (YYYY-MM-DD, on the creation date), stage, per_page (1 to 100, default 20) and page. The stage filter accepts pending, out_for_delivery, delivered, partially_delivered, returning and returned; any other value, including processing and awaiting_funds, is ignored. Parcels in the awaiting_funds stage are not listed.

GET /parcels/{tracking}#

Takes a tracking number or a reference (the newest match wins). The answer is the parcel object plus events, in time order. An unknown parcel gives 404 and not_found.

json
"events": [
  { "status_code": 1, "stage": "pending", "status_label": "Pending", "note": "Parcel created (api)", "at": "2026-10-13T10:00:00+03:00" },
  { "status_code": 5, "stage": "processing", "status_label": "Received Warehouse", "note": null, "at": "2026-10-13T10:00:01+03:00" }
]

POST /parcels/{tracking}/cancel#

Cancels a parcel and refunds what was charged at creation. It only works while the status is Pending or Received Warehouse and the parcel has no external_tracking_number; otherwise you get 409 and not_cancellable. The parcel is deleted, and the answer is {"tracking_number": "...", "cancelled": true, "refunded": true}. An API cancellation does not send a parcel.cancelled webhook.

Statement#

GET /statement#

Your wallet statement for a period: opening and closing balance per wallet, a summary per category and every movement. Amounts are in USD.

Query parameters: from and to (YYYY-MM-DD; default from the start of the month to today, and at most 366 days), wallet (recharge, held, return or payout_pending), category (for example parcel_created), per_page (1 to 500, default 100) and page. Invalid dates and unknown wallets are ignored, and from after to is swapped.

json
{
  "success": true,
  "data": {
    "period": { "from": "2026-10-01", "to": "2026-10-31" },
    "currency": "USD",
    "wallets": [
      { "wallet": "recharge", "name": "Shipping balance", "opening": 50, "credits": 200, "debits": 132.5, "closing": 117.5, "count": 41 }
    ],
    "by_category": [
      { "category": "parcel_created", "name": "Parcel creation fee", "credits": 0, "debits": 132.5, "count": 38 }
    ],
    "lines": [
      { "id": 9811, "date": "2026-10-02T10:14:00+03:00", "wallet": "recharge", "wallet_name": "Shipping balance",
        "type": "parcel_created", "type_name": "Parcel creation fee", "credit": 0, "debit": 3.5, "balance": 46.5,
        "description": "...", "parcel": "DS261002AB12CD" }
    ]
  },
  "meta": { "page": 1, "per_page": 100, "total": 41, "last_page": 1 }
}

Webhook endpoints#

These endpoints manage the URLs that receive your notifications. The message format, the signature and the retries are described in the webhook articles.

Method and path What it does
GET /webhooks/events Lists events: event, name, description
GET /webhooks Lists your endpoints (never includes the secret)
POST /webhooks Creates an endpoint and returns secret once
PATCH /webhooks/{id} Edits name, url, events or active; turning active on again resets the failure counter
DELETE /webhooks/{id} Deletes it: {"deleted": true}
POST /webhooks/{id}/test Sends a ping: {"delivered": true, "response_status": 200}, or 422 and delivery_failed
bash
curl -X POST https://YOUR_DOMAIN/api/v1/webhooks \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{ "name": "ERP", "url": "https://example.com/hooks/delivery", "events": ["parcel.delivered", "parcel.returned"] }'
json
{
  "success": true,
  "data": {
    "id": 7, "name": "ERP", "url": "https://example.com/hooks/delivery",
    "events": ["parcel.delivered", "parcel.returned"], "active": true,
    "disabled_reason": null, "last_status": null, "last_error": null, "last_sent_at": null,
    "secret": "whsec_xxxxxxxxxxxxxxxx"
  }
}
  • url is required on creation: https only, up to 500 characters, a public host, no user name or password, and port 443 or 8443.
  • events is required on creation: a list with at least one entry, each * or an exact event name: parcel.created, parcel.status_changed, parcel.out_for_delivery, parcel.delivered, parcel.returning, parcel.returned, parcel.cancelled, parcel.awaiting_funds, wallet.topup or wallet.withdrawal. Group names such as parcel.* are rejected here, although the panel accepts them.
  • name is optional (up to 100 characters) and active is an optional boolean.
  • POST /webhooks needs a plan with the webhooks feature (403 and plan_feature), and you can have at most 10 endpoints or your plan's limit if lower (422 and limit_reached). The other webhook endpoints do not check the feature, but if your plan lacks it the platform sends no event messages to your endpoints.

Code samples#

Create a parcel#

bash
curl -X POST https://YOUR_DOMAIN/api/v1/parcels \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "company_id": 13,
    "reference": "ORDER-1001",
    "recipient": {
      "name": "Ahmad Mohammad",
      "phone": "0933123456",
      "governorate": "Damascus",
      "address": "Mazzeh, next to the mosque, building 4",
      "lat": 33.5021,
      "lng": 36.2489
    },
    "cod_amount": 25,
    "weight": 1.5,
    "note": "Call before arriving"
  }'
javascript
async function createParcel() {
  const res = await fetch("https://YOUR_DOMAIN/api/v1/parcels", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${process.env.DS_API_KEY}`,
      "Content-Type": "application/json",
      "Accept": "application/json",
    },
    body: JSON.stringify({
      company_id: 13,
      reference: "ORDER-1001",
      recipient: {
        name: "Ahmad Mohammad",
        phone: "0933123456",
        governorate: "Damascus",
        address: "Mazzeh, building 4",
      },
      cod_amount: 25,
      weight: 1.5,
    }),
  });
  const json = await res.json();
  if (!json.success) {
    throw new Error(`${res.status} ${json.error.code}: ${json.error.message}`);
  }
  console.log(json.data.tracking_number, json.data.tracking_url, json.duplicate === true);
}

createParcel();
php
<?php
$payload = [
    'company_id' => 13,
    'reference'  => 'ORDER-1001',
    'recipient'  => [
        'name'        => 'Ahmad Mohammad',
        'phone'       => '0933123456',
        'governorate' => 'Damascus',
        'address'     => 'Mazzeh, building 4',
    ],
    'cod_amount' => 25,
    'weight'     => 1.5,
];

$ch = curl_init('https://YOUR_DOMAIN/api/v1/parcels');
curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 30,
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . getenv('DS_API_KEY'),
        'Content-Type: application/json',
        'Accept: application/json',
    ],
    CURLOPT_POSTFIELDS     => json_encode($payload),
]);
$body   = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if (!($body['success'] ?? false)) {
    throw new RuntimeException("$status {$body['error']['code']}: {$body['error']['message']}");
}
echo $body['data']['tracking_number'], "\n";
python
import os
import requests

BASE = "https://YOUR_DOMAIN/api/v1"
HEADERS = {
    "Authorization": f"Bearer {os.environ['DS_API_KEY']}",
    "Accept": "application/json",
}

payload = {
    "company_id": 13,
    "reference": "ORDER-1001",
    "recipient": {
        "name": "Ahmad Mohammad",
        "phone": "0933123456",
        "governorate": "Damascus",
        "address": "Mazzeh, building 4",
    },
    "cod_amount": 25,
    "weight": 1.5,
}

r = requests.post(f"{BASE}/parcels", json=payload, headers=HEADERS, timeout=30)
body = r.json()
if not body["success"]:
    raise RuntimeError(f"{r.status_code} {body['error']['code']}: {body['error']['message']}")
print(body["data"]["tracking_number"], body.get("duplicate", False))

List and track parcels#

bash
curl "https://YOUR_DOMAIN/api/v1/parcels?stage=out_for_delivery&per_page=50&page=1" \
  -H "Authorization: Bearer YOUR_API_KEY" -H "Accept: application/json"

curl "https://YOUR_DOMAIN/api/v1/parcels/ORDER-1001" \
  -H "Authorization: Bearer YOUR_API_KEY" -H "Accept: application/json"
javascript
async function listAndTrack() {
  const base = "https://YOUR_DOMAIN/api/v1";
  const headers = { Authorization: `Bearer ${process.env.DS_API_KEY}`, Accept: "application/json" };

  const list = await (await fetch(`${base}/parcels?stage=out_for_delivery&per_page=50`, { headers })).json();
  console.log(list.meta.total, list.data.map((p) => p.tracking_number));

  const one = await (await fetch(`${base}/parcels/ORDER-1001`, { headers })).json();
  if (one.success) {
    console.log(one.data.stage, one.data.events.map((e) => e.status_label));
  } else if (one.error.code === "not_found") {
    console.log("No parcel with this number or reference");
  }
}

listAndTrack();
php
<?php
function ds_get(string $path): array
{
    $ch = curl_init('https://YOUR_DOMAIN/api/v1' . $path);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 30,
        CURLOPT_HTTPHEADER     => ['Authorization: Bearer ' . getenv('DS_API_KEY'), 'Accept: application/json'],
    ]);
    $body = json_decode(curl_exec($ch), true);
    curl_close($ch);

    return $body;
}

$list = ds_get('/parcels?' . http_build_query(['stage' => 'out_for_delivery', 'per_page' => 50]));
echo $list['meta']['total'], "\n";

$one = ds_get('/parcels/ORDER-1001');
if ($one['success']) {
    echo $one['data']['stage'], "\n";
}
python
import os
import requests

BASE = "https://YOUR_DOMAIN/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['DS_API_KEY']}", "Accept": "application/json"}

page = requests.get(f"{BASE}/parcels", params={"stage": "out_for_delivery", "per_page": 50}, headers=HEADERS, timeout=30).json()
print(page["meta"]["total"], [p["tracking_number"] for p in page["data"]])

one = requests.get(f"{BASE}/parcels/ORDER-1001", headers=HEADERS, timeout=30).json()
if one["success"]:
    print(one["data"]["stage"], [e["status_label"] for e in one["data"]["events"]])

Common mistakes#

  • Missing company_id. 422; call GET /companies first.
  • Governorate pricing without a governorate, or distance pricing without coordinates. 422 on customer_city, lat or shop_id. Call POST /quote before creating.
  • Expecting a new parcel when you repeat a reference. You get the first parcel back with "duplicate": true.
  • Filtering by stage=processing or awaiting_funds. The filter is ignored and every parcel is returned.
  • Subscribing with parcel.* through the API. 422; list the exact events or use *.
  • Reading cod_amount as the currency you sent. It is always in USD.
  • Relying on the message text. Use error.code.

Limits and permissions#

  • Only platform merchants can use this API, and the plan must include the API feature; creating a webhook endpoint (POST /webhooks) also needs the webhooks feature.
  • Route limits are in the table above (one shared counter per IP address, not separate quotas). Plan limits per minute and per day return 429 and rate_limited; the general and route limits return 429 with {"message": "Too Many Attempts."}.
  • Page sizes: parcels 1 to 100, statement 1 to 500.
  • Fees are charged from your shipping balance; a plan with a parcel quota stops creation when the quota is used.

API

  • GET /api/v1/me
  • GET /api/v1/companies
  • GET /api/v1/governorates
  • GET /api/v1/pickup-locations
  • GET /api/v1/warehouse/stock
  • POST /api/v1/quote
  • POST /api/v1/parcels
  • GET /api/v1/parcels
  • GET /api/v1/parcels/{tracking}
  • POST /api/v1/parcels/{tracking}/cancel
  • GET /api/v1/statement
  • GET /api/v1/webhooks/events
  • GET /api/v1/webhooks
  • POST /api/v1/webhooks
  • PATCH /api/v1/webhooks/{id}
  • DELETE /api/v1/webhooks/{id}
  • POST /api/v1/webhooks/{id}/test