Chuyển đến nội dung chính
Mục lục tài liệu

Tối ưu tuyến đường

Tạo job tối ưu, theo dõi trạng thái và lấy kết quả. Đây là nhóm trung tâm của Route4Green.

Mở trong ChatGPTMở trong Claude

Tích hợp tối ưu tuyến đường

Phần này giải thích cách gọi nhóm endpoint tối ưu tuyến. Danh sách endpoint đầy đủ nằm ngay bên dưới; ở đây là những điều bạn cần biết trước khi gọi lần đầu.

Sơ đồ một tuyến đường đã tối ưu qua bảy điểm dừng, đặt cạnh một thứ tự thủ công trước đó dùng đường nét đứt đi qua cùng bảy điểm dừng đó
Cả hai đường đều đi qua cùng bảy điểm dừng. Đường liền nét là thứ tự do tối ưu tuyến đường tính ra; đường nét đứt là thứ tự sắp xếp thủ công, tự cắt qua chính nó.

Gửi một job

Tối ưu tuyến chạy bất đồng bộ. Bạn gửi job, hệ thống nhận và xử lý, kết quả lấy về sau bằng cách hỏi trạng thái hoặc nhận webhook.

Trả về 202, không phải 200

Gửi thành công trả về 202 Accepted. Nghĩa là job đã được ghi nhận, chưa phải đã xong. Phản hồi mang mã job để bạn tra cứu, không mang kết quả tối ưu.

Idempotency-Key là bắt buộc

Mỗi request gửi job phải kèm header Idempotency-Key. Khóa này có hiệu lực riêng trong từng client, dài tối đa 128 ký tự và được hệ thống lưu vô thời hạn, nên giá trị đã dùng không thể dùng lại cho job khác.

Vai trò của Idempotency-Key

Khi request bị timeout, bạn không biết máy chủ đã nhận job hay chưa. Nếu gửi lại mà không có khóa này, rất dễ tạo ra job thứ hai cho cùng một chuyến. Có Idempotency-Key, việc gửi lại luôn an toàn.

Khi gửi lại với cùng một khóa

Cùng khóa và nội dung giống hệt lần trước: hệ thống trả về chính job đã tạo, không tạo thêm và không báo lỗi. Nhờ vậy, gửi lại sau timeout là an toàn.

Cùng khóa nhưng nội dung khác đi: hệ thống từ chối với 409 và mã IDEMPOTENCY_CONFLICT. Gặp mã này gần như chắc chắn là bạn đã dùng lại khóa cũ cho một job mới.

Ba quy tắc
  1. Sinh khóa trước lần gửi đầu tiên, không sinh lại ở mỗi lần thử.
  2. Mọi lần gửi lại của cùng một job dùng đúng giá trị đó, và nội dung gửi lên phải giống hệt lần đầu.
  3. Một job thật sự mới thì phải là một giá trị mới.

external_reference

Nội dung gửi lên nhận trường external_reference, là mã định danh do bạn đặt cho job. Trường này khác Idempotency-Key: khóa bảo vệ một lần gửi cụ thể, còn external_reference gắn job với dữ liệu bên bạn. Mã định danh của từng đơn hàng và từng xe trong nội dung gửi lên là loại mã thứ ba; hai mã lỗi liên quan tới chúng nằm ở phần Mã lỗi bên dưới.

replaces_job_id

Nội dung gửi lên còn nhận trường tùy chọn replaces_job_id: mã của một job do chính bạn tạo và vẫn đang ở trạng thái QUEUED. Gửi kèm trường này sẽ thay thế job đó trong cùng một thao tác, job cũ chuyển sang CANCELLED, và đây là cách duy nhất để một job thành CANCELLED. Không có endpoint hủy job riêng. Việc thay thế bị chặn ngay khi hệ thống đã nhận job đó để xử lý, và điều này có thể xảy ra trong lúc job vẫn báo QUEUED; từ thời điểm đó, request bị từ chối với 409 và mã JOB_ALREADY_PROCESSING.

