บทความทั้งหมด
ทดสอบ Postmark Webhook บนเครื่องผ่าน HTTPS
Postmarkemail webhookslocalhostwebhook security

ทดสอบ Postmark Webhook บนเครื่องผ่าน HTTPS

หากต้องการทดสอบ Postmark webhook บน localhost ให้เปิดตัวรับ webhook ในเครื่อง ใช้ npx portpreview PORT เปิดพอร์ตสู่ภายนอก แล้วลงทะเบียนเส้นทาง HTTPS ที่ได้กับ Postmark Message Stream ที่ถูกต้อง ป้องกัน endpoint ด้วย Basic Authentication หรือ custom header ที่เป็นความลับ ตรวจสอบ JSON บันทึกแบบ idempotent และตอบ HTTP 200 โดยเร็ว

Postmark ส่งอะไรมาใน webhook

Postmark ส่ง HTTP POST เมื่อเกิดเหตุการณ์อีเมล Outbound Message Stream แจ้ง delivery, bounce, open, click, spam complaint และ subscription change ส่วน Inbound Message Stream ส่งอีเมลขาเข้าที่แยกข้อมูลแล้ว ฟิลด์ต่างกันตามชนิด จึงควรแยกด้วย RecordType และตรวจ schema เฉพาะชนิด Delivery หมายถึงเซิร์ฟเวอร์ปลายทางรับข้อความ ไม่ได้ยืนยันว่าเข้า inbox ส่วน Bounce มี Type, TypeCode, Inactive และ CanActivate ภาพรวม webhook อย่างเป็นทางการ · เอกสาร bounce webhook

สร้างตัวรับ Express ขนาดเล็กในเครื่อง

ตัวอย่างใช้ Express พอร์ต 3000 ตรวจ Basic Auth ก่อนรับ JSON ตรวจ envelope ขั้นต่ำ และบันทึกคีย์ป้องกันข้อมูลซ้ำอย่างถาวรก่อนตอบรับ เปลี่ยน helper ฐานข้อมูลเป็น transaction หรือ durable queue ของระบบ จำกัดขนาด request และอย่าบันทึกอีเมลขาเข้าทั้งฉบับ เพราะอาจมีข้อมูลส่วนบุคคล ลิงก์เข้าสู่ระบบ ไฟล์แนบ หรือข้อความลับ

import crypto from 'node:crypto';
import express from 'express';

const app = express();
app.use('/webhooks/postmark', express.json({ limit: '2mb' }));

