Tshova Partner API · v1

Put Tshova rides inside your software

Request a ride, let verified drivers compete on price, accept the best offer and track the trip to the door — all over a simple REST API. The same name-your-price marketplace riders use in the app, as a few HTTP calls.

Base URL

https://api.tshova.co.zw/v1

Auth

Bearer API key — tsk_live_…

Format

JSON in, JSON out. UTF-8. HTTPS only.

Price

Free to integrate. You pay only for trips taken.

Ideal for hotels, hospitals, delivery desks, booking systems and dispatchers who want to move people without running their own fleet. Get a key in seconds at dev.tshova.co.zw — no waiting on email.

Quickstart

From zero to a live ride request in three steps.

1 · Get your API key

Sign in at the developer dashboard, verify your email, and click New key. Copy it once and store it as a server secret — it looks like tsk_live_4f8c….

2 · Request a ride

Send the pickup, destination and your offer (the fare you propose). The ride enters bidding and nearby drivers start competing.

curl -X POST https://api.tshova.co.zw/v1/rides \
  -H "Authorization: Bearer $TSHOVA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "pickup":      { "lat": -17.8292, "lng": 31.0522, "address": "Harare CBD" },
    "destination": { "lat": -17.8003, "lng": 31.0388, "address": "Avondale" },
    "fare": 3.50,
    "passenger": { "name": "Tinashe", "phone": "+263771234567" },
    "webhookUrl": "https://yourapp.com/hooks/tshova"
  }'
const res = await fetch("https://api.tshova.co.zw/v1/rides", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${process.env.TSHOVA_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    pickup:      { lat: -17.8292, lng: 31.0522, address: "Harare CBD" },
    destination: { lat: -17.8003, lng: 31.0388, address: "Avondale" },
    fare: 3.50,
    passenger: { name: "Tinashe", phone: "+263771234567" },
    webhookUrl: "https://yourapp.com/hooks/tshova",
  }),
});
const ride = await res.json();
console.log(ride.rideId, ride.status); // → "…" "bidding"
import os, requests

r = requests.post(
    "https://api.tshova.co.zw/v1/rides",
    headers={"Authorization": f"Bearer {os.environ['TSHOVA_KEY']}"},
    json={
        "pickup":      {"lat": -17.8292, "lng": 31.0522, "address": "Harare CBD"},
        "destination": {"lat": -17.8003, "lng": 31.0388, "address": "Avondale"},
        "fare": 3.50,
        "passenger": {"name": "Tinashe", "phone": "+263771234567"},
        "webhookUrl": "https://yourapp.com/hooks/tshova",
    },
)
ride = r.json()
print(ride["rideId"], ride["status"])  # → "…" "bidding"
$ch = curl_init("https://api.tshova.co.zw/v1/rides");
curl_setopt_array($ch, [
  CURLOPT_RETURNTRANSFER => true,
  CURLOPT_POST => true,
  CURLOPT_HTTPHEADER => [
    "Authorization: Bearer " . getenv("TSHOVA_KEY"),
    "Content-Type: application/json",
  ],
  CURLOPT_POSTFIELDS => json_encode([
    "pickup"      => ["lat" => -17.8292, "lng" => 31.0522, "address" => "Harare CBD"],
    "destination" => ["lat" => -17.8003, "lng" => 31.0388, "address" => "Avondale"],
    "fare"       => 3.50,
    "passenger"  => ["name" => "Tinashe", "phone" => "+263771234567"],
    "webhookUrl" => "https://yourapp.com/hooks/tshova",
  ]),
]);
$ride = json_decode(curl_exec($ch), true);
echo $ride["rideId"] . " " . $ride["status"];

3 · Accept the best offer

Poll GET /v1/rides/:id/offers (or wait for the offer.received webhook), then accept one. The driver is dispatched and you get a live trackUrl.


Authentication

Every request must carry your secret API key. Send it as a Bearer token (preferred) or in the X-Api-Key header.

Authorization: Bearer tsk_live_4f8c9e2a…
X-Api-Key: tsk_live_4f8c9e2a…
Keep keys secret. They live server-side only — never ship a key in a mobile app, browser, or public repo. Rotate or revoke instantly in the dashboard; a revoked key stops working immediately.