Gửi một job. Phần thân request tùy theo bài toán của bạn.
// The minimal valid body: one vehicle, one order, every required field
// filled in. See the POST /jobs reference for the full field list.
const body = {
  external_reference: "your-own-id",
  optimization_options: {
    delivery_mode: "1",
    kind_of_plan: "1",
    tour_mode: "1;0;0;0;0;0",
  },
  vehicles: [
    {
      external_vehicle_id: "veh-01",
      resource_id: 1,
      from_address: "118 Tay Son, Dong Da, Ha Noi",
      from_lat: 21.0122,
      from_lon: 105.8252,
      from_working_time: "2026-09-04T07:00:00+07:00",
      to_working_time: "2026-09-04T18:00:00+07:00",
      handling_unit_ids: [1],
      height_of_vehicle: 180,
      length_of_vehicle: 420,
      width_of_vehicle: 190,
      vehicle_tonnage: 1500,
    },
  ],
  orders: [
    {
      external_order_id: "ord-01",
      transport_mode: "lastmile",
      handling_unit_id: 1,
      service_id: 1,
      pickup_address: "118 Tay Son, Dong Da, Ha Noi",
      pickup_lat: 21.0122,
      pickup_lon: 105.8252,
      pickup_date_time: "2026-09-04T08:00:00+07:00",
      pickup_date_time_to: "2026-09-04T09:00:00+07:00",
      drop_address: "72 Nguyen Trai, Thanh Xuan, Ha Noi",
      drop_lat: 20.9971,
      drop_lon: 105.8032,
      drop_date_time: "2026-09-04T09:30:00+07:00",
      drop_date_time_to: "2026-09-04T11:00:00+07:00",
      rlength: 40,
      rwidth: 30,
      rheight: 30,
      rweight: 5,
      count_of_parcels: 1,
    },
  ],
};

const idempotencyKey = crypto.randomUUID();

async function submit(body: unknown) {
  const response = await fetch(
    "https://<your-host>/api/v1/partners/addons/route-optimization/jobs",
    {
      method: "POST",
      headers: {
        "api-key": process.env.ROUTE4GREEN_API_KEY!,
        "Idempotency-Key": idempotencyKey,
        "content-type": "application/json",
      },
      body: JSON.stringify(body),
    },
  );

  // 202 Accepted. The job is recorded, not finished.
  const envelope = await response.json();
  if (!envelope.success) {
    throw new Error(`${envelope.error_code}: ${envelope.message}`);
  }
  return envelope.data;
}

Vòng đời của job

Job đi qua sáu trạng thái. Tên trạng thái dưới đây là chuỗi nguyên văn hệ thống trả về: hãy so khớp chính xác, không dịch và không đổi chữ hoa, chữ thường.

Trạng tháiKết thúcÝ nghĩaViệc cần làm
QUEUEDKhôngJob đã được nhận, chưa bắt đầu xử lý.Chờ tiếp.
PROCESSINGKhôngĐang tính toán.Chờ tiếp.
SUCCEEDEDCóĐã tính xong toàn bộ.Lấy kết quả.
PARTIALCóĐã tính xong nhưng không phải mọi phần đều thành công.Lấy kết quả và kiểm tra phần nào chưa được xếp trước khi dùng.
FAILEDCóJob dừng vì lỗi.Đọc thông tin lỗi trước khi gửi lại, và nhớ dùng khóa mới nếu đây là một lần gửi mới.
CANCELLEDCóJob đã bị thay thế: một lần gửi sau đó từ cùng client đã nêu job này trong replaces_job_id.Không có gì để lấy. Hãy xem job đã thay thế nó.

Sáu chuỗi này đã được xác nhận trong mã nguồn backend. Hãy so khớp đúng chúng, và báo lại cho chúng tôi nếu bạn gặp trạng thái nào không có trong danh sách này.

Hỏi trạng thái

Nếu chưa dùng webhook, bạn hỏi trạng thái theo chu kỳ và dừng lại khi job vào một trong bốn trạng thái kết thúc. Luôn đặt giới hạn cho vòng lặp: thời gian xử lý tối đa hiện tại là 30 phút, nên một vòng lặp không giới hạn sẽ chạy mãi khi có sự cố.

Chu kỳ 5 giây trong ví dụ chỉ là một lựa chọn hợp lý, không phải con số hệ thống quy định. Cách nhận kết quả tốt hơn là webhook.
const TERMINAL = new Set([
  "SUCCEEDED",
  "PARTIAL",
  "FAILED",
  "CANCELLED",
]);

