Webhook ของ TonPing: รูปแบบ JSON และตรวจลายเซ็น HMAC

รูปแบบ JSON ของ webhook จาก TonPing ทั้ง 4 ประเภท header วิธีตรวจลายเซ็น HMAC-SHA256 การกันซ้ำด้วย event_id และตารางลองใหม่ พร้อมตัวอย่าง PHP

เมื่อเครื่องมีข้อความเข้าหรือสถานะเปลี่ยน เซิร์ฟเวอร์จะส่ง POST ไปยัง URL ที่ตั้งไว้ในแท็บ “การส่งต่อ” ของแอพ เนื้อหาเป็น JSON และมีลายเซ็นให้ตรวจว่ามาจาก TonPing จริง

  • URL ต้องเป็น https (ไม่มี user:pass และไม่ชี้ไปที่อยู่ภายใน)
  • Content-Type: application/json; charset=utf-8
  • User-Agent: TonPing-Webhook/1

รูปแบบข้อมูล

ทุกประเภทมีช่องเหล่านี้: event_id, type, created_at, device (id, name) เวลาทุกช่องเป็น RFC 3339 UTC ช่องที่ไม่มีค่าส่งเป็น null ไม่ตัดทิ้ง

message.received ข้อความใหม่จากเครื่อง

{
  "event_id": "4b1c6f0e-3a52-4d0b-9a77-0e2f5c1d8a11",
  "type": "message.received",
  "created_at": "2026-11-07T06:05:00.123Z",
  "device": {"id": "9d2e7c4a-61b8-4c35-8f0a-5b3e1a7c9d20", "name": "สาขา 1"},
  "message": {
    "id": "b7a3e2d1-5c40-4f8e-a1b2-3c4d5e6f7a8b",
    "source": "sms",
    "sender": "BANK-SMS",
    "title": null,
    "body": "เนื้อหาดิบทุกตัวอักษร",
    "sim_slot": 0,
    "sent_at": "2026-11-07T06:04:58Z",
    "received_at": "2026-11-07T06:04:59.5Z",
    "recovered": false
  }
}
  • message.id คือรหัสข้อความที่เครื่องสร้าง
  • source เป็น sms หรือ notification
  • title, sim_slot, sent_at, received_at อาจเป็น null
  • recovered เป็น true เมื่อเป็นข้อความที่เครื่องกู้กลับมาส่งภายหลัง

device.offline เครื่องเงียบเกินเกณฑ์

{
  "event_id": "0c8d1f52-7e3b-4a96-b4d0-6a2f9e1c3b57",
  "type": "device.offline",
  "created_at": "2026-11-07T06:20:01.004Z",
  "device": {"id": "9d2e7c4a-61b8-4c35-8f0a-5b3e1a7c9d20", "name": "สาขา 1"},
  "since": "2026-11-07T06:05:00Z"
}

since คือเวลาที่ติดต่อเครื่องได้ครั้งสุดท้าย

device.online เครื่องกลับมาหลัง offline

{
  "event_id": "e5f60a37-92c1-4b8d-8e14-d7a3b0c2f968",
  "type": "device.online",
  "created_at": "2026-11-07T06:42:10.500Z",
  "device": {"id": "9d2e7c4a-61b8-4c35-8f0a-5b3e1a7c9d20", "name": "สาขา 1"},
  "since": "2026-11-07T06:42:09Z"
}

since คือเวลาที่เครื่องกลับมา

webhook.test ปุ่มทดสอบในแอพ

{
  "event_id": "2a9b4c6d-8e01-4f73-b5a2-91c0d3e7f486",
  "type": "webhook.test",
  "created_at": "2026-11-07T06:50:00.250Z",
  "device": {"id": "9d2e7c4a-61b8-4c35-8f0a-5b3e1a7c9d20", "name": "สาขา 1"}
}

ไม่มี message และไม่มี since การทดสอบไม่ถูกเข้าคิวและไม่ลองใหม่

ช่องที่ไม่รู้จัก: รอบหลังอาจเพิ่มช่องใหม่ ผู้รับต้องไม่สนใจช่องที่ไม่รู้จัก และไม่ล้มเมื่อเจอ type ที่ไม่รู้จัก (ตอบ 2xx แล้วข้ามไป)

