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.

REST + JSONBulk: 100 per callSigned webhooksEmbeddable widgetOpenAPI 3

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

  1. Request an API key from Turship. You'll receive a key like tsk_live_… (and a webhook secret).
  2. 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_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Keep your key secret. Call the API from your server only — never put the key in browser JavaScript or mobile apps. Browser-side (CORS) calls are intentionally not supported. To show tracking on a website without a backend, use the embed widget.

Each 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:

HeaderMeaning
X-RateLimit-LimitRequests allowed per minute for your key
X-RateLimit-RemainingRequests left in the current window
Retry-AfterSeconds 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

POST/trackscope: track

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.

GET/track?tracking_numbers=TS4827193056,TS6388578830scope: track

Convenience version for up to 25 numbers (comma-separated). Same response format.

Shipments

POST/shipmentsscope: 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
}
FieldRules
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
piecesinteger 1–9999 (default 1)
weight_kg, declared_value, cod_amountpositive numbers
currencyISO 4217, default USD
estimated_deliveryYYYY-MM-DD
*_emailvalid email; receives status emails when notify_email is true

Validation errors return 422 with error.details listing each invalid field.

GET/shipments?status=out_for_delivery&limit=25&offset=0scope: shipments

List shipments created by (or assigned to) your key, newest first. Filters: status, created_from, created_to (YYYY-MM-DD), limit (max 100), offset.

GET/shipments/{tracking_number}scope: shipments

Full details of one of your shipments, including sender, receiver, package and the complete event history.

GET/shipments/{tracking_number}/labelscope: shipments

Returns a signed URL of the printable 4×6 in shipping label with barcode.

POST/shipments/{tracking_number}/cancelscope: shipments

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.

CodeLabelStepTypeFinal
label_createdShipment information received1info
picked_upPicked up2progress
in_transitIn transit3progress
arrived_at_facilityArrived at Turship facility3progress
departed_facilityDeparted Turship facility3progress
customs_clearanceCustoms clearance in progress4progress
customs_xray_scanCustoms X-ray scan4progress
customs_documents_requiredCustoms documents required4exception
customs_duties_dueCustoms duties and taxes due4exception
customs_holdHeld at customs4exception
customs_clearedCleared customs4progress
out_for_deliveryOut for delivery5progress
delivery_attemptedDelivery attempted5exception
on_holdOn hold—exception
deliveredDelivered6successYes
returned_to_senderReturned to sender—exceptionYes
cancelledCancelled—cancelledYes

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." } }
HTTPerror.codeWhen
400invalid_requestMalformed JSON, missing or too many tracking numbers
401unauthorizedMissing, invalid or revoked API key
403forbiddenKey lacks the required scope
404not_foundUnknown endpoint, or shipment not owned by your key
405method_not_allowedWrong HTTP method (see the Allow header)
413invalid_requestRequest body exceeds 1 MB
409conflictAction not allowed in the shipment's current status
422validation_failedInvalid shipment fields (see error.details)
429rate_limitedToo many requests — honour Retry-After
500server_errorTemporary 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 typeSent when
shipment.createdA shipment owned by your key is created
shipment.status_updatedA new tracking event is recorded (pickup, hub scans, customs, delivery, exceptions…)
shipment.cancelledThe shipment is cancelled
webhook.testTurship 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 "", 200

Use 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 /track with 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-After on 429 and retry 5xx with exponential back-off.
  • Send an Idempotency-Key when creating shipments.
  • Treat new status codes gracefully — use status.type for styling and status.label for 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