Test mode

Build and verify your whole integration without touching a real driver or a single dollar. Mint a test key in the dashboard (choose Test when creating a key) — test keys are prefixed tsk_test_…, live keys tsk_live_…. The API, base URL and payloads are identical; the key decides the mode.

A ride created with a test key is completely isolated:

Point your webhook at your own test endpoint, create a ride with your test key, and watch your integration handle the entire lifecycle end-to-end. When you're ready for real rides, switch to your tsk_live_… key — no code changes.

curl https://api.tshova.co.zw/v1/rides \
  -H "Authorization: Bearer tsk_test_…" \
  -H "Content-Type: application/json" \
  -d '{
    "pickup":      { "lat": -17.8292, "lng": 31.0522, "address": "Harare CBD" },
    "destination": { "lat": -17.8640, "lng": 31.0290, "address": "Avondale" },
    "fare": 4.50,
    "webhookUrl": "https://yourapp.com/hooks/tshova"
  }'
# → { "rideId": "…", "status": "bidding", "testMode": true, … }
# then your webhook receives: offer.received → ride.status (driver_arriving,
#      arrived, in_progress, completed) over ~15s, from a simulated driver.

The ride lifecycle

A ride moves through a small set of statuses. Your integration reacts to each — by polling or via webhooks.

biddingDrivers compete on your fare
→
driver_arrivingYou accepted an offer
→
arrivedDriver at pickup
→
in_progressTrip underway
→
completedDropped off

A ride can become cancelled from any pre-completion state (via POST …/cancel, driver cancel, or timeout).

Base URL & versions

All endpoints are under a single, versioned base URL. The legacy path https://tshova.co.zw/api/v1 continues to work and is equivalent.

https://api.tshova.co.zw/v1

API reference

Five endpoints cover the whole flow. All requests need the Authorization header; all responses are JSON.

Create a ride

POST/v1/rides

Creates a bidding ride and dispatches it to nearby drivers. Returns 201.

Body parameters

FieldTypeDescription
pickuprequiredobjectlat, lng (numbers, required) and address (string).
destinationrequiredobjectlat, lng (required) and address.
farerequirednumberYour offer, in USD. Must be > 0. Drivers may accept it or counter-offer.
carTypeoptionalenumregular (default), comfort, or family.
passengerCountoptionalinteger1–7. Default 1.
passengeroptionalobjectname and phone — shown to the driver so they can find your rider.
notesoptionalstringFree-text note to the driver (≤ 200 chars).
rideTypeoptionalenumstandard (default) or intercity.
distanceKmoptionalnumberTrip distance. Auto-computed (great-circle) if omitted.
webhookUrloptionalstringHTTPS URL to receive events for this ride. Overrides the key's default webhook.
curl -X POST https://api.tshova.co.zw/v1/rides \
  -H "Authorization: Bearer $TSHOVA_KEY" \
  -H "Content-Type: application/json" \
  -d '{"pickup":{"lat":-17.8292,"lng":31.0522,"address":"City Hall"},
       "destination":{"lat":-17.8003,"lng":31.0388,"address":"NUST"},
       "fare":3.50,"carType":"regular","passenger":{"name":"Tinashe"}}'
const ride = await tshova.post("/v1/rides", {
  pickup:      { lat: -17.8292, lng: 31.0522, address: "City Hall" },
  destination: { lat: -17.8003, lng: 31.0388, address: "NUST" },
  fare: 3.50, carType: "regular", passenger: { name: "Tinashe" },
});
ride = tshova.post("/v1/rides", json={
    "pickup":      {"lat": -17.8292, "lng": 31.0522, "address": "City Hall"},
    "destination": {"lat": -17.8003, "lng": 31.0388, "address": "NUST"},
    "fare": 3.50, "carType": "regular", "passenger": {"name": "Tinashe"},
})
201 · Response
{
  "rideId": "8Kf2c9aB1x",
  "status": "bidding",
  "fare": 3.5,
  "carType": "regular",
  "distanceKm": 5.2,
  "trackUrl": "https://tshova.co.zw/t/9b1c…"
}

