CodeGym /Các khóa học /ChatGPT Apps /Webhooks và tích hợp bên ngoài: chữ ký, timeout, tính ide...

Webhooks và tích hợp bên ngoài: chữ ký, timeout, tính idempotent

ChatGPT Apps
Mức độ , Bài học
Có sẵn

1. Webhooks trong ChatGPT App: rốt cuộc ai gọi ai

Trong thế giới HTTP cổ điển mọi thứ rất đơn giản: bạn là client, bạn gọi POST tới /api/..., server trả lời, mọi người hạnh phúc. Với webhooks thì ngược lại: dịch vụ bên ngoài tự khởi tạo HTTP request đến backend của bạn khi có điều gì đó xảy ra ở phía họ.

Trong hệ sinh thái ChatGPT Apps điều này xuất hiện ở vài kịch bản điển hình. Ví dụ, GiftGenius sau khi tạo checkout qua ACP/Instant Checkout sẽ nhận từ nhà cung cấp thanh toán thông báo payment_succeeded qua webhook. Hoặc dịch vụ nền tạo ảnh preview cho quà tặng gửi cho bạn image_ready khi render xong. Trong các trường hợp này ChatGPT và máy chủ MCP của bạn đã làm xong, trái bóng ở phía dịch vụ thứ ba và họ báo kết quả cho bạn qua webhook.

Đặc điểm then chốt: sáng kiến đến từ ngoài hệ thống của bạn. Request có thể đến bất cứ lúc nào và nhiều lần. Vì thế, hãy coi handler webhook như điểm có thể dễ tổn thương nhất — cả internet sẽ "gõ cửa" ở đó.

Bảng nhỏ để so sánh:

Kiểu gọi Ai khởi tạo Ví dụ trong GiftGenius
Yêu cầu API thông thường Bạn Máy chủ MCP gọi Stripe API
Webhook Bên ngoài Stripe gửi payment_succeeded cho bạn

2. Sơ đồ đơn giản: ChatGPT ở đâu, MCP ở đâu, webhook ở đâu

Sơ đồ luồng trông như sau:

sequenceDiagram
    participant User as Người dùng trong ChatGPT
    participant GPT as ChatGPT + mô hình
    participant App as GiftGenius (MCP/App)
    participant PSP as Cổng thanh toán (Stripe/ACP)

    User->>GPT: "Tôi muốn mua quà"
    GPT->>App: callTool(create_checkout)
    App->>PSP: POST /checkout_sessions
    PSP-->>App: 200 OK + checkout_session_id
    App-->>GPT: ToolOutput (thông tin checkout)

    PSP-->>App: POST /webhooks/payment_succeeded
    App-->>PSP: 200 OK (đã nhận sự kiện)
    App->>DB: đánh dấu đơn hàng đã thanh toán

Phần đầu — các request đi ra bình thường mà bạn đã quen làm. Webhook — phần dưới của sơ đồ, nơi cổng thanh toán tự gọi đến bạn. Đó chính là phần chúng ta quan tâm hôm nay.

3. Trình xử lý webhook cơ bản trong Next.js (bộ khung)

Chúng ta tiếp tục phát triển GiftGenius học tập trên Next.js 16. Trong template có app/ cho UI và app/mcp/route.ts cho máy chủ MCP.

Handler webhook hợp lý nên đặt ở một HTTP route riêng, ví dụ: app/api/webhooks/commerce/route.ts.

Khung tối thiểu như sau:


// app/api/webhooks/commerce/route.ts
import { NextRequest } from "next/server";

export async function POST(req: NextRequest) {
  const rawBody = await req.text();          // 1. Đọc body dưới dạng chuỗi
  const headers = Object.fromEntries(req.headers); // 2. Lấy headers

  // 3. TODO: xác thực chữ ký (sẽ thêm sau)
  // 4. TODO: parse JSON và xử lý sự kiện

  return new Response("ok", { status: 200 }); // 5. Phản hồi nhanh 2xx
}

Ở đây đã ẩn vài ý quan trọng.

Thứ nhất, chúng ta đọc body dưới dạng text, chứ không dùng ngay await req.json(). Nhiều nhà cung cấp ký vào chính luồng byte “thô” của body, và nếu bạn parse (nhất là format lại) trước khi kiểm tra chữ ký, chữ ký sẽ không khớp nữa.

Thứ hai, chúng ta nghĩ ngay tới việc phản hồi nhanh 2xx. Công việc nặng nên đẩy sang worker riêng, hoặc ít nhất vào một hàm async sau khi đã log sự kiện. Điều này liên quan trực tiếp đến timeout và retry, mà ta sẽ nói kỹ ngay sau đây.