Header ความหมาย
X-TonPing-Event-Id เท่ากับ event_id ใน body ใช้กันประมวลผลซ้ำ
X-TonPing-Timestamp วินาที Unix ตอนยิงครั้งนั้น (ครั้งที่ลองใหม่ได้ค่าใหม่)
X-TonPing-Signature v1= ตามด้วย hex ตัวพิมพ์เล็กของ HMAC-SHA256
Content-Type application/json; charset=utf-8
User-Agent TonPing-Webhook/1

ชื่อ header ไม่สนตัวพิมพ์ ให้เทียบแบบไม่สนตัวพิมพ์

ตรวจลายเซ็นทีละขั้น

รหัสลับคือ whsec_... ของเครื่อง ดูได้ในแท็บ “การส่งต่อ” ของแอพ

  1. อ่าน body ดิบ ตามที่ได้รับ ห้ามถอดรหัส JSON แล้วเข้ารหัสใหม่ก่อนคำนวณ เพราะไบต์จะเปลี่ยน (ไทยและอีโมจิเป็น UTF-8)
  2. อ่าน X-TonPing-Timestamp ต้องเป็นตัวเลขล้วน
  3. คำนวณ HMAC-SHA256(รหัสลับ, timestamp + "." + body ดิบ) เป็น hex ตัวพิมพ์เล็ก
  4. ต่อหน้าด้วย v1= แล้วเทียบกับ X-TonPing-Signature ด้วยฟังก์ชันเทียบเวลาคงที่ (ใน PHP คือ hash_equals) ไม่ตรงให้ตอบ 401
  5. ตรวจว่า timestamp ห่างจากเวลาปัจจุบันไม่เกิน 300 วินาที (5 นาที) เกินให้ตอบ 401 กันการเล่นซ้ำคำขอเก่า
  6. ถอดรหัส JSON แล้วกันซ้ำด้วย event_id

ค่าทดสอบสำหรับเช็กโค้ดของคุณ: รหัสลับ whsec_test, timestamp 1700000000, body {} ได้

v1=35495024f4ef3f94e5a93e22221544c4b75e9a42300cd965ab81cb85cd994e91

ฟังก์ชัน tonping_verify ใน tonping.php (PHP 7.3 ขึ้นไป) ทำขั้นที่ 1 ถึง 5 แล้วถอดรหัส JSON คืนอาร์เรย์ของเหตุการณ์ หรือ null ถ้าลายเซ็นหรือเวลาผิด การกันซ้ำ (ขั้นที่ 6) อยู่ใน tonping_handle_once ในไฟล์ tonping.php เดียวกัน:

/**
 * Verify one TonPing webhook request.
 *
 * @param string $secret    The device webhook secret (whsec_...).
 * @param string $rawBody   The request body exactly as received (do not re-encode it).
 * @param array  $headers   Request headers, any name case. A value is a string, or an array of
 *                          strings (Laravel/Symfony headers->all(), PSR-7 getHeaders()); the first is used.
 * @param int    $now       Current Unix time in seconds (pass time()).
 * @param int    $tolerance Largest accepted gap between the timestamp and $now, in seconds.
 * @return array|null       The decoded JSON on success, null on any failure.
 */
function tonping_verify(string $secret, string $rawBody, array $headers, int $now, int $tolerance = 300): ?array
{
    $h = [];
    foreach ($headers as $name => $value) {
        if (is_array($value)) {
            $value = reset($value);
        }
        if (is_string($name) && is_string($value)) {
            $h[strtolower($name)] = $value;
        }
    }
    $ts = $h['x-tonping-timestamp'] ?? null;
    $sig = $h['x-tonping-signature'] ?? null;
    if ($ts === null || $sig === null || $secret === '') {
        return null;
    }
    if (!preg_match('/\A[0-9]{1,15}\z/', $ts)) {
        return null;
    }
    if (strncmp($sig, 'v1=', 3) !== 0) {
        return null;
    }
    $given = substr($sig, 3);
    $expected = hash_hmac('sha256', $ts . '.' . $rawBody, $secret);
    if (!hash_equals($expected, $given)) {
        return null;
    }
    if (abs($now - (int) $ts) > $tolerance) {
        return null;
    }
    try {
        $event = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
    } catch (JsonException $e) {
        return null;
    }
    return is_array($event) ? $event : null;
}

