Quickstart
Routing between two points in South Africa in three steps:
- Get a key from the Get a Key section below.
- Send the key in the
X-API-Keyheader on every request. - Call
/ors/v2/directions/{profile}withstartandendaslon,lat.
curl -H "X-API-Key: YOUR_KEY" \ "https://routing.dvz.services/ors/v2/directions/driving-car?start=28.0473,-26.2041&end=18.4241,-33.9249"
Get an API Key
API keys are created from the Dashboard. Create a free account and you get 10,000 tokens every month at no cost (no card required). From there you can create, rename, reveal, copy and revoke keys, watch your usage live, and upgrade when you outgrow the free tier. See Tokens & Limits below.
Already have a key? Paste it into the Check Usage box in the sidebar to see its usage right here.
Authentication
All /ors/v2/* endpoints (except /ors/v2/health and /ors/v2/status) require an X-API-Key header. Missing or unknown keys return 401.
Base URL
https://routing.dvz.services
Rate limit
60 requests/sec per key. Bursts beyond return429.
Profiles
driving-car, foot-walking
Tokens & Limits
Usage is metered in tokens, weighted by how heavy each call is. Every account starts with 10,000 free tokens per month, no card needed. When you run out, paid plans and once-off top-up packs are available on the Dashboard.
What each call costs
| Endpoint | Tokens per call |
|---|---|
/ors/v2/directions/{profile} | 5 (simple) · 10 (multi-stop) |
/ors/v2/snap/{profile} | 1 |
/geocode | 1 |
/reverse | 1 |
/ors/v2/matrix/{profile} | 1 per origin×destination pair |
/ors/v2/isochrones/{profile} | 10 |
/ors/v2/directions/driving-car-traffic | 20 |
/nav/sessions (create) | 10 |
/nav/sessions/{id}/updates | 1 per fix |
/nav/sessions/{id}/reroute | 5 |
GET /nav/sessions (list) and /nav/webhook* | 0 (free) |
/incidents | 0 (free) |
/ors/v2/health, /ors/v2/status | 0 (free, no key) |
Only successful (2xx) authenticated calls are billed. Failed or unauthorized requests cost nothing. Tokens reset monthly. On the free tier your keys stop working at 0 until the next reset or an upgrade; paid plans keep serving and you simply top up.
Free tier
10,000 tokens / month, all endpoints, no card.Rate limit
60 requests/sec per key (separate from tokens). Bursts return429.
Manage & upgrade
Balance, usage charts and plans live on the Dashboard.Endpoints
GET /ors/v2/directions/{profile} · Route between two points
Returns a single best route as GeoJSON with turn-by-turn instructions.
Query params
| Name | Type | Required | Description |
|---|---|---|---|
start | string | yes | Start lon,lat |
end | string | yes | End lon,lat |
Example
curl -H "X-API-Key: YOUR_KEY" \ "https://routing.dvz.services/ors/v2/directions/driving-car?start=28.0473,-26.2041&end=18.4241,-33.9249"
POST /ors/v2/directions/{profile} · Advanced routing
Multiple waypoints, alternative routes, preferences, full options.
Example
curl -X POST "https://routing.dvz.services/ors/v2/directions/driving-car" \
-H "X-API-Key: YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"coordinates": [[28.0473,-26.2041], [25.6022,-33.9608], [18.4241,-33.9249]],
"instructions": true,
"preference": "recommended"
}'POST /ors/v2/matrix/{profile} · N-to-N distance + duration
Computes all-pairs distance and duration between up to 50 locations (a 50×50 matrix = 2,500 pairs).
Example
curl -X POST "https://routing.dvz.services/ors/v2/matrix/driving-car" \
-H "X-API-Key: YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"locations": [[28.0473,-26.2041], [18.4241,-33.9249], [31.0218,-29.8587]],
"metrics": ["distance", "duration"]
}'POST /ors/v2/isochrones/{profile} · Reach polygons
Areas reachable from a point within given times or distances.
Example
curl -X POST "https://routing.dvz.services/ors/v2/isochrones/driving-car" \
-H "X-API-Key: YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"locations": [[28.0473,-26.2041]],
"range": [600, 1800],
"range_type": "time"
}'POST /ors/v2/snap/{profile} · Snap points to road network
Snap arbitrary lon/lat points to the nearest road within a radius.
Example
curl -X POST "https://routing.dvz.services/ors/v2/snap/driving-car" \
-H "X-API-Key: YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"locations": [[28.0473,-26.2041]], "radius": 350}'GET /ors/v2/health · Engine health public
Returns {"status": "ready"} when the routing engine is up. No API key required, so it suits monitoring.
GET /ors/v2/status · Engine status & profiles public
Returns the engine version, graph build dates, the enabled routing profiles (driving-car, foot-walking) and the available services (routing, isochrones, matrix, snap). No API key required.
Example
curl "https://routing.dvz.services/ors/v2/status"
GET /geocode · Forward geocode (address → coordinates)
Takes free text and returns ranked coordinate matches. Powered by self-hosted Pelias on OpenStreetMap + Who's On First data, biased to South Africa. It matches businesses and POIs (e.g. Sandton City, Spar Polokwane, OR Tambo International Airport), as well as streets, suburbs, towns and place names. Coverage is as good as OpenStreetMap, so well-known places and chains resolve across both cities and rural towns; tiny unmapped businesses may not. Costs 1 token per call. Response is a stable DVZ envelope.
Query params
| Name | Type | Required | Description |
|---|---|---|---|
q | string | yes | Free-text address or place name (1 to 200 chars) |
limit | int | no | Max results, 1 to 20 (default 5) |
country | string | no | ISO-3166-1 alpha-2 country bias. Defaults to za. Pass empty string for worldwide. |
format | string | no | dvz (default, clean envelope) or raw for the full upstream Pelias GeoJSON. |
Example
curl -H "X-API-Key: YOUR_KEY" \ "https://routing.dvz.services/geocode?q=Sandton%20City&limit=5"
Response shape
{
"query": "Sandton City",
"provider": "pelias",
"count": 1,
"results": [
{
"lat": -26.10853,
"lon": 28.05299,
"display_name": "Sandton City, Sandton, GT, South Africa",
"type": "venue",
"importance": 0.9,
"address": {
"suburb": "Sandton",
"city": "Sandton",
"state": "GT",
"country": "South Africa",
"country_code": "za",
"postcode": null
}
}
]
}type reflects the match layer: venue (business/POI), address, street, locality (town), or admin area. importance is a confidence score from 0 to 1.
GET /reverse · Reverse geocode (coordinates → address)
Takes a lat/lon point and returns the closest matching address or place. Same self-hosted Pelias backend and DVZ envelope as /geocode, but for a single point. Costs 1 token per call.
Query params
| Name | Type | Required | Description |
|---|---|---|---|
lat | float | yes | Latitude, from minus 90 to 90 |
lon | float | yes | Longitude, from minus 180 to 180 |
format | string | no | dvz (default) or raw for the full upstream payload. |
Example
curl -H "X-API-Key: YOUR_KEY" \ "https://routing.dvz.services/reverse?lat=-26.2041&lon=28.0473"
Response shape
{
"lat": -26.2041,
"lon": 28.0473,
"provider": "pelias",
"result": {
"lat": -26.2041,
"lon": 28.0473,
"display_name": "Marshalltown, Johannesburg, GT, South Africa",
"type": "locality",
"address": {
"city": "Johannesburg",
"state": "GT",
"country": "South Africa",
"country_code": "za"
}
}
}Traffic-aware routing
A live traffic variant of driving-car for South African roads. You get a normal GeoJSON route back, but the duration reflects current congestion from TomTom plus any known incidents on the way, instead of static road speeds.
Access
Paid plans only (Starter and up). Free plan keys get403.
Cost
20 tokens per call.Speed
Typically 2 to 3 seconds, since live flow is sampled along the route.POST /ors/v2/directions/driving-car-traffic · Route with live traffic
The body takes a single field, coordinates: a start and an end point as [lon, lat] pairs. Other driving-car options such as instructions or preference are not read by this endpoint.
Body
| Name | Type | Required | Description |
|---|---|---|---|
coordinates | array | yes | Start and end, each [lon, lat]. |
Example
curl -X POST "https://routing.dvz.services/ors/v2/directions/driving-car-traffic" \
-H "X-API-Key: YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"coordinates": [[28.0473,-26.2041], [28.1878,-25.7461]]
}'GET /ors/v2/directions/driving-car-traffic · Same, as a GET
Handy for quick tests and browser maps. Same response and cost as the POST form.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
start | string | yes | Start as lon,lat. |
end | string | yes | End as lon,lat. |
Example
curl -H "X-API-Key: YOUR_KEY" \ "https://routing.dvz.services/ors/v2/directions/driving-car-traffic?start=28.0473,-26.2041&end=28.1878,-25.7461"
What comes back
A standard GeoJSON route. summary.duration already holds the adjusted time (live traffic plus incident penalty), so you can use it as the ETA directly. The breakdown sits in properties.traffic:
"traffic": {
"profile": "driving-car-traffic",
"base_duration_s": 2860.4,
"traffic_adjusted_s": 3412.9,
"incident_penalty_s": 0.0,
"adjusted_duration_s": 3412.9,
"provider": "TomTomTrafficProvider",
"incident_provider": "NullIncidentProvider",
"incidents_on_route": []
}| Field | Description |
|---|---|
base_duration_s | Free flow time with no traffic, in seconds. |
traffic_adjusted_s | Time after applying live congestion. |
incident_penalty_s | Extra time added for incidents on the chosen route. |
adjusted_duration_s | Traffic time plus incident penalty. Same value as summary.duration. |
provider | Where live flow came from. TomTomTrafficProvider in production. |
incident_provider | Where incidents came from. See Incidents. |
incidents_on_route | Incidents touching the chosen route, each with road, type, severity and description. |
Congestion rerouting
Most calls score one direct route. Alternatives are fetched and compared only when it is worth it: an incident sits on the direct route, or live traffic makes it at least 15% slower than free flow (and at least a minute slower in total). The fastest option after traffic and incidents is returned, so the geometry can differ from plain driving-car when the main road is jammed.
Free plan response
{
"error": {
"code": 403,
"message": "Traffic-aware routing requires a paid plan. See /documentation/."
}
}A 400 is returned if start or end is not a numeric lon,lat pair.
Webhooks
Instead of polling journeys, give us a URL and we push journey events to it as they happen: a driver going off route, arriving, or their phone going quiet. One URL per API key; events for every journey that key creates go there. All webhook calls are free.
POST /nav/webhook · Register your receiver
Registers (or replaces) your receiving URL and returns a signing secret, shown only in this response. Store it. Registering again issues a new secret, which is how you rotate it.
Body
| Name | Type | Required | Description |
|---|---|---|---|
url | string | yes | Must be https, resolve to a public address, and contain no username or password. Redirects are never followed. |
Example
curl -X POST "https://routing.dvz.services/nav/webhook" \
-H "X-API-Key: YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://dispatch.example.co.za/routing-events"}'
# -> { "url": "...", "secret": "whsec_...", "events": [...] }GET /nav/webhook / DELETE · View or remove
GET shows your URL, the events you'll receive, how many are queued (pending), how many failed in the last 24 hours, and the last delivery. The secret is never shown again. DELETE removes the receiver and cancels anything still queued.
POST /nav/webhook/test · Send a test event
Queues a webhook.test event so you can check your receiver and signature check before any journeys run.
GET /nav/webhook/deliveries · Delivery log
Your most recent events, newest first, each with its status (pending, delivered, failed, cancelled), attempts, and the last HTTP status or error your receiver gave. Use it while building your receiver. Optional limit (1-200, default 50).
Events
| Type | Sent when | data |
|---|---|---|
journey.started | A journey is created | profile, distance_m, duration_s, expires_at, plus traffic on the traffic profile |
journey.off_route | 75 m off the route for 15 s and moving away | distance_from_route_m, reroute_suggested |
journey.back_on_route | Back within 25 m after going off route | - |
journey.rerouted | /reroute was called | reroute_count, distance_m, duration_s, plus traffic on the traffic profile |
journey.stopped | Under 5 km/h for 5 minutes | - |
journey.resumed | Moving again after a stop | - |
journey.eta_delayed | Arrival has slipped another 5 minutes since the last alert | delay_s (against the first ETA), arrive_at |
journey.arrived | Within 50 m of the destination (final) | - |
journey.signal_lost | No GPS fix for heartbeat_s (2 minutes) | silent_s, heartbeat_s |
journey.signal_restored | Fixes resume after signal_lost | silent_s |
journey.ended | You ended the journey | - |
journey.expired | The 6-hour lifetime ran out | - |
webhook.test | You asked for a test event | message |
Events fire on changes, not repeatedly: a driver off route for ten minutes is one off_route, not hundreds. signal_lost is not raised before a journey's first fix or after it has arrived, since there is nothing to miss. Thresholds can be changed per journey with config when you create it.
Payload
Every event has the same shape. journey is where things stood at the moment of the event, including the metadata you sent at creation, and data holds the event-specific fields above.
{
"id": "evt_8kZ2xq4Lm1Pq",
"type": "journey.off_route",
"occurred_at": "2026-10-20T08:14:03Z",
"journey": {
"session_id": "nav_6j4e35vzCBy4ifHZ",
"metadata": { "driver_id": "D-114", "job_id": "JOB-9" },
"state": "OFFROUTE",
"position": { "lon": 28.0242, "lat": -26.1956 },
"snapped": { "lon": 28.0280, "lat": -26.1951, "distance_from_route_m": 925.1 },
"progress": { "completed_m": 2952.4, "remaining_m": 14201.1, "pct": 0.1721 },
"eta": { "remaining_s": 746.0, "arrive_at": 1792451785.3 },
"reroute_count": 1,
"last_fix_at": "2026-10-20T08:14:03+00:00"
},
"data": { "distance_from_route_m": 925.1, "reroute_suggested": true }
}eta.arrive_at is your fix's ts plus the remaining seconds, so it uses whatever clock you send. Send Unix seconds and it is a Unix timestamp.
Verifying the signature
Every request carries X-DVZ-Signature: t=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256 of "<t>.<raw body>" keyed with your secret. Check it against the raw body before parsing, and reject signatures more than five minutes old so a captured request can't be replayed. Header names are case-insensitive. Also sent: X-DVZ-Event, X-DVZ-Event-Id and X-DVZ-Delivery-Attempt.
const crypto = require("crypto");
// rawBody: the exact bytes received (Buffer), before JSON.parse
function verifyDvz(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries((header || "").split(",").map(p => p.split("=")));
const ts = Number(parts.t);
if (!ts || Math.abs(Date.now() / 1000 - ts) > toleranceSec) return false;
const expected = crypto.createHmac("sha256", secret)
.update(ts + ".").update(rawBody).digest("hex");
return typeof parts.v1 === "string" && parts.v1.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}import hashlib, hmac, time
def verify_dvz(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
try:
parts = dict(p.split("=", 1) for p in header.split(","))
ts = int(parts["t"])
except (ValueError, KeyError, AttributeError):
return False
if abs(time.time() - ts) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))Delivery
- Reply with any 2xx within 10 seconds. Anything else (an error, a timeout or a redirect) counts as a failed attempt. Do slow work after replying.
- Retries follow after 30 s, 2 min, 10 min, 30 min, 1 h and 2 h (about 3 h 40 min in all), then the event is marked
failedand the queue moves on. - In order: events for your key arrive in the order they happened. While one is being retried, the ones behind it wait, so you never see
back_on_routebeforeoff_route. - At least once: after a timeout you may receive an event twice. Use
idto ignore repeats.
Incidents
Current road incidents (crashes, closures, roadworks) as points you can draw on a map. Free to call (0 tokens), though an API key is still required. The same feed is used by driving-car-traffic, which penalises routes passing near an incident and will route around a closure.
GET /incidents · List incidents
Returns every known incident, or only those inside a bounding box.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
bbox | string | no | min_lon,min_lat,max_lon,max_lat. Omit for all incidents. |
Example
curl -H "X-API-Key: YOUR_KEY" "https://routing.dvz.services/incidents?bbox=27.9,-26.4,28.3,-26.0"
Current response
The live incident feed (SANRAL i-Traffic) is not connected yet, so production currently returns an empty list. Your integration can call this endpoint today and will start receiving incidents once the feed is switched on, with no change on your side.
{
"provider": "NullIncidentProvider",
"count": 0,
"incidents": []
}Incident shape
Once the feed is live, each item in incidents looks like this:
{
"id": "12345",
"road": "N1",
"type": "closure",
"severity": "closure",
"lat": -26.045,
"lon": 28.096,
"description": "Full closure at Buccleuch interchange"
}Fields
| Field | Description |
|---|---|
severity | low, medium, high or closure. Higher severity adds a bigger penalty when routing. |
type | Incident kind, for example accident, roadworks or closure. |
lat / lon | Incident location. |
A 400 is returned if bbox is not four numeric values.
Postman collection
Every endpoint above is also in our Postman collection, ready to run, with collection variables for base URL, API key and routing profile. It includes traffic routing, incidents, journey sessions and webhooks. Creating a journey session saves its id to the sessionId variable, so the follow up requests work straight away.
After importing, open the collection's Variables tab and paste your apiKey. The baseUrl defaults to production; change it to http://localhost for local dev.
Also works in Insomnia, Bruno, Hoppscotch, and most other API clients via "Import from URL" with the JSON link above.
Errors
| Status | Meaning | What to do |
|---|---|---|
401 | Missing / unknown / revoked key, out of tokens, or suspended account | Check your key. If you've used all your monthly tokens, top up or upgrade on the Dashboard. |
429 | Rate limit exceeded (60 req/sec) | Add client-side throttling or request a higher limit. |
400 | Bad request (malformed body, invalid params) | Check the example for that endpoint. |
500/502/503 | Engine unavailable | Check /ors/v2/health. If it's down, contact the team. |