1. Home
  2. Learn
  3. Webhooks
  4. Webhook Verification คืออะไร และทำไมระบบ production ต้องมี
WebhooksCluster article

Webhook Verification คืออะไร และทำไมระบบ production ต้องมี

พื้นฐานการตรวจสอบ webhook แบบใช้งานจริง พร้อมตัวอย่าง Express/Node.js ที่ตรวจ signature, replay, duplicate และ idempotency

Webhook Verification คืออะไร และทำไมระบบ production ต้องมี

หลายระบบเริ่มจากการเปิด endpoint รับ webhook แล้วทำแค่ parse JSON -> update database -> ตอบ 200 ทันที วิธีนี้ดูง่ายและใช้ได้ในช่วงแรก แต่พอขึ้น production จริง ปัญหาจะเริ่มชัดขึ้นทันที ทั้ง request ปลอม, event ซ้ำ, replay ของ event เก่า และ business state ที่เปลี่ยนผิดเพราะเชื่อข้อมูลจากภายนอกเร็วเกินไป

Webhook จึงไม่ใช่แค่ endpoint รับข้อมูล แต่เป็น boundary ของความน่าเชื่อถือ ระหว่างระบบของเรากับระบบภายนอก ถ้าขอบเขตนี้หลวม ระบบด้านในก็หลวมตามไปด้วย

บทความนี้สรุปว่า webhook verification คืออะไร ทำไมระบบ production ต้องมี และควรออกแบบ flow อย่างไรให้ปลอดภัยพอสำหรับงานจริง

TL;DR

ถ้าจะจำให้สั้นที่สุด ให้จำประโยคนี้

รับ raw body -> ตรวจ signature -> กัน replay -> กันซ้ำ -> แล้วค่อยเปลี่ยน state

Webhook คืออะไร

Webhook คือกลไกที่ provider หรือระบบภายนอกยิง event เข้ามาหาเราเมื่อมีบางอย่างเกิดขึ้น เช่น

  • ชำระเงินสำเร็จ
  • order ถูกสร้าง
  • subscription ถูกต่ออายุ
  • มี message ใหม่
  • มีการเปลี่ยนสถานะเอกสารหรือ workflow

จุดสำคัญคือ request นี้ ไม่ได้เริ่มจากระบบของเรา เราจึงต้องถือว่ามันยังไม่น่าเชื่อถือจนกว่าจะตรวจสอบผ่าน

Webhook Verification คืออะไร

Webhook Verification คือกระบวนการตรวจสอบว่า request ที่ยิงเข้ามาเป็น request ที่เชื่อถือได้ก่อนจะให้ระบบประมวลผลจริง

โดยทั่วไปจะมีองค์ประกอบหลักดังนี้

  1. ตรวจว่า request มาจาก provider จริงหรือไม่
  2. ตรวจว่า payload ไม่ถูกแก้ไขระหว่างทาง
  3. ตรวจว่า request ไม่เก่าเกินไปจนเข้าข่าย replay
  4. ตรวจว่า event เดิมไม่ถูกประมวลผลซ้ำ
  5. ทำ business logic ให้ idempotent เพื่อให้ retry แล้วไม่ทำ state เพี้ยน

ถ้า verification ไม่ผ่าน ต้อง reject ทันที ไม่ควร parse ต่อ ไม่ควรเข้า business logic และไม่ควรแตะ state ธุรกิจ

ทำไมระบบ production ต้องมี

ถ้าไม่มี webhook verification ปัญหาที่เจอบ่อยมีประมาณนี้

  1. มีคนยิง request ปลอมแล้วทำให้ order ถูก mark ว่าจ่ายเงินแล้ว
  2. provider retry event เดิมหลายครั้งแล้วระบบ process ซ้ำ
  3. event เก่าถูก replay กลับมาทีหลังแล้วไปย้อน state
  4. payload ถูกเปลี่ยนระหว่างทาง แต่ระบบยังเชื่อเพราะดูแค่ shape ของ JSON
  5. audit ย้อนหลังไม่ได้ว่า event ไหนเคยถูกประมวลผลแล้วบ้าง

สรุปคือ webhook ไม่ใช่แค่ “endpoint รับข้อมูล” แต่คือ จุดคั่นระหว่างข้อมูลที่เพิ่งเข้ามา กับ ข้อมูลที่เชื่อถือได้พอจะเปลี่ยน state ภายในระบบ

ลำดับที่ถูกต้องของการรับ webhook

ลำดับที่ปลอดภัยควรเป็นแบบนี้

  1. รับ raw body
  2. ดึง signature และ header ที่เกี่ยวข้อง
  3. คำนวณและตรวจ signature
  4. ตรวจ timestamp หรือ tolerance window
  5. parse JSON หลัง verify ผ่านแล้ว
  6. validate schema ของ payload
  7. ตรวจ event id เพื่อกัน duplicate
  8. เข้า business logic แบบ idempotent
  9. บันทึกผลการประมวลผลและสถานะ event