การกันซ้ำ

ส่งแบบ อย่างน้อยหนึ่งครั้ง (at-least-once) เหตุการณ์เดียวอาจมาถึงมากกว่าหนึ่งครั้ง เช่น ตอบช้าเกินจนเซิร์ฟเวอร์ลองใหม่ หรือเจ้าของระบบสั่งยิงซ้ำจากหน้าผู้ดูแล การยิงซ้ำใช้ event_id เดิม

ผู้รับต้องจำ event_id ที่ประมวลผลแล้ว ถ้าเห็นซ้ำให้ ตอบ 200 โดยไม่ประมวลผลอีก (ถ้าตอบ error เซิร์ฟเวอร์จะลองใหม่ไม่รู้จบจนครบ 24 ชั่วโมง)

ข้อแลกเปลี่ยนของตัวอย่าง PHP

receive.php จะบันทึก event_id ก็ต่อเมื่อ handle_event ทำงานเสร็จโดยไม่โยน exception ถ้าล้มจะตอบ 500 เพื่อให้ TonPing ลองใหม่ จึงไม่มีเหตุการณ์หาย แต่ถ้ามีคำขอเดียวกัน 2 ตัวมาพร้อมกัน ทั้งคู่อาจรัน handler ได้ ซึ่งอยู่ในข้อกำหนด at-least-once ระบบจริงควรบันทึก event_id ด้วยคีย์ UNIQUE ในทรานแซกชันเดียวกับงานของตัวเอง เช่นใน MySQL:

START TRANSACTION;
INSERT INTO seen_events (event_id) VALUES (?);  -- event_id เป็น UNIQUE ซ้ำจะล้ม (หรือใช้ INSERT ... ON DUPLICATE KEY แล้วดูจำนวนแถว)
-- งานของคุณ แล้ว COMMIT; ถ้าซ้ำให้ ROLLBACK และตอบ 200

การตอบและการลองใหม่

  • ตอบ 2xx ภายใน 10 วินาที ถือว่าสำเร็จ ควรรับไว้ก่อนแล้วค่อยประมวลผลงานหนัก
  • ไม่ตามการเปลี่ยนเส้นทาง (redirect) 301/302/307 ถือว่าล้มเหลว ให้ตั้ง URL ปลายทางจริงโดยตรง
  • ล้มเหลวทุกแบบ (เชื่อมต่อไม่ได้ หมดเวลา 3xx 4xx 5xx) จะลองใหม่ที่ 10 วินาที แล้วเพิ่มเป็นสองเท่าทุกครั้ง (10 วินาที, 20, 40, 80 …) จนสูงสุดครั้งละ 1 ชั่วโมง
  • เมื่องานมีอายุครบ 24 ชั่วโมง ยังไม่สำเร็จ เซิร์ฟเวอร์เลิกลองและงานเป็น failed เจ้าของระบบสั่งยิงซ้ำได้จากหน้าผู้ดูแล

ลำดับ

ข้อความของ เครื่องเดียวกัน ถูกยิงตามลำดับที่เซิร์ฟเวอร์รับ งานถัดไปของเครื่องนั้นจะรอจนงานก่อนหน้าสำเร็จหรือ failed ดังนั้นถ้าปลายทางปฏิเสธข้อความฉบับหนึ่งตลอด ข้อความถัดไปของเครื่องนั้นอาจรอได้นานสุด 24 ชั่วโมง ส่วนเครื่องต่างกันไม่เรียงลำดับข้ามกัน

เมื่อปลายทางปิดแล้วเปิดคืน จะได้รับครบตามลำดับของแต่ละเครื่อง

หมุนรหัสลับ

