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/jsonwith bodies, always sendAccept: application/json, and addAccept-Language: enfor English messages and labels (without it they are in Arabic; the samples on this page are shown in English). - Envelope:
{"success": true, "data": ...}(onlyGET /parcelsandGET /statementaddmeta) 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.
{
"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.
{
"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": [] } }
]
}
typeisdelivery_companyorcarrier.pricing_modeisgovernorate,per_kg,per_kmorkm_kg, orcarrier_quotefor carriers.supports_own_accountis true when the carrier allows merchants' own accounts and you have linked yours (menu: My shipping company accounts). Sendinguse_own_account: truefails withcarrier_errorwhen 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 getcarrier_error. The flag has no effect on delivery companies.coverageisnullwhen 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.
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 }'
{
"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 |
{
"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:
201with the parcel object. - Same
referenceagain:200with the existing parcel and"duplicate": true, so retries are safe. Parcels that were refunded or expired do not count as duplicates. - Not enough balance:
402andinsufficient_balance. Withwait_for_balance: trueyou get201and the stageawaiting_fundsinstead; the parcel is hidden fromGET /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), socod_amountin parcel objects is always in USD. A company may accept only some currencies, and thencod_currencyfails with a message listing them. - External carriers: if the carrier rejects the parcel, nothing is created or charged and you get
422andcarrier_error. - Plan: a merchant with no active plan is rejected with
403andplan_featurebefore the parcel is processed; a reached parcel quota gives422on the fieldplan. pickup_location_iderrors are reported on the fieldshop_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.
"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.
{
"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 |
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"] }'
{
"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"
}
}
urlis required on creation:httpsonly, up to 500 characters, a public host, no user name or password, and port 443 or 8443.eventsis 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.topuporwallet.withdrawal. Group names such asparcel.*are rejected here, although the panel accepts them.nameis optional (up to 100 characters) andactiveis an optional boolean.POST /webhooksneeds a plan with the webhooks feature (403andplan_feature), and you can have at most 10 endpoints or your plan's limit if lower (422andlimit_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#
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"
}'
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
$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";
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#
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"
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
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";
}
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; callGET /companiesfirst. - Governorate pricing without a governorate, or distance pricing without coordinates.
422oncustomer_city,latorshop_id. CallPOST /quotebefore creating. - Expecting a new parcel when you repeat a
reference. You get the first parcel back with"duplicate": true. - Filtering by
stage=processingorawaiting_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_amountas 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
429andrate_limited; the general and route limits return429with{"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/meGET /api/v1/companiesGET /api/v1/governoratesGET /api/v1/pickup-locationsGET /api/v1/warehouse/stockPOST /api/v1/quotePOST /api/v1/parcelsGET /api/v1/parcelsGET /api/v1/parcels/{tracking}POST /api/v1/parcels/{tracking}/cancelGET /api/v1/statementGET /api/v1/webhooks/eventsGET /api/v1/webhooksPOST /api/v1/webhooksPATCH /api/v1/webhooks/{id}DELETE /api/v1/webhooks/{id}POST /api/v1/webhooks/{id}/test