Your store does not run on Shopify, WooCommerce or Magento? Our connectors use nothing you cannot use yourself: a REST API, one key, five calls. This guide is the shortest path, with the requests ready to copy.
POST /ordersPOST /orders/{id}/quotesPOST /orders/{id}/shipGET /shipments/{ref}/label.pdfGET /shipments/{ref}The merchant creates the key himself, in his dashboard: Settings → API keys → Create a key. It is shown once. A “shp_live_…” key creates real parcels; a “shp_test_…” key is for development. Send it in the Authorization header of every request.
Base URL https://shiplayer.online
Content-Type application/json
Authorization: Bearer shp_live_xxxxxxxxxxxxxxxxxxxxxxxx
# Vérifiez la clé : votre compte marchand
curl https://shiplayer.online/merchants/me \
-H "Authorization: Bearer $SHIPLAYER_KEY"
Every response is JSON. Amounts are strings in dirhams (“38.08”), never floats. Dates are UTC, ISO 8601. The limit is about 10 requests per second per key: beyond it you get 429, retry after a second.
The full reference of every route, generated from the code: shiplayer.online/docs.
Send the order as soon as it is placed in your store. It lands in the merchant's “Orders” inbox with its risk score and the WhatsApp link to confirm it with the customer — shipping is decided afterwards, by you or by him.
curl -X POST https://shiplayer.online/orders \
-H "Authorization: Bearer $SHIPLAYER_KEY" -H "Content-Type: application/json" \
-d '{
"order_id": "CMD-10482",
"origin": {
"raw": "12 rue Ibnou Mounir, Maarif", "city": "Casablanca",
"phone": "0661000000", "recipient_name": "Boutique Atlas"
},
"destination": {
"raw": "Résidence Yasmine, imm. B, apt 4, Guéliz", "city": "Marrakech",
"phone": "0662000000", "recipient_name": "Salma Bennani"
},
"parcel": { "weight_kg": "0.8" },
"payment_mode": "cod",
"cod_amount_mad": "450",
"declared_value_mad": "450",
"ready_at": "2026-10-14T09:00:00",
"contents": "Caftan",
"notes": "Appeler avant livraison"
}'
201 Created
{
"order_id": "CMD-10482",
"recipient": "Salma Bennani", "city": "Marrakech", "zone": "metro",
"cod_mad": "450", "needs_address_review": false,
"risk": { "score": 34, "band": "medium", "action": "confirm_first", ... },
"whatsapp_url": "https://wa.me/212662000000?text=...",
...
}
| Field | Required | Notes |
|---|---|---|
order_id | yes | Your order number, 64 characters max. It must be unique: sending it again answers 409, which makes your retries safe. |
origin, destination | yes | raw (the free-text address), city, phone, recipient_name. No zone to supply: we infer it from the city. Spell the city the way carriers know it (“Missour”, not “Mayssour”). |
parcel.weight_kg | yes | Greater than 0. Some carriers do not take parcels that are too light or too heavy for some cities: they then simply do not appear in the rates. |
payment_mode | yes | cod (cash on delivery, in which case cod_amount_mad is required) or prepaid. |
declared_value_mad | yes | The value of the goods, the basis for compensation and insurance. |
ready_at | yes | When the parcel will be ready for pickup. |
contents, notes | no | The contents are printed on the label; the note is passed to the carrier. |
exchange_contents | no | An exchange: what the driver takes back from the customer during the same visit. |
Optional if you let Shiplayer choose. Otherwise this call lists every carrier serving the city, with its price and its expected landed cost — the price plus the failure risk and the cash tied up, the method explained on the home page. Carriers that do not serve the city are listed in not_offered, with the reason.
curl -X POST https://shiplayer.online/orders/CMD-10482/quotes \
-H "Authorization: Bearer $SHIPLAYER_KEY"
200 OK
{
"recommended": { "carrier_code": "relaisexpress", "price_mad": "30.00", ... },
"candidates": [
{ "carrier_code": "relaisexpress", "price_mad": "30.00",
"expected_landed_cost_mad": "48.10", "promised_transit_hours": 48,
"rate_basis": "contract", "insurance_offer_fee_mad": "6.75", ... },
{ "carrier_code": "sendit", "price_mad": "45.00", ... }
],
"not_offered": [
{ "carrier_code": "digylog", "reason": "Digylog n'accepte pas les colis de moins de 1.2 kg." }
]
}
Name a carrier, or leave “auto”: Shiplayer picks the lowest landed cost according to the merchant's setting. The answer is the full parcel — keep shiplayer_ref, the reference for everything else, and carrier_tracking_ref, the carrier's own number. The tracking link to give the customer is shiplayer.online/t/{shiplayer_ref}.
curl -X POST https://shiplayer.online/orders/CMD-10482/ship \
-H "Authorization: Bearer $SHIPLAYER_KEY" -H "Content-Type: application/json" \
-d '{ "carrier_code": "auto", "insured": false }'
200 OK
{
"shiplayer_ref": "SHP8B064DE84C28",
"order_id": "CMD-10482",
"status": "created",
"total_price_mad": "30.00",
"legs": [ { "carrier_code": "relaisexpress", "carrier_tracking_ref": "RE123456789",
"from_city": "Casablanca", "to_city": "Marrakech", "price_mad": "30.00",
"promised_transit_hours": 48, ... } ],
"events": [ { "at": "2026-10-13T14:18:44Z", "status": "created",
"description": "Expédition créée, étiquette prête" } ],
"pickup": { "stated": true, "date": "2026-10-14", "when": "demain avant 12 h", ... },
"cancellable": true,
...
}
The label is a PDF in the carrier's format, with the logo and the contents. Print it and stick it on the parcel. Pickups are managed in the merchant's dashboard (Shipments → Pickups): he sees the driver's time slot there and confirms the handover. Cancelling is possible as long as the parcel has not been picked up.
# L'étiquette (PDF)
curl https://shiplayer.online/shipments/SHP8B064DE84C28/label.pdf \
-H "Authorization: Bearer $SHIPLAYER_KEY" -o etiquette.pdf
# Plusieurs étiquettes en un PDF
curl "https://shiplayer.online/labels.pdf?ref=SHP8B064DE84C28&ref=SHP2F19C0A7E415" \
-H "Authorization: Bearer $SHIPLAYER_KEY" -o etiquettes.pdf
# Annuler avant ramassage
curl -X POST https://shiplayer.online/shipments/SHP8B064DE84C28/cancel \
-H "Authorization: Bearer $SHIPLAYER_KEY"
The best way is the webhook below: every change reaches you without asking. Otherwise read the parcel whenever you like; the answer is the same as at shipping time, with the full history in events. The public page /t/{ref} and its JSON /track/{ref} need no key and show nothing confidential: you can give them to the customer.
curl https://shiplayer.online/shipments/SHP8B064DE84C28 \
-H "Authorization: Bearer $SHIPLAYER_KEY"
200 OK
{
"shiplayer_ref": "SHP8B064DE84C28", "status": "delivered",
"delivered_at": "2026-10-16T11:02:00Z",
"events": [
{ "at": "2026-10-13T14:18:44Z", "status": "created", "description": "Expédition créée, étiquette prête" },
{ "at": "2026-10-14T10:40:12Z", "status": "carrier_received", "description": "Pris en charge", "location": "Casablanca" },
{ "at": "2026-10-15T07:12:30Z", "status": "in_transit", "location": "Marrakech" },
{ "at": "2026-10-16T08:05:00Z", "status": "out_for_delivery" },
{ "at": "2026-10-16T11:02:00Z", "status": "delivered" }
],
"pod": { "recipient_name": "Salma Bennani", "recorded_at": "2026-10-16T11:02:00Z", ... },
...
}
# Sans clé, pour le client
curl https://shiplayer.online/track/SHP8B064DE84C28
The merchant enters your system's address in Settings → Webhook and gets a secret, shown once. From then on Shiplayer POSTs JSON to that address on every parcel creation and every status change. The body is an envelope whose data is exactly the GET /shipments/{ref} object, plus tracking_url: there is only one format to learn.
| Event | When |
|---|---|
shipment.created | The parcel has just been created: it has its reference, its carrier and its label. |
shipment.updated | The parcel's status changed (received by the carrier, in transit, delivered, failed, returned, cancelled…). Once per change, not on every scan. |
ping | The dashboard's “Test” button. Answer 200 and that is all. |
POST https://ma-boutique.ma/webhooks/shiplayer
Content-Type: application/json
User-Agent: Shiplayer-Webhooks/1.0
Shiplayer-Event: shipment.updated
Shiplayer-Delivery: whd_3f9c2a7b1e4d5a6c8b0f
Shiplayer-Signature: t=1760364004,v1=5c1a0f…e2b9
{
"id": "whd_3f9c2a7b1e4d5a6c8b0f",
"event": "shipment.updated",
"created_at": "2026-10-16T11:02:04Z",
"merchant_id": "mrc_atlas",
"data": {
"shiplayer_ref": "SHP8B064DE84C28",
"order_id": "CMD-10482",
"status": "delivered",
"tracking_url": "https://shiplayer.online/t/SHP8B064DE84C28",
"legs": [ ... ], "events": [ ... ], "pod": { ... },
...
}
}
Every delivery is signed with the merchant's secret: v1 = HMAC-SHA256(secret, "{t}.{body}"), in hex. Recompute it over the body exactly as received, byte for byte, before parsing it; compare in constant time; refuse a t more than five minutes from your clock. A delivery whose signature does not match did not come from Shiplayer.
import hmac, hashlib, time
def verify(secret: str, header: str, body: bytes) -> bool:
fields = dict(p.split("=", 1) for p in header.split(","))
t, v1 = int(fields["t"]), fields["v1"]
if abs(time.time() - t) > 300:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
# Flask
@app.post("/webhooks/shiplayer")
def shiplayer():
if not verify(SECRET, request.headers["Shiplayer-Signature"], request.get_data()):
abort(401)
msg = request.get_json()
if msg["event"] == "shipment.updated":
update_order(msg["data"]["order_id"], msg["data"]["status"], msg["data"]["tracking_url"])
return "", 200
function shiplayer_verify(string $secret, string $header, string $body): bool {
parse_str(str_replace(',', '&', $header), $f);
if (!isset($f['t'], $f['v1']) || abs(time() - (int)$f['t']) > 300) return false;
$expected = hash_hmac('sha256', $f['t'] . '.' . $body, $secret);
return hash_equals($expected, $f['v1']);
}
$body = file_get_contents('php://input');
if (!shiplayer_verify(SECRET, $_SERVER['HTTP_SHIPLAYER_SIGNATURE'] ?? '', $body)) {
http_response_code(401); exit;
}
$msg = json_decode($body, true);
if ($msg['event'] === 'shipment.updated') {
update_order($msg['data']['order_id'], $msg['data']['status'], $msg['data']['tracking_url']);
}
http_response_code(200);
const crypto = require("crypto");
function verify(secret, header, rawBody) {
const f = Object.fromEntries(header.split(",").map(p => p.split("=")));
if (Math.abs(Date.now() / 1000 - Number(f.t)) > 300) return false;
const expected = crypto.createHmac("sha256", secret).update(`${f.t}.`).update(rawBody).digest("hex");
return expected.length === f.v1.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(f.v1));
}
// Express — gardez le corps brut : express.raw(), pas express.json()
app.post("/webhooks/shiplayer", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(SECRET, req.get("Shiplayer-Signature") || "", req.body)) return res.sendStatus(401);
const msg = JSON.parse(req.body);
if (msg.event === "shipment.updated") updateOrder(msg.data.order_id, msg.data.status, msg.data.tracking_url);
res.sendStatus(200);
});
Answer 2xx within ten seconds, then process. Any other answer, or no answer, is retried: after 1, 5 and 15 minutes, then 1, 3, 6, 12 and 24 hours — eight attempts over a day. The same message can therefore arrive twice: keep its id and ignore a duplicate. Order is not guaranteed between two close messages: trust the status and the events in data, not the arrival order. The merchant sees every delivery, its answer and its next attempt in Settings → Webhook → Delivery log.
The address must be https, on a public server. During development a tunnel (ngrok, Cloudflare Tunnel) to your machine does the job; the “Test” button sends a ping right away and shows your server's answer.
Every carrier has its own codes; Shiplayer maps them all onto this list. The original code stays in each event's raw_carrier_code if you need it.
| status | Meaning |
|---|---|
created | Parcel created, label ready, waiting for pickup. |
picked_up | The merchant confirmed the handover to the driver. |
carrier_received | The carrier scanned the parcel: it is in its network. |
in_transit | On its way to the destination city. |
out_for_delivery | With the driver, delivery today. |
attempt_failed | Attempt failed (absent, unreachable, refused). The merchant decides in his dashboard: new attempt or return. |
delivered | Delivered. Final. The cash on delivery is now owed by the carrier; pod carries the proof when it provides one. |
returned | Returned to the merchant. Final. |
cancelled | Cancelled before pickup: nothing left the shop. Final. |
lost | Lost, declared by the carrier. Final; compensation applies. |
An error body is always an object with a detail key: a sentence in French, or for 422 the list of refused fields with their location.
401 | Key missing, unknown or revoked. |
404 | The order or parcel does not exist — or belongs to another merchant, which is the same answer. |
409 | Conflict: order_id already used, order already shipped, account not yet validated, Idempotency-Key reused with a different body. The message says which. |
422 | Invalid body: missing field, too long, negative amount, city served by no carrier. |
429 | Too many requests: wait a second and retry. |
5xx | Shiplayer or the carrier did not answer. Nothing was created: retry, with the same Idempotency-Key if you use POST /shipments. |