นี่คือ flow ที่ช่วยแยก “ข้อมูลที่เพิ่งเข้ามา” ออกจาก “ข้อมูลที่เชื่อถือได้และพร้อมให้เปลี่ยน state”

ตัวอย่าง Express/Node.js แบบใช้งานจริง

ตัวอย่างนี้เขียนให้เห็น flow ที่ครบทั้งเรื่อง raw body, signature, replay protection, duplicate handling และ idempotency เบื้องต้น

const express = require("express");
const crypto = require("crypto");

const app = express();

const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || "change-me";
const ALLOWED_SKEW_SECONDS = 300;

/**
 * demo only:
 * ระบบจริงควรเก็บใน database หรือ persistent storage
 * เช่น Postgres, Redis, DynamoDB หรือ table สำหรับ webhook events
 */
const processedEvents = new Map();

/**
 * รับ raw body เพื่อให้ signature ตรงกับ payload ดิบ
 * อย่าใช้ express.json() กับ route นี้ก่อน verify
 */
app.post(
  "/webhooks/provider",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    try {
      const signature = req.get("x-signature");
      const timestamp = req.get("x-timestamp");

      if (!signature || !timestamp) {
        return res.status(400).send("Missing signature headers");
      }

      const rawBodyBuffer = req.body;
      const rawBody = rawBodyBuffer.toString("utf8");

      if (!verifySignature(rawBody, timestamp, signature, WEBHOOK_SECRET)) {
        return res.status(401).send("Invalid signature");
      }

      if (!isFreshTimestamp(timestamp, ALLOWED_SKEW_SECONDS)) {
        return res.status(401).send("Stale webhook timestamp");
      }

      let payload;
      try {
        payload = JSON.parse(rawBody);
      } catch (err) {
        return res.status(400).send("Invalid JSON payload");
      }

      const validationError = validateWebhookPayload(payload);
      if (validationError) {
        return res.status(400).send(validationError);
      }

      const eventId = payload.id;
      if (processedEvents.has(eventId)) {
        return res.status(200).send("Event already processed");
      }

      /**
       * กัน race condition แบบง่ายในตัวอย่าง
       * ระบบจริงควรใช้ unique constraint / transaction / distributed lock
       */
      processedEvents.set(eventId, {
        status: "processing",
        receivedAt: new Date().toISOString(),
      });

      await handleWebhookEvent(payload);

      processedEvents.set(eventId, {
        status: "processed",
        processedAt: new Date().toISOString(),
      });

      return res.status(200).send("Webhook processed");
    } catch (error) {
      console.error("Webhook error:", error);
      return res.status(500).send("Webhook processing failed");
    }
  }
);

function verifySignature(rawBody, timestamp, receivedSignature, secret) {
  const signedPayload = `${timestamp}.${rawBody}`;

  const expectedSignature = crypto
    .createHmac("sha256", secret)
    .update(signedPayload, "utf8")
    .digest("hex");

  const expected = Buffer.from(expectedSignature, "utf8");
  const received = Buffer.from(receivedSignature, "utf8");

  if (expected.length !== received.length) {
    return false;
  }

  return crypto.timingSafeEqual(expected, received);
}

function isFreshTimestamp(timestamp, toleranceSeconds) {
  const ts = Number(timestamp);
  if (!Number.isFinite(ts)) {
    return false;
  }

  const now = Math.floor(Date.now() / 1000);
  const age = Math.abs(now - ts);

  return age <= toleranceSeconds;
}

function validateWebhookPayload(payload) {
  if (!payload || typeof payload !== "object") {
    return "Payload must be an object";
  }

  if (!payload.id || typeof payload.id !== "string") {
    return "Missing or invalid event id";
  }

  if (!payload.type || typeof payload.type !== "string") {
    return "Missing or invalid event type";
  }

  if (!payload.data || typeof payload.data !== "object") {
    return "Missing or invalid event data";
  }

  return null;
}

async function handleWebhookEvent(payload) {
  switch (payload.type) {
    case "payment.succeeded":
      await markPaymentSucceeded(payload.data);
      break;

    case "payment.failed":
      await markPaymentFailed(payload.data);
      break;

    default:
      console.log("Unhandled event type:", payload.type);
      break;
  }
}

async function markPaymentSucceeded(data) {
  console.log("markPaymentSucceeded:", data);
  /**
   * ตัวอย่างระบบจริง:
   * - หา payment/order จาก provider reference
   * - เช็คว่าถูก mark success ไปแล้วหรือยัง
   * - อัปเดต state แบบ idempotent
   * - เขียน audit log
   */
}

async function markPaymentFailed(data) {
  console.log("markPaymentFailed:", data);
}