async function waitForJob(jobId: string, deadlineMs = 30 * 60 * 1000) {
  const startedAt = Date.now();

  while (Date.now() - startedAt < deadlineMs) {
    const response = await fetch(
      `https://<your-host>/api/v1/partners/addons/route-optimization/jobs/${jobId}`,
      { headers: { "api-key": process.env.ROUTE4GREEN_API_KEY! } },
    );
    const envelope = await response.json();
    if (!envelope.success) {
      throw new Error(String(envelope.error_code));
    }

    const status: string = envelope.data.status;
    if (TERMINAL.has(status)) return status;

    await new Promise((resolve) => setTimeout(resolve, 5_000));
  }

  throw new Error("Job did not reach a terminal state before the deadline");
}

Webhook

Thay vì hỏi liên tục, bạn đăng ký một địa chỉ để hệ thống gọi về khi job kết thúc. Đây là các endpoint webhook của riêng nhóm tối ưu tuyến, khác với nhóm webhook chung trong tài liệu này.

Đăng ký webhook cũng cần khóa API. Cách nhận khóa có ở phần xác thực trên trang tài liệu chính.

Đăng ký

Địa chỉ nhận phải là HTTPS, và hệ thống chỉ gọi về khi job đã vào trạng thái kết thúc, không gọi ở QUEUED hay PROCESSING. Phản hồi khi đăng ký thành công trả về khóa ký một lần duy nhất, hãy lưu lại ngay.

{ "url": "https://your-app.example.com/webhooks/route-optimization" }

Xem danh sách

Danh sách trả về không kèm khóa ký.

Bật, tắt hoặc đổi khóa ký

Một endpoint dùng cho cả ba việc. Gửi rotate_secret là true sẽ trả về khóa ký mới ngay trong phản hồi của chính request đó, chỉ một lần duy nhất, nên hãy lưu lại ngay.

MụcGiá trị
Giao thứcChỉ HTTPS
Thời điểm gọiKhi job vào trạng thái kết thúc
Header chữ kýX-Smartway-Signature: sha256=<hex>
Header dấu thời gianX-Smartway-Timestamp, tính bằng giây
Header mã sự kiệnX-Smartway-Event-Id
Header loại sự kiệnX-Smartway-Event
Sự kiệnroute_optimization.job.terminal.v1
Chuỗi được kýtimestamp.eventId.eventType.rawBody
Thời gian chờ5 giây
Số webhook1 cho mỗi client
Số lần gửiChỉ gửi một lần. Không tự động gửi lại: nếu gửi thất bại, hỏi trạng thái (polling) là phương án dự phòng, và có thể yêu cầu đội Route4Green gửi lại (replay).
Địa chỉ nội bộBị từ chối

Nội dung gửi về

Mỗi lần gửi mang bốn trường: event_id, event_type, occurred_at và data. event_type luôn là chuỗi nguyên văn ở trên; occurred_at là thời điểm job vào trạng thái kết thúc, không phải thời điểm request được gửi. data có một trong hai dạng, tùy theo trạng thái.

Hai dạng của data, theo trạng thái kết thúc

SUCCEEDED và PARTIAL: data chính là đối tượng kết quả mà GET .../result trả về, đủ mã job, summary, routes.

FAILED và CANCELLED: không có kết quả để mang, nên data là một bản ghi nhỏ gồm job_id, external_reference, status, một đối tượng error (là null trừ khi error_code có giá trị, đây là trường hợp bình thường với CANCELLED) và completed_at.

{ "event_id": "3c1c7b2a-9e77-4a55-9c9b-2f6a2b9c3d4e", "event_type": "route_optimization.job.terminal.v1", "occurred_at": "2026-09-04T10:02:11+07:00", "data": { "job_id": "b6f2b6b0-6b9e-4a3a-9b8b-6f6f6a2b9c3d", "external_reference": "order-batch-2026-09-04", "status": "SUCCEEDED", "summary": { "order_count": 5, "assigned_order_count": 5, "route_count": 2 }, "routes": ["... same shape as GET .../result"], "completed_at": "2026-09-04T10:02:11+07:00" } }
{ "event_id": "7a9e2b6b-4c3d-4b0a-8f6a-1c2b9c3d4e5f", "event_type": "route_optimization.job.terminal.v1", "occurred_at": "2026-09-04T10:05:03+07:00", "data": { "job_id": "c7a3c7c1-7cae-4b4b-ac9c-7a7a7b3caf4e", "external_reference": "order-batch-2026-09-05", "status": "FAILED", "error": { "code": "ROUTE_OPT_PERSISTENCE_ERROR", "message": "..." }, "completed_at": "2026-09-04T10:05:03+07:00" } }