Retrieve a ride

GET/v1/rides/:rideId

The current state of a ride — status, the accepted driver, and live location once the trip is moving.

curl https://api.tshova.co.zw/v1/rides/8Kf2c9aB1x \
  -H "Authorization: Bearer $TSHOVA_KEY"
200 · Response
{
  "rideId": "8Kf2c9aB1x",
  "status": "in_progress",
  "fare": 3.5,
  "carType": "regular",
  "passengerCount": 1,
  "offersCount": 4,
  "pickup":      { "lat": -17.8292, "lng": 31.0522, "address": "City Hall" },
  "destination": { "lat": -17.8003, "lng": 31.0388, "address": "NUST" },
  "driver": {
    "name": "Blessing M.", "phone": "+2637…", "rating": 4.9,
    "vehicle": "Toyota Wish · white", "plate": "ABZ 1234"
  },
  "trackUrl": "https://tshova.co.zw/t/9b1c…",
  "liveLocation": { "lat": -20.166, "lng": 28.61, "heading": 74, "status": "in_progress" }
}

List offers

GET/v1/rides/:rideId/offers

Live driver offers for a bidding ride, cheapest first. Poll this, or listen for the offer.received webhook.

200 · Response
{
  "rideId": "8Kf2c9aB1x",
  "offers": [
    { "offerId": "of_a1", "price": 3.5, "etaMinutes": 4,
      "driver": { "name": "Blessing M.", "rating": 4.9, "plate": "ABZ 1234", "vehicle": "Toyota Wish" } },
    { "offerId": "of_b7", "price": 4.0, "etaMinutes": 2,
      "driver": { "name": "Rutendo K.", "rating": 4.8, "plate": "ACD 5678", "vehicle": "Honda Fit" } }
  ]
}

Accept an offer

POST/v1/rides/:rideId/accept

Accept one offer by offerId. The ride moves to driver_arriving and all other offers are dropped.

Body parameters

FieldTypeDescription
offerIdrequiredstringThe offerId from the offers list.
curl -X POST https://api.tshova.co.zw/v1/rides/8Kf2c9aB1x/accept \
  -H "Authorization: Bearer $TSHOVA_KEY" \
  -H "Content-Type: application/json" \
  -d '{"offerId":"of_a1"}'
200 · Response
{ "success": true, "rideId": "8Kf2c9aB1x", "status": "driver_arriving" }

Cancel a ride

POST/v1/rides/:rideId/cancel

Cancel any ride that isn't already completed or cancelled (returns 409 if it is).

200 · Response
{ "success": true, "rideId": "8Kf2c9aB1x", "status": "cancelled" }

Webhooks

Instead of polling, give us a webhookUrl (per ride, or a default per key) and we'll POST a JSON event whenever something changes. Respond 2xx within 8 seconds.

Events

EventFires when
offer.receivedA driver submits an offer on your bidding ride.
ride.statusThe ride's status changes (arriving, arrived, in progress, completed, cancelled).
Example delivery
{
  "event": "ride.status",
  "rideId": "8Kf2c9aB1x",
  "status": "driver_arriving",
  "fare": 3.5,
  "driver": { "name": "Blessing M.", "phone": "+2637…", "plate": "ABZ 1234", "vehicle": "Toyota Wish" },
  "trackUrl": "https://tshova.co.zw/t/9b1c…",
  "at": 1757894400000
}

Every delivery also carries these headers:

HeaderValue
X-Tshova-EventThe event name, e.g. ride.status.
X-Tshova-Signaturet=<unix>,v1=<hmac> — sign it to prove the call is really from us (below).
Only HTTPS, public URLs. For safety we block localhost, private ranges and cloud-metadata targets. Set a default webhookUrl per key in the dashboard, or pass one per ride — the per-ride value wins. Respond 2xx within 8 seconds; we don't retry, so make handling quick and idempotent on rideId.

Verify signatures