app.listen(3000, () => {
  console.log("Server listening on port 3000");
});

โค้ดชุดนี้กำลังป้องกันอะไรอยู่

โค้ดตัวอย่างด้านบนไม่ได้แค่ตรวจว่ามี header หรือไม่ แต่กำลังป้องกันความเสี่ยงหลายชั้นก่อนปล่อยให้ event ไปแตะ business state จริง

1) ป้องกัน request ปลอม

ระบบตรวจ signature ก่อนเสมอ เพื่อยืนยันว่า request นี้น่าจะมาจาก provider จริง และ payload ไม่ถูกแก้ไขระหว่างทาง

ถ้า signature ไม่ผ่าน ระบบจะ reject ทันที และไม่ควรไปต่อถึงขั้น parse หรือ update ข้อมูลภายใน

2) ป้องกัน replay attack

การมี timestamp และ tolerance window ช่วยกัน request เก่าที่ถูกดักหรือถูกนำกลับมายิงซ้ำภายหลัง แม้ request นั้นจะเคยเป็นของจริงมาก่อนก็ตาม

3) ป้องกัน duplicate processing

provider หลายรายมี retry policy ของตัวเอง ถ้าระบบเราตอบช้า timeout หรือเกิด error event เดิมอาจถูกส่งซ้ำได้ การตรวจ event id ช่วยให้ระบบไม่ประมวลผลซ้ำโดยไม่จำเป็น

4) ป้องกัน state เพี้ยนจาก business logic ที่ไม่ idempotent

แม้ event จะเป็นของจริง แต่ถ้า event เดิมเข้ามาซ้ำแล้วระบบยังสร้าง invoice ซ้ำ เพิ่มยอดซ้ำ หรือเปลี่ยนสถานะซ้ำ ระบบก็ยังพังได้อยู่ดี เพราะฉะนั้น logic ฝั่งธุรกิจต้องออกแบบให้รับ event เดิมซ้ำได้อย่างปลอดภัย

5) ป้องกันการเชื่อ payload เร็วเกินไป

ลำดับที่ถูกต้องคือรับ raw body ก่อน ตรวจ signature ให้ผ่านก่อน แล้วค่อย parse JSON และ validate payload ภายหลัง ถ้ากลับลำดับ โดยเฉพาะในกรณีที่ provider sign จาก raw body ค่าที่ตรวจอาจไม่ตรงทันที

ถ้าระบบตอบ 500 ใน route webhook หมายถึงอะไร

บรรทัดนี้สำคัญมาก

return res.status(500).send("Webhook processing failed");

ความหมายของมันคือ request มาถึงระบบแล้ว แต่มี failure ภายในระหว่างประมวลผล เช่น

  • parse พัง
  • database ล่ม
  • logic มี bug
  • transaction ทำไม่สำเร็จ
  • service ภายในตอบผิดพลาด

ในมุมของ provider การตอบ 500 มักหมายความว่า ปลายทางยังไม่ประมวลผลสำเร็จ และ provider หลายเจ้าจะ retry ให้อัตโนมัติ ดังนั้น route webhook ต้องถูกออกแบบให้รองรับ retry เสมอ และ logic ด้านในต้อง idempotent ไม่เช่นนั้น event เดิมจะย้อนกลับมาทำ state ซ้ำได้อีก

จุดที่ต้องระวังใน production

1) อย่าใช้ in-memory store สำหรับ dedup จริง

Map เหมาะกับการสอนแนวคิด แต่ไม่เหมาะกับ production เพราะ restart แล้วข้อมูลหาย และถ้ามีหลาย instance ข้อมูลก็ไม่แชร์กัน

ทางที่ถูกกว่าคือใช้ persistent store และมี unique constraint ที่ระดับฐานข้อมูล เช่น (provider, event_id)

2) อย่า parse ก่อน verify ถ้า provider ใช้ raw body signing

นี่เป็นจุดพลาดที่พบได้บ่อยมากใน Express เพราะพอ express.json() parse ไปแล้ว payload อาจไม่เหมือน raw body เดิมแบบ byte-for-byte ทำให้ signature ตรวจไม่ผ่าน

3) อย่าเชื่อแค่ event type

การเห็น payment.succeeded ไม่ได้แปลว่าควรอัปเดต order ทันที ต้องเช็คต่อว่า

  • provider reference ตรงกับ record ภายในไหม
  • amount ถูกไหม
  • currency ถูกไหม
  • account หรือ environment ถูกไหม
  • state ก่อนหน้าของ order อนุญาตให้เปลี่ยนไหม

4) อย่าทำ business logic แบบไม่ idempotent

ตัวอย่างของผลเสียจาก logic ที่ไม่ idempotent คือ

  • สร้าง invoice ซ้ำ
  • ส่งอีเมลซ้ำ
  • ปลดล็อกสิทธิ์ซ้ำ
  • เพิ่มยอดซ้ำ
  • เปลี่ยนสถานะไปมาแบบผิดลำดับ