Kiểm tra chữ ký

Chữ ký là HMAC-SHA256 dạng hex, tính trên bốn phần nối bằng dấu chấm theo đúng thứ tự này: dấu thời gian, mã sự kiện, loại sự kiện, rồi phần thân thô. Bỏ tiền tố sha256= trước khi so sánh, và so sánh bằng hàm chống dò thời gian.

Phải tính trên đúng chuỗi byte thô của request. Nếu bạn parse JSON rồi tạo lại chuỗi, thứ tự khóa sẽ khác đi và mọi chữ ký đều sai. Trong Next.js nghĩa là đọc bằng request.text() chứ không phải request.json().

Hàm kiểm tra chữ ký đầy đủ, chạy được.
import { createHmac, timingSafeEqual } from "node:crypto";

const SECRET = process.env.ROUTE4GREEN_WEBHOOK_SECRET!;

/**
 * HMAC-SHA256 over the four delivery parts joined by dots, in this order.
 * Anything else, including a different separator, produces a digest that
 * never matches.
 */
function expectedSignature(
  timestamp: string,
  eventId: string,
  eventType: string,
  rawBody: string,
): string {
  const preimage = `${timestamp}.${eventId}.${eventType}.${rawBody}`;
  return createHmac("sha256", SECRET).update(preimage).digest("hex");
}

function safeEqualHex(a: string, b: string): boolean {
  const left = Buffer.from(a, "hex");
  const right = Buffer.from(b, "hex");
  // Compare lengths first: timingSafeEqual throws on a mismatch.
  return left.length === right.length && timingSafeEqual(left, right);
}

export async function POST(request: Request): Promise<Response> {
  // Raw bytes, not a parsed object. Re-serialising JSON reorders keys and
  // every signature then fails.
  const rawBody = await request.text();

  const timestamp = request.headers.get("x-smartway-timestamp") ?? "";
  const eventId = request.headers.get("x-smartway-event-id") ?? "";
  const eventType = request.headers.get("x-smartway-event") ?? "";
  const received = (request.headers.get("x-smartway-signature") ?? "").replace(/^sha256=/, "");

  if (!timestamp || !eventId || !eventType || !received) {
    return new Response("missing signature headers", { status: 400 });
  }

  if (!safeEqualHex(received, expectedSignature(timestamp, eventId, eventType, rawBody))) {
    return new Response("invalid signature", { status: 401 });
  }

  // Your own freshness window. No server-side replay window is documented,
  // so choose one and reject anything older.
  const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!Number.isFinite(ageSeconds) || ageSeconds > 300) {
    return new Response("stale delivery", { status: 401 });
  }

  // Deliveries can repeat, so key your handler on eventId and make it safe to
  // run twice. Acknowledge first: the delivery timeout is 5 seconds.
  void handleTerminalEvent(eventId, eventType, JSON.parse(rawBody));
  return new Response("ok");
}

Nhận và trả lời

Hệ thống chỉ gửi một lần. Trường hợp duy nhất bạn thấy cùng một sự kiện lần nữa là khi có yêu cầu gửi lại (replay) từ chúng tôi, lúc đó sự kiện được gửi lại y hệt, nên hãy dùng event id làm khóa xử lý và đảm bảo bộ xử lý chạy lại an toàn. Trả lời thật nhanh rồi xử lý sau, vì thời gian chờ phản hồi hiện là 5 giây.

Cấu trúc phản hồi

Mọi phản hồi đều có chung một lớp bọc gồm success, message, error_code và data. Điều quan trọng nhất: không thể chỉ dựa vào mã trạng thái HTTP. Có trường hợp trả về HTTP 200 nhưng success là false, nên luôn kiểm tra phần thân phản hồi, đừng chỉ kiểm tra mã HTTP.

