NHT

Vietnam Directions API: Motorbike Routing & Turn-by-Turn the Google-Compatible Way

A developer how-to for the GoGoDuk Directions API: call /v1/directions, read real ETA and distance, decode the polyline onto your map, render turn-by-turn, and handle the ESTIMATED fallback.

Nguyen Hoang TuanNguyen Hoang Tuan16 Jun 20269 min read

Most location features in Vietnamese products start with a distance circle: "we deliver within 5 km." It is easy to ship and almost always wrong. In real cities, two points that are 3 km apart as the crow flies can be a 6 km ride once you account for rivers, one-way streets, bridges, and alleys—and a motorbike takes a very different path than a car. If your delivery fee, ETA, or driver assignment is built on straight-line distance, it drifts away from reality the moment traffic gets dense.

This how-to walks through integrating the GoGoDuk Directions API end to end: calling GET /v1/directions, reading the real distance and ETA, decoding the route polyline onto a map, rendering turn-by-turn instructions, and handling the straight-line fallback gracefully. The response is intentionally Google-Directions-compatible, so if you already speak that shape, most of your existing code carries over.


Why real routing beats straight-line distance

A routing engine answers a different question than a distance formula. Instead of "how far apart are these two points," it answers "what path does a vehicle actually take, and how long does it take." For Vietnam, the vehicle detail is the whole game:

  • Motorbikes and cars route differently. Bikes cut through narrow lanes and alleys (hẻm/ngõ) that cars cannot enter, and they are exempt from some car-only restrictions. A car-profile route over-estimates both distance and time for the way most Vietnamese deliveries actually move.
  • Straight-line distance under-charges and over-promises. A radius-based fee is cheap in the easy cases and badly wrong across a river or a one-way grid. A radius-based ETA promises arrivals your driver cannot hit.
  • The road graph changes. Real road networks have turn restrictions, dead ends, and disconnected segments. Only a routing engine that walks the graph captures them.

GoGoDuk's routing runs on the OpenStreetMap road graph tuned for a motobike profile, so the path—and the estimated time—match how a real rider would go. If you are new to the platform, the GoGoDuk developer overview covers the rest of the API surface (geocoding, suggest, boundaries).


The directions endpoint at a glance

The endpoint is a single GET request. You pass an origin and a destination as lat,lng pairs (the same order Google and Goong use) and a vehicle profile.

| Parameter | Required | Format | Notes | | --- | --- | --- | --- | | origin | Yes | lat,lng | Start point, WGS84 (latitude first) | | destination | Yes | lat,lng | End point, WGS84 | | vehicle | No | enum | motobike (default). car is planned. Other values return 400 INVALID_VEHICLE. |

Authentication uses the X-API-Key header (keep the key server-side), and the key needs the routing:read scope. A minimal call looks like this:

curl -G "https://api.gogoduk.com/v1/directions" \
  -H "X-API-Key: $GOGODUK_API_KEY" \
  --data-urlencode "origin=21.0285,105.8542" \
  --data-urlencode "destination=21.2212,105.8072" \
  --data-urlencode "vehicle=motobike"

A Google-compatible response

The response mirrors the Google Directions API structure, so a team migrating from Google or Goong rarely has to reshape anything. The shape is routes[] → legs[] → steps[], with a status flag at the top:

| Field | Meaning | | --- | --- | | status | OK for a real road route, ESTIMATED for a straight-line fallback | | routes[].legs[].distance | { text: "5.7 km", value: 5704.1 }value is in meters | | routes[].legs[].duration | { text: "9 phút", value: 511.2 }value is in seconds | | routes[].legs[].steps[] | Turn-by-turn steps with html_instructions, maneuver, and a per-step polyline | | routes[].overview_polyline.points | Encoded polyline for the whole route (precision 5) | | routes[].bounds | { northeast, southwest } for fitting the map viewport |

A trimmed response:

{
  "status": "OK",
  "routes": [
    {
      "legs": [
        {
          "distance": { "text": "5.7 km", "value": 5704.1 },
          "duration": { "text": "9 phút", "value": 511.2 },
          "steps": [
            {
              "distance": { "text": "243 m", "value": 243.4 },
              "html_instructions": "Head northwest on Pasteur",
              "maneuver": "turn-right",
              "polyline": { "points": "suw`Aq{fjSOF{Bx@..." },
              "travel_mode": "DRIVING"
            }
          ]
        }
      ],
      "overview_polyline": { "points": "suw`Aq{fjS..." }
    }
  ]
}

If you already parse Google Directions, the field names line up. The two things to watch are units (distance.value in meters, duration.value in seconds) and the status flag, which is where the fallback behavior lives.


Calling the API and reading ETA and distance

Call the endpoint from your server, then read the leg-level distance and duration. Those two numbers are usually all a delivery or ride-hailing flow needs for pricing and an arrival estimate.

// Server-side: fetch a route and read real distance + ETA
type LatLng = { lat: number; lng: number };

async function getRoute(origin: LatLng, destination: LatLng) {
  const params = new URLSearchParams({
    origin: `${origin.lat},${origin.lng}`,
    destination: `${destination.lat},${destination.lng}`,
    vehicle: "motobike",
  });

  const res = await fetch(`https://api.gogoduk.com/v1/directions?${params}`, {
    headers: { "X-API-Key": process.env.GOGODUK_API_KEY! },
  });

  if (!res.ok) throw new Error(`Directions failed: ${res.status}`);
  const data = await res.json();

  const leg = data.routes?.[0]?.legs?.[0];
  return {
    status: data.status as "OK" | "ESTIMATED",
    distanceMeters: leg?.distance?.value ?? 0,
    durationSeconds: leg?.duration?.value ?? 0,
    distanceText: leg?.distance?.text ?? "",
    durationText: leg?.duration?.text ?? "",
    overview: data.routes?.[0]?.overview_polyline?.points as string | undefined,
  };
}