5) แยก structural validation ออกจาก business validation

สองอย่างนี้ไม่เหมือนกัน

  • Structural validation: JSON ถูกไหม, field ครบไหม, type ถูกไหม
  • Business validation: event นี้สัมพันธ์กับ order ไหน, จำนวนเงินถูกไหม, state transition นี้อนุญาตหรือไม่

ถ้าแยกสองชั้นนี้ชัด ระบบจะ debug ง่ายขึ้นและ audit ชัดขึ้นมาก

แนวคิดเรื่อง idempotency ที่ควรมี

Webhook ที่ดีไม่ใช่แค่ “รับแล้วทำ” แต่ต้อง “รับซ้ำได้โดยไม่ทำให้ state เสีย”

ตัวอย่าง pseudo-flow ที่ควรคิดมีลักษณะประมาณนี้

if event_id already processed:
  return success

load related order

if order already in final expected state:
  mark event as safely acknowledged
  return success

apply state transition once
save audit log
mark event processed
return success

แก่นของเรื่องนี้คือระบบต้องมอง event ซ้ำเป็นเรื่องปกติ ไม่ใช่ exception แปลกประหลาด

รีวิวโค้ดชุดนี้แบบ production-minded

Correctness

ทิศทางโดยรวมถูกต้อง เพราะเริ่มจาก verify ก่อน parse, parse ก่อน business logic, แล้วค่อย deduplicate และประมวลผลจริง แต่ใน production ควรเพิ่ม schema validation ที่เข้มขึ้น เช่น Zod, Joi หรือ Ajv

Security

มีการใช้ HMAC และ timingSafeEqual ซึ่งถูกทิศทางดี มี replay protection จาก timestamp ด้วย แต่ควรเพิ่มการตรวจรูปแบบ header, secret rotation strategy, rate limiting และ IP allowlist ถ้า provider รองรับ

Efficiency

สำหรับ traffic ไม่มาก logic นี้เบาและเพียงพอ จุดที่ต้องปรับเมื่อโตขึ้นคือ storage สำหรับ dedup, async queue สำหรับงานหนัก และการแยก fast-ack ออกจาก heavy processing

Error handling

มี try/catch ครอบ route และตอบ 500 เมื่อพัง ซึ่งถูกต้อง แต่ควรมี structured logging ที่ระบุ event id, provider, event type, request id และ failure reason เพื่อให้ debug ง่ายขึ้น

เวอร์ชันที่ควรใช้ในระบบจริง

ถ้าจะใช้จริง ควรต่อยอดแบบนี้

  • ใช้ตาราง webhook_events
  • ทำ unique index ที่ provider + event_id
  • เก็บ raw payload, signature header, received_at, processed_at, status, error_message
  • ใช้ transaction ตอนเปลี่ยน business state
  • ถ้างานหนัก ให้รับ event แล้ว enqueue เข้า worker
  • ตอบ 2xx เมื่อบันทึก event เข้า durable storage ได้แล้ว
  • ให้ worker เป็นคนประมวลผลต่อแบบ retry-safe

ตัวอย่าง schema คร่าว ๆ

create table webhook_events (
  id bigserial primary key,
  provider text not null,
  event_id text not null,
  event_type text,
  status text not null default 'received',
  raw_payload jsonb not null,
  signature text,
  received_at timestamptz not null default now(),
  processed_at timestamptz,
  error_message text,
  unique (provider, event_id)
);

Checklist สั้น ๆ ก่อนปล่อย webhook ขึ้น production

  • รับ raw body ก่อน parse
  • ตรวจ signature ก่อนทุกอย่าง
  • ตรวจ timestamp หรือ replay window
  • validate payload หลัง verify ผ่าน
  • deduplicate ด้วย event id
  • ออกแบบ business logic ให้ idempotent
  • เก็บ audit log และสถานะการประมวลผล
  • ใช้ persistent storage แทน in-memory
  • รองรับ retry จาก provider
  • แยก fast acknowledgement ออกจากงานหนัก

สรุป

Webhook ไม่ควรถูกออกแบบให้รับข้อมูลแล้วเชื่อทันที แต่ควรถูกออกแบบให้ตรวจสอบก่อนเสมอ เพราะเป้าหมายไม่ใช่แค่กัน request ปลอม แต่คือป้องกันไม่ให้ business state ภายในระบบถูกเปลี่ยนจากข้อมูลที่ยังไม่น่าเชื่อถือ

สรุปสั้นที่สุดอีกครั้ง

รับดิบ ตรวจให้ผ่าน กัน replay กันซ้ำ แล้วค่อยเปลี่ยน state

💬 Chat (ตอบเร็ว)