Kiểm tra phần thân, không chỉ mã HTTP.
// error_code is a string on some responses and a number on others, so the
// only safe shape is a union. Never compare it numerically.
type Envelope<T> = {
  success: boolean;
  message: string;
  error_code: string | number;
  data: T;
};

const response = await fetch(url, options);
const envelope: Envelope<unknown> = await response.json();

// Branch on success first. Do NOT branch on response.ok alone, and do not
// branch on error_code alone: some failures arrive as HTTP 200 with success
// set to false, and a successful response can still carry error_code 0.
if (!envelope.success) {
  handleFailure(String(envelope.error_code), envelope.message);
  return;
}

use(envelope.data);

error_code có thể là chuỗi hoặc số tùy phản hồi: hệ thống thật đã trả về cả error_code là 0 lẫn error_code là một chuỗi. Hãy khai kiểu là string | number, đừng so sánh bằng phép so sánh số, và đừng rẽ nhánh chỉ dựa vào error_code. Rẽ nhánh theo success trước, rồi mới đọc mã.

Đây là lỗi ở phía hệ thống gốc chứ không phải quy ước. Chúng tôi ghi lại để bạn viết mã chịu được cả hai kiểu, và sẽ cập nhật khi phía hệ thống thống nhất lại.

Dạng có phân trang

GET /jobs (danh sách) cũng bọc phản hồi theo cách này, thêm đúng một thứ: một đối tượng meta nằm cạnh data, mô tả trang chứ không phải một job cụ thể. data ở đây là mảng các job.

GET /jobs (danh sách). meta nằm cạnh data, không nằm trong đó.
// GET /jobs (list) returns the same envelope, plus a sibling meta block.
// data is the array of jobs; meta describes the page, not an item in it.
type PaginatedEnvelope<T> = {
  success: boolean;
  message: string;
  error_code: string | number;
  data: T[];
  meta: {
    total_items: number;
    item_count: number;
    items_per_page: number;
    total_pages: number;
    current_page: number;
  };
};

const response = await fetch(`${BASE}/jobs?page=1&size=10`, { headers });
const envelope: PaginatedEnvelope<unknown> = await response.json();

if (!envelope.success) {
  handleFailure(String(envelope.error_code), envelope.message);
  return;
}

use(envelope.data, envelope.meta);
TrườngÝ nghĩa
total_itemsTổng số job khớp bộ lọc, tính trên mọi trang.
item_countSố job trả về trong trang này.
items_per_pageKích thước trang đã dùng, lấy từ tham số size.
total_pagesTổng số trang có với bộ lọc này.
current_pageSố thứ tự trang đang trả về, lấy từ tham số page.

Mã lỗi

Mã lỗi nằm trong trường error_code của phần thân phản hồi.

Mỗi mã dưới đây được ghi kèm mã trạng thái HTTP mà nó khai báo. Trên hệ thống thật, mọi mã trừ ROUTE_OPT_PAYLOAD_TOO_LARGE hiện đều trả về HTTP 200 kèm success là false, chứ không phải mã trạng thái đã khai báo. Hãy rẽ nhánh theo success và error_code trong phần thân phản hồi, đừng dựa vào mã trạng thái HTTP thực nhận được.

MãTrạng thái khai báoNguyên nhân và cách xử lý
ROUTE_OPT_API_KEY_REQUIRED401Request không mang khóa API. Thêm header api-key.
ROUTE_OPT_NOT_ENABLED403Khóa chưa có Quyền dùng API tối ưu tuyến, hoặc tính năng đang tắt ở phía máy chủ. Liên hệ chúng tôi, đây không phải thứ bạn tự bật được.
ROUTE_OPT_VALIDATION_ERROR400Nội dung gửi lên không hợp lệ. Đọc message để biết trường nào.
DUPLICATE_EXTERNAL_ID400Trong cùng một request có hai mục mang trùng mã định danh. Đây được khai báo là lỗi 400, phát hiện lúc kiểm tra nội dung gửi lên.
ROUTE_OPT_RATE_LIMITED429Vượt số lần gửi cho phép trong một phút. Chờ rồi thử lại, và dùng lại đúng Idempotency-Key cũ.
ROUTE_OPT_PAYLOAD_TOO_LARGE413Nội dung vượt dung lượng tối đa hiện tại. Chia nhỏ rồi gửi lại.
ROUTE_OPT_JOB_NOT_FOUND404Không có job nào ứng với mã này.
ROUTE_OPT_WEBHOOK_NOT_FOUND404Không có webhook nào ứng với mã này.
ROUTE_OPT_PERSISTENCE_ERROR500Lỗi phía hệ thống khi lưu dữ liệu. Liên hệ chúng tôi nếu lặp lại.