4. Chữ ký webhook: phân biệt “Stripe” với “người dùng curl” như thế nào

Nhớ lại TODO trong skeleton handler webhook — “xác thực chữ ký”. Hãy xem làm sao để phân biệt Stripe thật với “người dùng curl”.

Suy nghĩ ngây thơ nhất là cho rằng nếu URL khó (/api/webhooks/stripe/super-secret-abc123) thì chẳng ai tìm thấy. URL‑secret thực chất là security through obscurity: cố giấu sau một URL phức tạp, bảo vệ rất yếu. Tuyến phòng thủ đúng đắn là chữ ký mật mã.

Hầu hết các nhà cung cấp nghiêm túc (Stripe, ACP, nhiều CRM) tính chữ ký HMAC dựa trên body request và thời gian gửi, rồi đưa kết quả vào header. Bạn, với tư cách người nhận, làm điều tương tự và so sánh. Chỉ cần lệch chút là bạn loại request đó như giả mạo.

Công thức chung:

  1. Bạn có một webhook secret, lấy trong trang quản trị của nhà cung cấp và lưu vào secret của môi trường (ví dụ, STRIPE_WEBHOOK_SECRET trong Vercel env).
  2. Nhà cung cấp khi gửi request sẽ tính HMAC trên timestamp + '.' + rawBody.
  3. Trong header, ví dụ Stripe-Signature, họ ghi timestamp và một hoặc nhiều chữ ký.
  4. Ở handler, bạn lấy timestamp, tính HMAC theo cùng quy tắc và so sánh.

Ví dụ nhỏ bằng TypeScript dùng crypto:

import crypto from "crypto";

function computeSignature(secret: string, payload: string) {
  return crypto
    .createHmac("sha256", secret)  // chọn thuật toán
    .update(payload, "utf8")       // raw text của body
    .digest("hex");                // chuỗi hex
}

Ví dụ kiểm tra chữ ký và độ mới của sự kiện:

const sigHeader = headers["stripe-signature"];
if (!sigHeader) return new Response("missing signature", { status: 400 });

const [tsPart, sigPart] = sigHeader.split(",").map(s => s.trim());
const timestamp = Number(tsPart.split("=")[1]);
const theirSig = sigPart.split("=")[1];

const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > 5 * 60) {
  return new Response("timestamp too old", { status: 400 });
}

const payload = `${timestamp}.${rawBody}`;
const expectedSig = computeSignature(
  process.env.STRIPE_WEBHOOK_SECRET!,
  payload
);

if (!crypto.timingSafeEqual(
  Buffer.from(expectedSig, "hex"),
  Buffer.from(theirSig, "hex")
)) {
  return new Response("invalid signature", { status: 400 });
}

Lưu ý timingSafeEqual — đây là bảo vệ khỏi tấn công kênh thời gian, khi kẻ xấu cố đoán chữ ký dựa trên thời lượng so sánh.

Sau khi xác thực chữ ký thành công, bạn có thể yên tâm gọi JSON.parse(rawBody) hoặc await req.json(), vì biết chắc nó đến từ nhà cung cấp thật.

Các lớp phòng thủ bổ sung như IP allowlist (chỉ cho phép request từ địa chỉ của nhà cung cấp) và domain riêng cho webhooks đều hữu ích, nhưng chính chữ ký mật mã mới cho bạn sự bảo đảm về tính xác thực.

5. Timeout, phản hồi nhanh và xử lý bất đồng bộ

Webhooks “yêu” những ai trả lời nhanh. Phần lớn nền tảng thanh toán và commerce kỳ vọng endpoint của bạn trả về 2xx trong vài giây (thường dưới 10 giây, đôi khi ít hơn). Nếu bạn “suy nghĩ” quá lâu, họ coi là thất bại và bắt đầu gửi lại.

Cách làm “thẳng” sẽ như sau: bạn kiểm tra chữ ký, truy vấn DB, gọi thêm ba API bên ngoài, tính báo cáo, tạo PDF, rồi mới trả về 200 OK. Nếu một trong số đó hơi treo, cổng thanh toán sẽ cho rằng webhook bị lỗi và gửi lại. Kết quả là bạn tạo đơn hai lần, gửi email hai lần, gọi một GPT‑tool nào đó hai lần — và lao vào sửa hỗn loạn.

Mẫu đúng là “nhận, ghi, hoãn lại”:

  1. Kiểm tra chữ ký và các bất biến cơ bản (loại sự kiện, trường bắt buộc).
  2. Ghi nhanh sự kiện vào bảng/hàng đợi (tối thiểu thao tác DB).
  3. Trả về 2xx.
  4. Xử lý sự kiện ở nền, bằng worker riêng.

