Turship Tracking API
Show live Turship tracking on your website, in your app or inside your order system. Track up to 100 shipments per request, create shipments programmatically, receive signed webhooks the moment a status changes — or drop our ready-made tracking widget into any page.
Overview
The API lives at https://flytfrance.com/api/v1. All requests and responses use JSON (UTF-8). Timestamps are ISO 8601 with time-zone offset; countries are ISO 3166-1 alpha-2 codes; weights are in kilograms.
Bulk tracking
Track up to 100 shipments in one request — ideal for order dashboards and nightly syncs.
Shipment creation
Create shipments from your store or ERP, get the tracking number and a printable label instantly.
Webhooks
Receive a signed HTTPS callback every time one of your shipments changes status — no polling.
Embed widget
Add a branded tracking box to your website with one line of HTML — no API key in the browser.
Quick start
- Request an API key from Turship. You'll receive a key like
tsk_live_…(and a webhook secret). - Call the bulk tracking endpoint from your server:
curl -X POST https://flytfrance.com/api/v1/track \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"tracking_numbers": ["TS4827193056", "TS6388578830"]}'3. Display status.label, progress.step and events to your customers — or link to tracking_url.
Authentication
Send your key in the Authorization header on every request (the X-API-Key header is also accepted):
Authorization: Bearer tsk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxEach key has scopes: track (bulk tracking) and shipments (create, list, read and cancel your own shipments). Keys can be revoked instantly by Turship.
Rate limits
Each key has a per-minute limit (default 60 requests/minute; higher limits on request). Every authenticated response includes:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Requests allowed per minute for your key |
X-RateLimit-Remaining | Requests left in the current window |
Retry-After | Seconds to wait (only on 429 responses) |
Tip: one bulk request with 100 numbers counts as a single request — batch your lookups and cache results for a few minutes.
Bulk tracking
Track up to 100 shipments. Results are returned in the order requested; duplicates are removed; spaces and dashes in numbers are ignored. Unknown or malformed numbers don't fail the request — they come back with found: false and an error.code of invalid_format or not_found.
{ "tracking_numbers": ["TS4827193056", "TS1234567890"] }Response
{
"ok": true,
"count": 2,
"found": 1,
"not_found": 1,
"results": [
{
"found": true,
"tracking_number": "TS4827193056",
"tracking_number_display": "TS48 2719 3056",
"reference": null,
"service": { "code": "express", "label": "International Express" },
"status": {
"code": "out_for_delivery",
"label": "Out for delivery",
"type": "progress",
"final": false,
"description": "The shipment is with a courier and will be delivered today."
},
"progress": { "step": 5, "total": 6, "steps": ["Created", "Picked up", "In transit", "Customs", "Out for delivery", "Delivered"] },
"origin": { "city": "Amman", "country": "JO", "country_name": "Jordan" },
"destination": { "city": "Dubai", "country": "AE", "country_name": "United Arab Emirates" },
"pieces": 1,
"weight_kg": 2.5,
"estimated_delivery": "2026-10-16",
"delivered_at": null,
"signed_by": null,
"last_update": "2026-10-16T08:12:00+03:00",
"events": [
{
"status": "out_for_delivery",
"label": "Out for delivery",
"description": "The shipment is with a courier and will be delivered today.",
"location": "Dubai",
"country": "AE",
"country_name": "United Arab Emirates",
"time": "2026-10-16T08:12:00+03:00"
}
],
"tracking_url": "https://flytfrance.com/track/TS4827193056"
},
{
"tracking_number": "TS1234567890",
"found": false,
"error": { "code": "invalid_format", "message": "Not a valid Turship tracking number (TS + 10 digits)." }
}
]
}Privacy: tracking responses never contain addresses, phone numbers, emails or values. For shipments created by your own key, reference is also included.
Convenience version for up to 25 numbers (comma-separated). Same response format.
Shipments
Create a shipment. Returns 201 with the full shipment, its new tracking_number and a signed label_url (printable 4×6 in label). Send an Idempotency-Key header (e.g. your order ID) so network retries never create duplicates — a repeated key returns the original shipment with Idempotent-Replayed: true.
{
"reference": "ORDER-1001",
"service_type": "express",
"sender_name": "Acme Store",
"sender_phone": "+962790000000",
"sender_email": "[email protected]",
"sender_address": "1 Rainbow St",
"sender_city": "Amman",
"sender_country": "JO",
"receiver_name": "John Smith",
"receiver_phone": "+971500000000",
"receiver_email": "[email protected]",
"receiver_address": "22 Marina Walk",
"receiver_city": "Dubai",
"receiver_country": "AE",
"pieces": 1,
"weight_kg": 2.5,
"dimensions_cm": "30x20x15",
"contents": "Clothing",
"declared_value": 120,
"currency": "USD",
"cod_amount": null,
"estimated_delivery": "2026-10-16",
"notify_email": true
}| Field | Rules |
|---|---|
service_type * | express, domestic, air_freight, sea_freight, road_freight, ecommerce |
sender_name, receiver_name * | max 150 characters |
sender_city, receiver_city * | max 100 characters |
sender_country, receiver_country * | ISO 3166-1 alpha-2, e.g. JO, AE, US |
pieces | integer 1–9999 (default 1) |
weight_kg, declared_value, cod_amount | positive numbers |
currency | ISO 4217, default USD |
estimated_delivery | YYYY-MM-DD |
*_email | valid email; receives status emails when notify_email is true |
Validation errors return 422 with error.details listing each invalid field.
List shipments created by (or assigned to) your key, newest first. Filters: status, created_from, created_to (YYYY-MM-DD), limit (max 100), offset.
Full details of one of your shipments, including sender, receiver, package and the complete event history.
Returns a signed URL of the printable 4×6 in shipping label with barcode.
Cancel a shipment that has not been picked up yet (status label_created). Optional body: {"reason": "Customer cancelled the order"}. After pickup, returns 409 conflict — contact Turship support instead.
Status codes
Shipments move through six progress steps: Created → Picked up → In transit → Customs → Out for delivery → Delivered. status.type tells you how to style it: info, progress, exception (needs attention), success or cancelled. The live catalogue is also available at GET /statuses.
| Code | Label | Step | Type | Final |
|---|---|---|---|---|
label_created | Shipment information received | 1 | info | |
picked_up | Picked up | 2 | progress | |
in_transit | In transit | 3 | progress | |
arrived_at_facility | Arrived at Turship facility | 3 | progress | |
departed_facility | Departed Turship facility | 3 | progress | |
customs_clearance | Customs clearance in progress | 4 | progress | |
customs_xray_scan | Customs X-ray scan | 4 | progress | |
customs_documents_required | Customs documents required | 4 | exception | |
customs_duties_due | Customs duties and taxes due | 4 | exception | |
customs_hold | Held at customs | 4 | exception | |
customs_cleared | Cleared customs | 4 | progress | |
out_for_delivery | Out for delivery | 5 | progress | |
delivery_attempted | Delivery attempted | 5 | exception | |
on_hold | On hold | — | exception | |
delivered | Delivered | 6 | success | Yes |
returned_to_sender | Returned to sender | — | exception | Yes |
cancelled | Cancelled | — | cancelled | Yes |
Errors
Errors use standard HTTP status codes and a consistent body:
{ "ok": false, "error": { "code": "rate_limited", "message": "Rate limit of 60 requests per minute exceeded. Retry after 12 seconds." } }| HTTP | error.code | When |
|---|---|---|
| 400 | invalid_request | Malformed JSON, missing or too many tracking numbers |
| 401 | unauthorized | Missing, invalid or revoked API key |
| 403 | forbidden | Key lacks the required scope |
| 404 | not_found | Unknown endpoint, or shipment not owned by your key |
| 405 | method_not_allowed | Wrong HTTP method (see the Allow header) |
| 413 | invalid_request | Request body exceeds 1 MB |
| 409 | conflict | Action not allowed in the shipment's current status |
| 422 | validation_failed | Invalid shipment fields (see error.details) |
| 429 | rate_limited | Too many requests — honour Retry-After |
| 500 | server_error | Temporary problem — retry with exponential back-off |
Webhooks
Give Turship an HTTPS URL and we'll POST an event to it whenever one of your shipments is created, changes status or is cancelled. Respond with any 2xx within 5 seconds. Failed deliveries are retried up to 5 times (after 1 min, 5 min, 30 min and 2 h).
| Event type | Sent when |
|---|---|
shipment.created | A shipment owned by your key is created |
shipment.status_updated | A new tracking event is recorded (pickup, hub scans, customs, delivery, exceptions…) |
shipment.cancelled | The shipment is cancelled |
webhook.test | Turship sends a test from the admin panel |
POST /your/webhook HTTP/1.1
Content-Type: application/json
X-Turship-Event: shipment.status_updated
X-Turship-Delivery: 1842
X-Turship-Signature: t=1792000000,v1=5f3a…e9c1
{
"id": "evt_9b1c2d3e4f5a6b7c8d9e0f1a",
"type": "shipment.status_updated",
"created_at": "2026-10-16T14:05:00+03:00",
"data": { "tracking_number": "TS4827193056", "reference": "ORDER-1001", "status": { "code": "delivered", "…": "…" }, "events": [ … ] }
}Verify the signature
Compute HMAC-SHA256(webhook_secret, t + "." + raw_body) and compare it with v1 using a constant-time comparison. Reject events older than 5 minutes to prevent replays. Use the event id to ignore duplicates.
<?php
$secret = getenv('TURSHIP_WEBHOOK_SECRET');
$payload = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_TURSHIP_SIGNATURE'] ?? '';
parse_str(str_replace(',', '&', $header), $sig); // ['t' => …, 'v1' => …]
if (!isset($sig['t'], $sig['v1']) || !is_string($sig['t']) || !ctype_digit($sig['t'])
|| !is_string($sig['v1']) || !preg_match('/^[a-f0-9]{64}$/', $sig['v1'])) {
http_response_code(400);
exit('Invalid signature');
}
$expected = hash_hmac('sha256', $sig['t'] . '.' . $payload, $secret);
if (!hash_equals($expected, $sig['v1'] ?? '') || abs(time() - (int) $sig['t']) > 300) {
http_response_code(400);
exit('Invalid signature');
}
$event = json_decode($payload, true);
// e.g. update your order: $event['data']['reference'], $event['data']['status']['code']
http_response_code(200);const crypto = require('crypto');
const express = require('express');
const app = express();
app.post('/turship/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.get('X-Turship-Signature') || '';
const sig = Object.fromEntries(header.split(',').map(p => p.split('=')));
if (!/^\d+$/.test(sig.t || '') || !/^[a-f0-9]{64}$/.test(sig.v1 || '')) {
return res.status(400).send('Invalid signature');
}
const expected = crypto.createHmac('sha256', process.env.TURSHIP_WEBHOOK_SECRET)
.update(`${sig.t}.${req.body}`).digest('hex');
const valid = sig.v1 && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig.v1))
&& Math.abs(Date.now() / 1000 - Number(sig.t)) < 300;
if (!valid) return res.status(400).send('Invalid signature');
const event = JSON.parse(req.body);
// update your order using event.data.reference / event.data.status.code
res.sendStatus(200);
});import hmac, hashlib, os, time, json
from flask import Flask, request, abort
app = Flask(__name__)
@app.post("/turship/webhook")
def turship_webhook():
raw = request.get_data()
sig = dict(p.split("=", 1) for p in request.headers.get("X-Turship-Signature", "").split(",") if "=" in p)
if not sig.get("t", "").isdigit() or len(sig.get("v1", "")) != 64:
abort(400)
expected = hmac.new(os.environ["TURSHIP_WEBHOOK_SECRET"].encode(), f"{sig.get('t')}.".encode() + raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, sig.get("v1", "")) or abs(time.time() - int(sig.get("t", 0))) > 300:
abort(400)
event = json.loads(raw)
# update your order using event["data"]["reference"]
return "", 200Use tracking on your website
The recommended pattern: your website calls your own server, and your server calls Turship with the secret key. Cache each result for a few minutes to stay well within your rate limit.
<?php
// track.php — your server-side proxy: GET /track.php?numbers=TS4827193056,TS6388578830
$numbers = array_slice(array_filter(array_map('trim', explode(',', $_GET['numbers'] ?? ''))), 0, 100);
$ch = curl_init('https://flytfrance.com/api/v1/track');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('TURSHIP_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode(['tracking_numbers' => array_values($numbers)]),
]);
$body = curl_exec($ch);
http_response_code(curl_getinfo($ch, CURLINFO_HTTP_CODE) ?: 502);
header('Content-Type: application/json');
echo $body;// Node 18+ (built-in fetch)
async function trackShipments(numbers) {
const res = await fetch('https://flytfrance.com/api/v1/track', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.TURSHIP_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ tracking_numbers: numbers.slice(0, 100) }),
});
if (res.status === 429) {
const wait = Number(res.headers.get('Retry-After') || 5);
await new Promise(r => setTimeout(r, wait * 1000));
return trackShipments(numbers);
}
const data = await res.json();
if (!data.ok) throw new Error(data.error.message);
return data.results;
}import os, requests
def track_shipments(numbers):
r = requests.post(
"https://flytfrance.com/api/v1/track",
headers={"Authorization": f"Bearer {os.environ['TURSHIP_API_KEY']}"},
json={"tracking_numbers": numbers[:100]},
timeout=10,
)
r.raise_for_status()
return r.json()["results"]
for s in track_shipments(["TS4827193056", "TS6388578830"]):
print(s["tracking_number"], s["status"]["label"] if s["found"] else s["error"]["code"])<!-- Calls YOUR server proxy (never Turship directly — keep the API key secret) -->
<form id="track-form">
<input id="track-input" placeholder="Tracking number" required>
<button>Track</button>
</form>
<div id="track-result"></div>
<script>
document.getElementById('track-form').addEventListener('submit', async (e) => {
e.preventDefault();
const n = document.getElementById('track-input').value.trim();
const res = await fetch('/track.php?numbers=' + encodeURIComponent(n));
const data = await res.json();
const s = data.results && data.results[0];
document.getElementById('track-result').textContent = !s ? 'Error'
: s.found ? `${s.status.label} — ${s.status.description}` : 'Tracking number not found';
});
</script>Embed widget (no code, no API key)
Paste this into any page of your website. Customers type their tracking number and see the full Turship tracking card inside your page. Options: theme=light|dark, accent= a 6-digit hex colour, and n= to pre-fill numbers (e.g. on your order-confirmation page).
<iframe src="https://flytfrance.com/track/embed?theme=light&accent=d97706"
title="Track your shipment" loading="lazy"
style="width:100%;max-width:820px;height:520px;border:0;border-radius:12px"></iframe>
<!-- Optional: auto-resize the iframe to its content -->
<script>
window.addEventListener('message', function (e) {
if (e.origin === 'https://flytfrance.com' && e.data && e.data.turshipTrackHeight) {
document.querySelectorAll('iframe[src^="https://flytfrance.com/track/embed"]')
.forEach(function (f) { f.style.height = (e.data.turshipTrackHeight + 10) + 'px'; });
}
});
</script>Pre-filled example for an order page: https://flytfrance.com/track/embed?n=TS4827193056. You can also simply link customers to https://flytfrance.com/track/{tracking_number}.
OpenAPI & tools
The machine-readable specification is available at /api/v1/openapi.json — import it into Postman, Insomnia or Swagger UI, or generate a client for your language with OpenAPI Generator.
Best practices
- Batch lookups: one
POST /trackwith 100 numbers instead of 100 single calls. - Prefer webhooks over polling for shipments you create.
- Cache tracking results for 5–15 minutes on your side.
- Honour
Retry-Afteron429and retry5xxwith exponential back-off. - Send an
Idempotency-Keywhen creating shipments. - Treat new status codes gracefully — use
status.typefor styling andstatus.labelfor display.
Versioning
This is version 1. New fields may be added to responses at any time; breaking changes will only ship under a new version path (/api/v2) with advance notice. The current version is returned in the X-Turship-Api-Version header.
Get your API key
Tell us about your business, expected volumes and integration. Our team will issue your key, webhook secret and rate limit — usually within one business day.
Request API Access