Hai mã về mã định danh, rất hay bị nhầm

Hai mã này nghe giống nhau nhưng nói về hai chuyện hoàn toàn khác. Đọc kỹ trước khi viết nhánh xử lý.

DUPLICATE_EXTERNAL_ID, khai báo là 400: nội dung bạn gửi tự mâu thuẫn. Hai mục trong cùng một request mang trùng mã định danh. Sửa ở phía bạn rồi gửi lại.

ACTIVE_EXTERNAL_ID_CONFLICT, khai báo là 409: mã định danh đó đang bận. Một đơn hàng hoặc một xe với mã đó đang gắn với một job khác còn hoạt động. Chờ job kia kết thúc, hoặc dùng mã khác.

Các mã xung đột

Các mã có trạng thái khai báo là 409. Mọi mã trong bảng đều đã được xác nhận trong mã nguồn add-on, nên rẽ nhánh theo mã là an toàn. Lưu ý ở trên vẫn áp dụng: trên hệ thống thật, các mã này được trả về dưới dạng HTTP 200 với success là false, nên hãy so khớp error_code, đừng dựa vào mã trạng thái.

MãEndpoint
IDEMPOTENCY_CONFLICTPOST /jobs
ACTIVE_EXTERNAL_ID_CONFLICTPOST /jobs
JOB_ALREADY_PROCESSINGPOST /jobs
RESULT_NOT_READYGET /jobs/{jobId}/result
RESULT_UNAVAILABLEGET /jobs/{jobId}/result
WEBHOOK_LIMIT_REACHEDPOST /webhooks

Giới hạn request

Các giới hạn dưới đây áp dụng cho khóa chính thức. Đây là giới hạn của hạ tầng, không phải mức tối đa cố định của sản phẩm.

Giới hạnKhóa chính thức
Dung lượng nội dung gửi lên5 MB
Số xe trong một job200
Số lần gửi job mỗi phút10 cho mỗi client
Số đơn trong một job300, một ngày và một chặng vận chuyển
Thời gian xử lý tối đa30 phút
Thời gian chờ webhook5 giây
Số webhook mỗi client1

Mỗi job là một lần tính cho cả đội xe, tối đa 300 đơn, cho một ngày và một chặng vận chuyển. Nếu khối lượng lớn hơn, hãy tách dữ liệu theo ngày hoặc theo chặng vận chuyển trước khi gửi.

Khóa thử nghiệm

Đây là các mức giới hạn đội Route4Green áp dụng cho khóa thử nghiệm, được kiểm soát bằng cách rà soát mức sử dụng chứ không bằng kiểm tra tự động trong hệ thống. Mức này đủ để xây dựng và kiểm thử trọn vẹn một tích hợp, nhưng cố ý không đủ cho vận hành thật.

Giới hạnKhóa thử nghiệm
Số lần gửi job mỗi phút5
Số xe trong một job10
Số điểm dừng trong một job50
Số job mỗi tháng200

Khóa thử nghiệm chỉ dùng được cho bài toán giao chặng cuối (last-mile). Đây là giới hạn về phạm vi dùng thử, không phải giới hạn kỹ thuật; các loại bài toán khác được mở khi bạn dùng khóa chính thức.

Cần mức cao hơn

Nếu khối lượng của bạn vượt các mức này, hãy cho chúng tôi biết. Nâng hạn mức là việc mở rộng hạ tầng và được xem xét theo từng trường hợp, vì vậy hãy mô tả đúng quy mô thực tế của bạn.

Trao đổi về hạn mức

Chúng tôi chưa cam kết thời gian phản hồi hay tỷ lệ sẵn sàng.

7 endpoint

Tối ưu tuyến đường | Route4Green