function safeEqual(actual, expected) {
  const a = Buffer.from(actual);
  const b = Buffer.from(expected);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

function authorized(req) {
  const value = req.get('authorization') ?? '';
  if (!value.startsWith('Basic ')) return false;
  const decoded = Buffer.from(value.slice(6), 'base64').toString('utf8');
  const separator = decoded.indexOf(':');
  if (separator < 0) return false;
  return safeEqual(decoded.slice(0, separator), process.env.POSTMARK_WEBHOOK_USER ?? '') &&
    safeEqual(decoded.slice(separator + 1), process.env.POSTMARK_WEBHOOK_PASSWORD ?? '');
}

app.post('/webhooks/postmark', async (req, res) => {
  if (!authorized(req)) return res.sendStatus(401);

  const event = req.body;
  if (typeof event?.RecordType !== 'string' ||
      typeof event?.MessageID !== 'string') {
    return res.status(400).json({ error: 'Invalid Postmark event' });
  }

  const deliveryKey = `${event.RecordType}:${event.MessageID}:${event.ID ?? ''}`;
  await saveWebhookOnce({ provider: 'postmark', deliveryKey, event });
  res.sendStatus(200);
});

app.listen(3000);

เปิด localhost ด้วย URL HTTPS สาธารณะ

เปิด receiver และตรวจพอร์ตในเครื่อง จากนั้นรัน npx portpreview 3000 ในอีก terminal แล้วต่อ route เช่น https://YOUR-TUNNEL.portpreview.dev/webhooks/postmark เข้ากับ origin ที่ได้ เปิด tunnel ไว้ระหว่างทดสอบ Tunnel ทำให้เข้าถึงได้และมี HTTPS แต่ไม่ได้ยืนยันว่า request มาจาก Postmark จึงยังต้องตรวจ auth และ payload คู่มือความปลอดภัยของ localhost tunnel

ตั้งค่า Postmark webhook ให้ถูกตัว

สำหรับ outbound ให้เลือก Server และ Message Stream ที่ถูกต้อง เพิ่ม URL ใน Webhooks และเปิดเฉพาะ trigger ที่รองรับ สำหรับ inbound ให้ตั้ง inbound URL ของ Inbound Message Stream ส่วน Webhooks API รองรับ HttpAuth, HttpHeaders และ trigger โดย X-Postmark-Server-Token ใช้เรียก API จัดการ ไม่ใช่ credential ที่ Postmark ส่งให้ receiver Webhooks API

การยืนยันตัวตนของ Postmark ไม่ใช่ลายเซ็นเข้ารหัส

เอกสารปัจจุบันระบุว่า Postmark ไม่รองรับการตรวจลายเซ็น HMAC ของ webhook จึงไม่มี signing secret สำหรับคำนวณ digest ของ raw body และไม่มี X-Postmark-Signature Basic Authentication, IP allowlist และ secret header พิสูจน์เพียงการมี shared credential ไม่ได้ผูก credential กับ body ทางการเข้ารหัส ใช้ HTTPS ตรวจ payload และอัปเดตช่วง IP ตามเอกสารล่าสุด เลือก HttpAuth แทน credential ใน URL; หากใช้ https://username:[email protected]/path ต้องใช้ secret เฉพาะที่แข็งแรงและ encode ให้ถูก ห้ามใช้ Server API token เป็นรหัสผ่าน webhook

แยกจัดการเหตุการณ์ delivery และ bounce

ย้าย business logic ออกจากเส้นทาง HTTP ให้ worker ประมวลผลเหตุการณ์ที่บันทึกไว้อย่าง idempotent อย่าตัดสิน permanent suppression จากชื่อฟิลด์เพียงอย่างเดียว ให้ยึด bounce classification ปัจจุบันและนโยบายส่ง Spam complaint กับ subscription change เป็นคนละชนิดกับ Bounce และ open/click เกิดซ้ำได้

async function processPostmarkEvent(event) {
  switch (event.RecordType) {
    case 'Delivery':
      await markAcceptedByRecipientServer({
        messageId: event.MessageID,
        deliveredAt: event.DeliveredAt
      });
      break;
    case 'Bounce':
      await recordBounce({
        bounceId: String(event.ID),
        messageId: event.MessageID,
        type: event.Type,
        inactive: event.Inactive,
        canActivate: event.CanActivate
      });
      break;
    default:
      await recordUnhandledPostmarkType(event.RecordType);
  }
}

ออกแบบให้รองรับการ retry และข้อมูลซ้ำ

Postmark จะ retry หากไม่ได้ HTTP 200 ตารางของ bounce และ inbound ยาวกว่า click, open, delivery และ subscription change ส่วน 403 หยุด retry Timeout หลัง database commit อาจสร้างข้อมูลซ้ำที่ถูกต้อง ใช้ unique key จาก MessageID และสำหรับ endpoint หลายชนิดให้เพิ่ม RecordType กับรหัสเฉพาะ เช่น ID ตอบ 200 หลัง durable handoff ขั้นต่ำ แล้วค่อยทำงาน API ภายนอกแบบ async การ retry และ idempotency ของ webhook

ทดสอบเหตุการณ์จริงอย่างปลอดภัย

เริ่มด้วย synthetic POST ผ่าน curl เพื่อตรวจ route, auth, validation และ persistence จากนั้นส่งอีเมลปกติไปยังที่อยู่ที่ควบคุมได้เพื่อทดสอบ Delivery สำหรับ Bounce ให้ใช้เครื่องมือทดสอบที่ Postmark ระบุ รวมถึง black-hole test domain เมื่อใช้ได้ บันทึก MessageID และส่ง fixture ที่ลบข้อมูลสำคัญแล้วสองครั้งเพื่อยืนยันว่า side effect เกิดครั้งเดียว

แก้ปัญหา Postmark webhook ที่พบบ่อย

request มาไม่ถึง endpoint

ถ้า request ไม่ถึง ให้ตรวจ tunnel, route เต็ม และพอร์ต หากได้ 401 ให้เทียบ credential รีสตาร์ตแอป และตรวจ proxy ว่าไม่ลบ Authorization โดยห้าม log ค่า หากยัง retry หลังสำเร็จ ให้ดู status และ latency ที่ endpoint สาธารณะ เพราะต้องเป็น 200 หาก payload ต่างจากตัวอย่าง ให้ตรวจ RecordType, trigger และ stream ขาเข้าหรือขาออก

ทุก request ได้ HTTP 401

คู่มือ webhook 401/403

ยัง retry หลังประมวลผลแล้ว

ถ้า request ไม่ถึง ให้ตรวจ tunnel, route เต็ม และพอร์ต หากได้ 401 ให้เทียบ credential รีสตาร์ตแอป และตรวจ proxy ว่าไม่ลบ Authorization โดยห้าม log ค่า หากยัง retry หลังสำเร็จ ให้ดู status และ latency ที่ endpoint สาธารณะ เพราะต้องเป็น 200 หาก payload ต่างจากตัวอย่าง ให้ตรวจ RecordType, trigger และ stream ขาเข้าหรือขาออก

payload ไม่ตรงตัวอย่าง

ถ้า request ไม่ถึง ให้ตรวจ tunnel, route เต็ม และพอร์ต หากได้ 401 ให้เทียบ credential รีสตาร์ตแอป และตรวจ proxy ว่าไม่ลบ Authorization โดยห้าม log ค่า หากยัง retry หลังสำเร็จ ให้ดู status และ latency ที่ endpoint สาธารณะ เพราะต้องเป็น 200 หาก payload ต่างจากตัวอย่าง ให้ตรวจ RecordType, trigger และ stream ขาเข้าหรือขาออก

รายการตรวจสอบความปลอดภัยสำหรับ production

ใช้ HTTPS กับ Basic Auth หรือ secret header เฉพาะที่คาดเดายาก แยก API token ออกจาก webhook credential หมุน secret หลังทดสอบและลบ URL เก่า ตรวจ content type, ขนาด, event type, identifier และฟิลด์บังคับ ปกปิดข้อมูลอีเมลและ secret ใน log ใช้สิทธิ์ต่ำสุด และเฝ้าดู auth failure, lag, duplicate และ dead letter

คู่มือดีบัก webhook ในเครื่อง

คำถามที่พบบ่อย

จะทดสอบ Postmark webhook บน localhost อย่างไร
เปิด handler ในเครื่อง ใช้ npx portpreview PORT เปิดพอร์ต ต่อ route เข้ากับ HTTPS URL ที่ได้ แล้วตั้ง URL ใน Message Stream ที่ถูกต้อง
Postmark เซ็น webhook ด้วย HMAC หรือไม่
ไม่ เอกสารปัจจุบันไม่รองรับ HMAC signature ให้ใช้ HTTPS กับ Basic Authentication อาจเสริม IP allowlist ล่าสุด แล้วตรวจ payload ทุกครั้ง
ทำไม Postmark ส่ง webhook เดิมซ้ำ
Postmark retry เมื่อไม่ได้ HTTP 200 ดังนั้น timeout หรือ response ที่ล้มเหลวอาจทำให้ event ที่ประมวลผลแล้วถูกส่งซ้ำ ต้องใช้คีย์คงที่พร้อม unique constraint
delivery webhook หมายถึงผู้รับอ่านอีเมลแล้วหรือไม่
ไม่ หมายถึงเซิร์ฟเวอร์อีเมลปลายทางรับข้อความเท่านั้น ไม่ยืนยัน inbox, open หรือ read