กดหมุนรหัสลับจากแอพ (แท็บ “การส่งต่อ”) รหัสเก่า หยุดใช้ได้ทันที คำขอที่ยิงหลังจากนั้นเซ็นด้วยรหัสใหม่ ให้เปลี่ยนค่าในฝั่งผู้รับทันทีที่หมุน มิฉะนั้นคำขอช่วงคาบเกี่ยวจะตอบ 401 แล้วถูกลองใหม่ตามตารางข้างต้น ซึ่งจะผ่านเมื่อผู้รับใช้รหัสใหม่แล้ว

เก็บรหัสลับในตัวแปรสภาพแวดล้อมหรือไฟล์ตั้งค่านอก repo อย่าพิมพ์ลง log

ใช้ตัวอย่าง PHP

ตัวอย่าง PHP มี 3 ไฟล์ ไม่ต้องติดตั้งอะไรเพิ่ม (PHP 7.3 ขึ้นไป ทดสอบกับ 7.4 และ 8.3) ดาวน์โหลดไปไว้ในโฟลเดอร์เดียวกัน:

แต่ละไฟล์ทำหน้าที่ดังนี้:

  • tonping.php: tonping_verify($secret, $rawBody, $headers, time()) คืนอาร์เรย์ของเหตุการณ์ หรือ null ถ้าลายเซ็นหรือเวลาผิด และ tonping_is_seen / tonping_mark_seen / tonping_handle_once สำหรับกันซ้ำ
  • receive.php: ตัวรับที่ตรวจลายเซ็น กันซ้ำ แล้วเรียก handle_event(array $event) ที่คุณเขียนเอง ตอบรหัสตามตารางด้านล่าง
  • selftest.php: ทดสอบตัวเอง

ตัวรับ receive.php ทั้งไฟล์:

<?php
// Example TonPing webhook receiver. Set TONPING_WEBHOOK_SECRET, then point the
// device's webhook URL (https) at this script.
declare(strict_types=1);

require __DIR__ . '/tonping.php';

/** Your own code goes here. Keep it quick: TonPing waits 10 seconds for the answer. Throw on failure: you answer 500 and TonPing retries. */
function handle_event(array $event): void
{
    // Log only the type and id; never the secret or the whole body.
    error_log('tonping event ' . ($event['type'] ?? '?') . ' ' . ($event['event_id'] ?? '?'));
}

function request_headers(): array
{
    if (function_exists('getallheaders')) {
        $all = getallheaders();
        if (is_array($all)) {
            return $all;
        }
    }
    $out = [];
    foreach ($_SERVER as $key => $value) {
        if (strncmp($key, 'HTTP_', 5) === 0 && is_string($value)) {
            $out[str_replace('_', '-', substr($key, 5))] = $value;
        }
    }
    return $out;
}

function respond(int $code, string $text): void
{
    http_response_code($code);
    header('Content-Type: text/plain; charset=utf-8');
    echo $text;
    exit;
}

if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
    header('Allow: POST');
    respond(405, 'method not allowed');
}

$secret = (string) getenv('TONPING_WEBHOOK_SECRET');
if ($secret === '') {
    error_log('TONPING_WEBHOOK_SECRET is not set');
    respond(500, 'server not configured');
}

$rawBody = (string) file_get_contents('php://input');
$event = tonping_verify($secret, $rawBody, request_headers(), time());
if ($event === null) {
    respond(401, 'invalid signature');
}

$eventId = $event['event_id'] ?? null;
if (!is_string($eventId) || $eventId === '') {
    respond(400, 'missing event_id');
}

// Record the id only after the handler succeeds; a failure answers 500 and TonPing retries.
try {
    $code = tonping_handle_once(tonping_store_path(), $event, 'handle_event');
} catch (Throwable $e) {
    // The dedupe store is unusable (path, permissions, disk). Never answer 200 without it.
    error_log('tonping dedupe store failed: ' . $e->getMessage());
    respond(500, 'store failure');
}
respond($code, $code === 200 ? 'ok' : 'handler failed');

รหัสที่ receive.php ตอบ:

รหัส เมื่อไร TonPing ทำอะไรต่อ
200 ประมวลผลสำเร็จ หรือเป็น event_id ที่เคยบันทึกแล้ว ถือว่าสำเร็จ
400 ลายเซ็นถูกแต่ไม่มี event_id (เป็น string) ใน body ลองใหม่ตามตาราง
401 ลายเซ็น timestamp หรือ JSON ไม่ถูกต้อง ลองใหม่ตามตาราง
405 ไม่ใช่ POST (ไม่ใช่คำขอของ TonPing)
500 ไม่ได้ตั้ง TONPING_WEBHOOK_SECRET / เขียนไฟล์กันซ้ำไม่ได้ / handle_event โยน exception ลองใหม่ตามตาราง

ตั้งรหัสลับ TONPING_WEBHOOK_SECRET ให้ PHP

receive.php อ่านรหัสลับด้วย getenv('TONPING_WEBHOOK_SECRET') ถ้าไม่ได้ตั้งจะตอบ 500 ทุกคำขอ TonPing จึงลองใหม่ซ้ำจนงานอายุครบ 24 ชั่วโมงแล้วเป็น failed ให้ตั้งแล้วทดสอบด้วยปุ่มทดสอบในแอพก่อนใช้งานจริง

PHP-FPM: ใส่ในไฟล์ pool (เช่น /etc/php/8.3/fpm/pool.d/www.conf) แล้ว reload php-fpm โปรดสังเกตว่า clear_env = yes เป็นค่าเริ่มต้น ซึ่งล้างตัวแปรสภาพแวดล้อมของระบบทิ้งก่อนเข้า PHP ดังนั้น export ในเชลล์หรือ systemd จะ ไม่ถึง PHP ต้องใช้บรรทัด env[...] นี้เท่านั้น

env[TONPING_WEBHOOK_SECRET] = whsec_...

nginx (ส่งเป็น FastCGI param ในบล็อก location ~ \.php$):

fastcgi_param TONPING_WEBHOOK_SECRET "whsec_...";

Apache (mod_php หรือ mod_fcgid/proxy_fcgi ในไฟล์ vhost หรือ .htaccess):

SetEnv TONPING_WEBHOOK_SECRET "whsec_..."

ไฟล์ตั้งค่าที่มีรหัสลับต้องอ่านได้เฉพาะ root และผู้ดูแล ห้ามใส่ใน repo

ที่เก็บ event_id (กันซ้ำ)

receive.php เก็บ event_id ที่ประมวลผลแล้วไว้ในไฟล์ ค่าเริ่มต้นคือ tonping-seen-ids ข้างสคริปต์ เปลี่ยนได้ด้วยตัวแปร TONPING_SEEN_STORE (ตั้งด้วยวิธีเดียวกับรหัสลับข้างบน):

env[TONPING_SEEN_STORE] = /var/lib/tonping/seen-ids
  • ไฟล์ต้องอยู่ นอก web root หรือเว็บเซิร์ฟเวอร์ต้องปฏิเสธการเข้าถึง ถ้าเก็บไว้ข้างสคริปต์ใน web root ให้ปฏิเสธ เช่น Apache .htaccess:

    <Files "tonping-seen-ids">
        Require all denied
    </Files>

    nginx: location = /tonping-seen-ids { deny all; }

  • ผู้ใช้ที่รัน PHP ต้องเขียนไฟล์ได้ (และสร้างไฟล์ได้ในโฟลเดอร์นั้นเมื่อยังไม่มี)

  • ถ้าเปิดหรือล็อกไฟล์ไม่ได้ receive.php จะ error_log ข้อความสั้น ๆ แล้วตอบ 500 (ไม่ตอบ 200 โดยไม่มีการกันซ้ำ) และ TonPing ลองใหม่

ตั้งค่าและรัน (ในโฟลเดอร์ที่ดาวน์โหลดไฟล์ไว้):

export TONPING_WEBHOOK_SECRET='whsec_...'
php -S 0.0.0.0:8080 receive.php    # ทดสอบในเครื่อง ใช้งานจริงให้วางหลังเว็บเซิร์ฟเวอร์ที่เปิด https

ทดสอบตัวอย่างโดยไม่ต้องมีเซิร์ฟเวอร์ TonPing (เปิดเทอร์มินัลในโฟลเดอร์ที่วางทั้งสามไฟล์):

docker run --rm -v "$PWD:/app" -w /app php:8.3-cli php selftest.php

ต้องเห็น SELFTEST PASS