Because the shape matches Google, a migration is mostly swapping the URL and the auth header. The Google version reads data.routes[0].legs[0].distance.value exactly the same way—you point the same parser at https://api.gogoduk.com/v1/directions with an X-API-Key header instead of a key= query parameter, which is the same swap described in migrating from Google Maps to a Vietnam map API.

With distanceMeters and durationSeconds in hand, pricing is a pure function: a base fee plus a per-kilometer rate, and an ETA you can show the customer in durationText.


Drawing the route: decode the polyline

overview_polyline.points is an encoded polyline at precision 5—the exact format Google uses—so any standard decoder works. Decode it to an array of [lat, lng] and draw it as a line on MapLibre, Leaflet, or Mapbox.

import polyline from "@mapbox/polyline"; // or @googlemaps/polyline-codec

// `overview` is routes[0].overview_polyline.points
function toGeoJsonLine(overview: string) {
  const coords = polyline
    .decode(overview, 5) // precision 5
    .map(([lat, lng]) => [lng, lat]); // GeoJSON is [lng, lat]

  return {
    type: "Feature" as const,
    geometry: { type: "LineString" as const, coordinates: coords },
    properties: {},
  };
}

// MapLibre GL example
map.addSource("route", { type: "geojson", data: toGeoJsonLine(overview) });
map.addLayer({
  id: "route",
  type: "line",
  source: "route",
  paint: { "line-color": "#2563eb", "line-width": 5 },
});

The only gotcha is precision: decode with 5, and remember decoders return [lat, lng] while GeoJSON wants [lng, lat]. Use the route's bounds to fit the viewport so the whole line is visible on first paint.


Turn-by-turn from the steps array

For navigation UIs, walk legs[].steps[]. Each step carries a human-readable html_instructions, an optional Google-style maneuver code (turn-left, turn-right, roundabout-right, …), its own distance, and a per-step polyline you can highlight as the rider progresses.

type Step = {
  html_instructions: string;
  maneuver?: string;
  distance: { text: string; value: number };
  polyline: { points: string };
};

function toDirectionsList(legs: { steps: Step[] }[]) {
  return legs.flatMap((leg) =>
    leg.steps.map((step) => ({
      instruction: step.html_instructions, // e.g. "Head northwest on Pasteur"
      maneuver: step.maneuver ?? "straight", // map to your own icon set
      distanceText: step.distance.text, // "243 m"
    })),
  );
}

Map each maneuver value to an icon in your design system, and render the list under the map. Because the codes match Google's, an existing icon mapping built for Google Directions can be reused as-is.


Always have a line: handling ESTIMATED

When there is no real road route between the two points—an island, a disconnected piece of the network, or a bad coordinate—the API does not error out. Instead it returns a straight-line estimate so your UI always has something to draw:

  • status becomes "ESTIMATED".
  • distance is the great-circle distance multiplied by a road-detour factor; duration is estimated from an average speed.
  • The leg has a single step from origin to destination.

Branch on status so an estimate is never mistaken for a real route:

const route = await getRoute(origin, destination);

if (route.status === "ESTIMATED") {
  // Draw a dashed line and label it as an estimate
  map.setPaintProperty("route", "line-dasharray", [2, 2]);
  showBadge("Estimated — no road route found");
} else {
  map.setPaintProperty("route", "line-dasharray", [1]); // solid
}

A simple rule of thumb: render ESTIMATED routes with a dashed line and a label, and consider blocking checkout (or flagging for review) when a delivery quote relies on an estimate rather than a real route.


Putting it together: a delivery ETA flow

Here is how the pieces line up for a typical "quote a delivery" flow:

  1. Capture both addresses. Use GET /v1/suggest for autocomplete and resolve the chosen result to coordinates. The UX details—debounce, keyboard navigation, conversion—are covered in Vietnam address autocomplete for checkout UX.
  2. Request the route. Call getRoute(pickup, dropoff) with vehicle=motobike.
  3. Price and promise. Compute the fee from distanceMeters (base + per-km) and show the ETA from durationText.
  4. Draw it. Decode overview_polyline onto the map and render turn-by-turn from steps.
  5. Guard the edges. If status === "ESTIMATED", mark the quote as approximate.

If your pricing also depends on which district or ward the drop-off falls in, pair this with reverse geocoding and boundary lookups—see working with Vietnam administrative boundaries for zone-based logic on top of the route.

In short: real motorbike routing replaces a guess with a number you can charge against and an ETA you can keep. The response is Google-compatible, so most integrations are a URL-and-header swap plus a status check for the fallback. Create a key with the routing:read scope in the dashboard (the free tier is 100 requests/day, no card required), explore the endpoints in the GoGoDuk docs, and try a live route on the interactive Vietnam map.

Facing performance issues or scaling challenges?

I specialize in building low-latency map infrastructure, real-time streaming pipelines (Kafka, ClickHouse), and highly optimized backend systems. Let's work together to scale your product.

Let's Work Together

Written by

Nguyen Hoang Tuan

Nguyen Hoang Tuan

Full-stack developer focused on practical backend architecture, web performance, and production delivery.

Related Articles