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.
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…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:
- It is never dispatched to real drivers — no one is notified, nothing is charged, no wallet moves.
- A built-in simulator drives it through the full journey automatically, firing the same webhooks a real ride would: an offer.received, then ride.status for
driver_arriving→arrived→in_progress→completed, over about 15 seconds. - The response includes
"testMode": true, and a fake Test Driver is attached at acceptance so your driver-handling code has data to render.
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.
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.
API reference
Five endpoints cover the whole flow. All requests need the Authorization header; all responses are JSON.
Create a ride
Creates a bidding ride and dispatches it to nearby drivers. Returns 201.
Body parameters
| Field | Type | Description |
|---|---|---|
| pickuprequired | object | lat, lng (numbers, required) and address (string). |
| destinationrequired | object | lat, lng (required) and address. |
| farerequired | number | Your offer, in USD. Must be > 0. Drivers may accept it or counter-offer. |
| carTypeoptional | enum | regular (default), comfort, or family. |
| passengerCountoptional | integer | 1–7. Default 1. |
| passengeroptional | object | name and phone — shown to the driver so they can find your rider. |
| notesoptional | string | Free-text note to the driver (≤ 200 chars). |
| rideTypeoptional | enum | standard (default) or intercity. |
| distanceKmoptional | number | Trip distance. Auto-computed (great-circle) if omitted. |
| webhookUrloptional | string | HTTPS 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"},
}){
"rideId": "8Kf2c9aB1x",
"status": "bidding",
"fare": 3.5,
"carType": "regular",
"distanceKm": 5.2,
"trackUrl": "https://tshova.co.zw/t/9b1c…"
}Retrieve a ride
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"{
"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
Live driver offers for a bidding ride, cheapest first. Poll this, or listen for the offer.received webhook.
{
"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
Accept one offer by offerId. The ride moves to driver_arriving and all other offers are dropped.
Body parameters
| Field | Type | Description |
|---|---|---|
| offerIdrequired | string | The 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"}'{ "success": true, "rideId": "8Kf2c9aB1x", "status": "driver_arriving" }Cancel a ride
Cancel any ride that isn't already completed or cancelled (returns 409 if it is).
{ "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
| Event | Fires when |
|---|---|
| offer.received | A driver submits an offer on your bidding ride. |
| ride.status | The ride's status changes (arriving, arrived, in progress, completed, cancelled). |
{
"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:
| Header | Value |
|---|---|
X-Tshova-Event | The event name, e.g. ride.status. |
X-Tshova-Signature | t=<unix>,v1=<hmac> — sign it to prove the call is really from us (below). |
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);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.
| Action | Limit |
|---|---|
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" }.
| Code | Meaning |
|---|---|
400 | Bad request — missing/invalid pickup, destination or fare. |
401 | Missing or invalid API key. |
404 | Ride not found (or not owned by your key). |
409 | Conflict — e.g. cancelling a ride that's already finished. |
429 | Rate limit exceeded. |
500 | Something 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 →