Ví dụ rút gọn của handler “nửa đúng” chưa có queue riêng nhưng có bước ghi nhanh:

export async function POST(req: NextRequest) {
  const rawBody = await req.text();
  const headers = Object.fromEntries(req.headers);

  if (!verifySignature(headers, rawBody)) {
    return new Response("invalid signature", { status: 400 });
  }

  const event = JSON.parse(rawBody);
  await saveWebhookEvent(event); // ghi nhanh vào DB

  // Ở đây có thể gửi tác vụ nền qua setImmediate/queue,
  // nhưng trong ví dụ học tập tạm thời chỉ ghi: gọi không await,
  // để phản hồi 200 đi ngay.
  processWebhookEventLater(event).catch(console.error);

  return new Response("ok", { status: 200 });
}

Lưu ý: chúng ta không gọi await processWebhookEventLater(...). Handler đặt tác vụ chạy nền và trả về 200 ngay, để không đụng phải timeout của webhook.

Trong production thực, ở chỗ này thường sẽ có một hàng đợi (ví dụ bảng webhook_jobs riêng hoặc dịch vụ bên ngoài), và các worker sẽ xử lý lần lượt sự kiện mà không chặn việc tiếp nhận cái mới.

6. Tính idempotent và khử trùng lặp: làm sao không trừ tiền hai lần

Trong các ví dụ học tập người ta hay vẽ mũi tên lý tưởng: một sự kiện → một lần xử lý → đơn hàng hạnh phúc. Thực tế webhooks đến theo đợt và nhiều lần liên tiếp.

Lý do rất đơn giản: mạng không ổn định, timeout xảy ra, và nhiều nhà cung cấp chủ ý gửi lại sự kiện cho tới khi nhận được 2xx chắc chắn. Với thanh toán thì điều này càng quan trọng: thà gửi lại payment_succeeded còn hơn là mất nó vĩnh viễn.

Vì vậy nghiệp vụ của bạn phải idempotent: xử lý lặp lại cùng một sự kiện không được làm thay đổi kết quả (hoặc ít nhất không làm hỏng hệ thống).

Mẫu điển hình:

  1. Sự kiện có một định danh bền vững, ví dụ event.id hoặc checkout_session_id.
  2. Bạn lưu nó trong bảng các sự kiện đã xử lý và áp đặt unique index trên trường này.
  3. Mỗi webhook đến, bạn kiểm tra trước: nếu đã có bản ghi với id đó và trạng thái “đã xử lý”, chỉ cần trả về 200 và không làm gì thêm.

Ví dụ nhỏ bằng pseudo‑ORM:

async function handlePaymentSucceeded(event: any) {
  const existing = await db.webhookEvents.findUnique({
    where: { providerId: event.id },
  });
  if (existing?.processedAt) {
    return; // đã xử lý rồi
  }

  await db.$transaction(async (tx) => {
    await tx.webhookEvents.upsert({
      where: { providerId: event.id },
      update: { processedAt: new Date() },
      create: {
        provider: "stripe",
        providerId: event.id,
        type: event.type,
        payload: event,
        processedAt: new Date(),
      },
    });

    await tx.orders.update({
      where: { checkoutSessionId: event.data.object.id },
      data: { status: "PAID" },
    });
  });
}

Điểm quan trọng là transaction: bạn đồng thời đánh dấu sự kiện là đã xử lý và cập nhật đơn hàng. Nếu mọi thứ hỏng giữa chừng, transaction sẽ rollback, và khi webhook được gửi lại, bạn sẽ thử lại mà không bị ghi trùng.

Một thực hành tốt nữa là bản thân thao tác cũng nên idempotent, ví dụ:

  • “đặt trạng thái đơn hàng thành PAID” thay vì “tăng số dư +100”;
  • “tạo bản ghi nếu chưa có” thay vì “thêm một dòng nữa”.

7. Xác thực dữ liệu webhook và PII: chữ ký không phải là bộ lọc duy nhất

Ngay cả khi webhook được ký và đến từ dịch vụ thật, hãy đối xử với dữ liệu của nó giống như input người dùng hoặc tham số của công cụ. Ở bài trước chúng ta đã nói rằng schema và normalization chính là firewall của bạn.

Schema cho sự kiện, chẳng hạn, có thể như sau (ở mức TypeScript/Zod):

import { z } from "zod";

const paymentSucceededSchema = z.object({
  id: z.string(),
  type: z.literal("payment_succeeded"),
  data: z.object({
    object: z.object({
      id: z.string(),            // checkout_session_id
      amount_total: z.number(),
      currency: z.string(),
      metadata: z.record(z.string(), z.string()).optional(),
    }),
  }),
});