Each webhook carries an X-Tshova-Signature: t=<timestamp>,v1=<signature> header. The signature is an HMAC-SHA256, hex-encoded, of the string "{t}.{raw request body}", keyed with your endpoint's signing secret (whsec_…, found on the Webhooks page of your dashboard). To verify a delivery, recompute the signature over the raw body and compare in constant time; reject anything older than five minutes to stop replays.

const crypto = require("crypto");
const express = require("express");
const app = express();

function verifyTshova(rawBody, header, secret) {
  const p = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
  const expected = crypto.createHmac("sha256", secret)
    .update(`${p.t}.${rawBody}`).digest("hex");
  const signed = crypto.timingSafeEqual(
    Buffer.from(expected), Buffer.from(p.v1 || ""));
  const fresh = Math.abs(Date.now() / 1000 - Number(p.t)) < 300;
  return signed && fresh;
}

// Capture the RAW body — verifying the parsed object will fail.
app.post("/hooks/tshova", express.raw({ type: "application/json" }), (req, res) => {
  const sig = req.get("X-Tshova-Signature") || "";
  if (!verifyTshova(req.body.toString(), sig, process.env.TSHOVA_WHSEC)) {
    return res.status(400).send("bad signature");
  }
  const event = JSON.parse(req.body.toString());
  // handle event.event ("offer.received" | "ride.status") + event.rideId
  res.sendStatus(200);
});
import hmac, hashlib, time
from flask import Flask, request, abort

app = Flask(__name__)

def verify_tshova(raw_body: bytes, header: str, secret: str) -> bool:
    p = dict(kv.split("=") for kv in header.split(","))
    expected = hmac.new(
        secret.encode(),
        f"{p['t']}.".encode() + raw_body,
        hashlib.sha256,
    ).hexdigest()
    if not hmac.compare_digest(expected, p.get("v1", "")):
        return False
    return abs(time.time() - int(p["t"])) < 300

@app.post("/hooks/tshova")
def hook():
    sig = request.headers.get("X-Tshova-Signature", "")
    if not verify_tshova(request.get_data(), sig, TSHOVA_WHSEC):
        abort(400)
    event = request.get_json()
    # handle event["event"] + event["rideId"]
    return "", 200
<?php
$raw = file_get_contents("php://input");
$header = $_SERVER["HTTP_X_TSHOVA_SIGNATURE"] ?? "";
parse_str(str_replace(",", "&", $header), $p);   // t=..&v1=..

$expected = hash_hmac("sha256", $p["t"] . "." . $raw, getenv("TSHOVA_WHSEC"));
if (!hash_equals($expected, $p["v1"] ?? "") || abs(time() - (int) $p["t"]) > 300) {
  http_response_code(400);
  exit("bad signature");
}

$event = json_decode($raw, true);
// handle $event["event"] + $event["rideId"]
http_response_code(200);
Keep the signing secret server-side. Rolling it in the dashboard takes effect on the very next delivery, so update your server at the same time.

Live tracking

Every ride comes with a trackUrl — a public, no-auth live map of the driver en route. Drop it into a confirmation SMS, email or your own UI; it needs no API key and works in any browser.

Rate limits

Generous by default. Ride creation is limited per key; reads are unlimited.

ActionLimit
Create a ride (POST /v1/rides)30 / minute · 300 / hour
All GET reads (status, offers)Unlimited

Over the limit returns 429 Too Many Requests. Back off and retry.

Errors

Standard HTTP status codes. The body is { "error": "message" }.

CodeMeaning
400Bad request — missing/invalid pickup, destination or fare.
401Missing or invalid API key.
404Ride not found (or not owned by your key).
409Conflict — e.g. cancelling a ride that's already finished.
429Rate limit exceeded.
500Something went wrong on our side. Safe to retry.

Going to production

Secure your key

Server-side only, in a secrets manager. Rotate on a schedule from the dashboard.

Prefer webhooks

Set a webhookUrl and react to events instead of tight polling loops.

Handle bidding

Accept promptly — offers are live. Show your rider the driver, ETA and price.

Be idempotent

Track each rideId; ignore duplicate webhook deliveries gracefully.

Ready? Grab your API key →