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

Kiểm tra chữ ký webhook

Xác minh chữ ký trước khi xử lý, tính trên phần thân thô.

Mở trong ChatGPTMở trong Claude

Chưa có bộ SDK đóng gói sẵn. Nội dung dưới đây dùng HTTP thuần.

Mỗi lần gọi về đều mang chữ ký. Hãy kiểm tra trước khi xử lý nội dung, so sánh theo cách chống dò thời gian, và luôn tính trên phần thân thô của request.

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");
}

Chuỗi được ký là timestamp, mã sự kiện, loại sự kiện và phần thân thô, nối bằng dấu chấm theo đúng thứ tự đó. Hàm kiểm tra đầy đủ nằm trong phần webhook của hướng dẫn tối ưu tuyến.

Kiểm tra chữ ký webhook | Route4Green