Trong handler bạn xác thực như sau:

const event = JSON.parse(rawBody);
const parsed = paymentSucceededSchema.parse(event);
// sau đó chỉ làm việc với parsed

Như vậy bạn tự bảo vệ trước các bất ngờ dạng “nhà cung cấp đổi format”, “ở môi trường test trường trở thành nullable” v.v. Nếu có gì sai — ghi log lỗi và trả về 400, nhà cung cấp sẽ retry hoặc gửi cảnh báo.

Cũng cần nhớ về PII: body của webhooks thường chứa email, địa chỉ giao hàng, đôi khi cả mảnh dữ liệu thanh toán (ở dạng token). Việc mask chúng trong log và không gửi thẳng sang dịch vụ APM/log bên thứ ba là thực hành bắt buộc mà chúng ta đã nói ở chủ đề về secrets và dữ liệu nhạy cảm.

Và chắc chắn không nên gửi toàn bộ JSON webhook ngược lại ChatGPT như ToolOutput — mô hình không cần thấy tất cả những gì nhà cung cấp thanh toán gửi, nhất là khi không cần cho UX.

8. GiftGenius thực tế: webhook thanh toán cho ACP/Instant Checkout

Quay lại GiftGenius. Ở module về commerce và ACP chúng ta đã xem cách agent tạo checkout session và cách Instant Checkout thực hiện việc trừ tiền. Về phía backend, sau đó còn chờ webhook order.paid (hoặc checkout.session.completed theo cách gọi của Stripe), để:

  • ghi nhận trạng thái đơn hàng;
  • kích hoạt chuỗi “gửi email” / “chuẩn bị giao hàng”;
  • trả cho agent một câu trả lời chắc chắn “thanh toán đã hoàn tất”.

Ví dụ handler đơn giản trong Next.js:

// app/api/webhooks/commerce/route.ts
import { NextRequest } from "next/server";
import { handlePaymentSucceeded } from "@/lib/webhooks/commerce";

export async function POST(req: NextRequest) {
  const rawBody = await req.text();
  const headers = Object.fromEntries(req.headers);

  if (!verifyCommerceSignature(headers, rawBody)) {
    return new Response("invalid signature", { status: 400 });
  }

  const event = JSON.parse(rawBody);
  if (event.type === "payment_succeeded") {
    // Trình xử lý idempotent từ phần trước
    await handlePaymentSucceeded(event);
  }

  return new Response("ok", { status: 200 });
}

Hàm verifyCommerceSignature triển khai logic chữ ký HMAC tương tự như ta đã xem ở trên. Trong dự án thực tế, nên làm module riêng cho từng nhà cung cấp (verifyStripeSignature, verifyACPCheckoutSignature) để không trộn lẫn schema.

Bên trong handlePaymentSucceeded bạn:

  • xác thực đối tượng theo schema (Zod);
  • trong transaction, đánh dấu sự kiện là đã xử lý và cập nhật đơn hàng;
  • tùy chọn đưa tác vụ vào queue cho các việc “chậm”: email, analytics, các API call bổ sung.

Cách tiếp cận này giúp chuỗi “ACP → webhook → GiftGenius” bền vững trước sự kiện lặp, lỗi tạm thời và dữ liệu kỳ quặc.

9. Webhooks kết nối với MCP, ChatGPT và công cụ ở đâu

Thoạt nhìn có vẻ webhooks sống tách biệt khỏi ChatGPT App: một HTTP route nào đó trên backend, thế là xong. Thực ra đó là phần quan trọng của kiến trúc tổng thể.

Thường thì bộ ghép trông như sau:

  1. Công cụ MCP create_checkout được mô hình trong ChatGPT gọi.
  2. Máy chủ MCP gọi đến cổng thanh toán, tạo checkout session và trả về trong ToolOutput thông tin đơn hàng cùng trạng thái “chờ thanh toán”.
  3. Người dùng hoàn tất thanh toán trong UI (Instant Checkout làm điều này ngay trong ChatGPT).
  4. Cổng thanh toán gửi webhook đến backend của bạn.
  5. Backend qua DB thay đổi trạng thái đơn hàng; ở lần gọi công cụ tiếp theo hoặc follow‑up từ mô hình, có thể nói một cách chính xác: “Đơn đã được thanh toán, đây là chi tiết”.

Đôi khi backend có thể khởi tạo follow‑up gián tiếp — ví dụ, qua widget hoặc tích hợp Realtime, thứ sẽ tự gọi sendFollowUpMessage theo tín hiệu từ server. Nhưng ngay cả khi không có, fact thanh toán vẫn được lưu ở phía bạn, và ở lần gọi công cụ tiếp theo backend sẽ đọc trạng thái mới từ DB và trả về dữ liệu cập nhật cho mô hình để phản hồi.

Quan trọng là webhook — một điểm vào sống cùng cấp với máy chủ MCP và dùng cùng các dịch vụ (DB, queue, secrets). Logic bảo mật về cơ bản giống nhau: quyền tối thiểu, dữ liệu đầu vào được xác thực, logging cẩn thận.

10. Các lỗi điển hình khi làm việc với webhooks và tích hợp bên ngoài

Lỗi số 1: không kiểm tra chữ ký webhook.
Đôi khi lập trình viên chỉ dùng URL “bí mật” hoặc đơn giản là Bearer my-secret trong header. Nếu secret bị lộ ở đâu đó, ai cũng có thể gửi webhook cho bạn, tạo đơn hàng, đổi trạng thái thanh toán và làm đủ thứ. Cách đúng là chữ ký mật mã của body (HMAC) và kiểm tra timestamp. Điều này làm việc giả mạo khó hơn nhiều so với “đoán URL”.

Lỗi số 2: xử lý nặng bên trong request webhook.
Viết trong handler webhook “tạo đơn hàng, gọi hai API ngoài, tạo PDF, gọi mô hình GPT, gửi 5 email” — là cách chắc chắn để dính timeout và retry. Kết quả là chính bạn gây ra bản sao, rồi lại phải gỡ. Đáng tin cậy hơn là xác nhận nhanh việc nhận sự kiện (2xx), ghi vào DB hoặc queue và xử lý ở nền.

Lỗi số 3: nghiệp vụ không idempotent.
Thường thấy kiểu code “mỗi lần payment_succeeded thì tăng số dư thêm số tiền”. Nếu webhook đến hai lần, số dư sẽ gấp đôi. Trường hợp khác — tạo hai lần cùng một đơn hoặc gửi email hai lần cho người dùng. Tính idempotent đạt được qua định danh sự kiện bền vững, bảng sự kiện đã xử lý, transaction và các thao tác dạng “đặt trạng thái” thay vì “cộng thêm”.

Lỗi số 4: không có schema và xác thực dữ liệu webhook.
Ngay cả webhook đã ký cũng có thể không như bạn mong đợi: nhà cung cấp đổi format, bạn copy JSON từ tài liệu nhưng ở môi trường test trường lại tên khác, hoặc bạn đơn giản sai kiểu dữ liệu. Nếu xử lý JSON như vậy mà không có schema và kiểm tra, lỗi sẽ âm thầm làm hỏng đơn hoặc ném exception giữa chuỗi xử lý. Dùng Zod/JSON Schema ở đầu vào giúp chẩn đoán dễ hơn và loại bỏ rõ ràng các sự kiện không hợp lệ.

Lỗi số 5: log raw body webhook có PII.
Khi debug rất dễ đặt console.log(rawBody) rồi quên. Ở production điều này biến thành log đầy email, địa chỉ và PII khác, trôi sang các dịch vụ log bên thứ ba. Về quyền riêng tư và quy định (kiểu GDPR) đây là cú “bắn vào chân”. Tốt hơn là triển khai PII‑scrub ngay — mask các trường nhạy cảm và chỉ log những gì thực sự cần cho chẩn đoán.

Lỗi số 6: trộn webhook test và live.
Tình huống điển hình — cùng một endpoint nhận sự kiện từ cả môi trường test và live của nhà cung cấp. Kết quả là thanh toán test bất ngờ đổi trạng thái đơn hàng thật, hoặc ngược lại. Đáng tin cậy hơn là tách URL (ví dụ, /webhooks/commerce/test/webhooks/commerce/live) hoặc ít nhất lưu “chế độ” trong config và kiểm tra nó ở đầu vào.

Lỗi số 7: phụ thuộc hoàn toàn kịch bản ChatGPT vào webhook đồng bộ.
Đôi khi ta muốn sau khi gọi công cụ và tạo checkout session, mô hình biết ngay kết quả thanh toán. Nhưng theo định nghĩa, webhooks là bất đồng bộ và thanh toán có thể mất thời gian. Thiết kế kịch bản như thể mọi thứ diễn ra tức thì là ý tưởng tệ. Tốt hơn hãy thiết kế hội thoại và công cụ sao cho hoạt động đúng với các sự kiện trễ: lưu trạng thái đơn hàng, cho phép người dùng quay lại chat và nhận thông tin cập nhật sau.

Bình luận
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION