TonPing webhook reference

JSON format of the four TonPing webhook events, headers, HMAC-SHA256 signature check, dedupe by event_id and the retry schedule, with a PHP example.

When a device receives a message or its status changes, the server sends a POST to the URL set in the “Forwarding” tab of the app. The body is JSON and carries a signature so you can check that it really came from TonPing.

  • The URL must be https (no user:pass, and it must not point to an internal address)
  • Content-Type: application/json; charset=utf-8
  • User-Agent: TonPing-Webhook/1

Payload format

Every event type has these fields: event_id, type, created_at, device (id, name). All timestamps are RFC 3339 UTC. A field with no value is sent as null, never dropped.

message.received: a new message from the device

{
  "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": "Branch 1"},
  "message": {
    "id": "b7a3e2d1-5c40-4f8e-a1b2-3c4d5e6f7a8b",
    "source": "sms",
    "sender": "BANK-SMS",
    "title": null,
    "body": "The raw text, every character",
    "sim_slot": 0,
    "sent_at": "2026-11-07T06:04:58Z",
    "received_at": "2026-11-07T06:04:59.5Z",
    "recovered": false
  }
}
  • message.id is the message ID created on the device
  • source is sms or notification
  • title, sim_slot, sent_at and received_at can be null
  • recovered is true when the device recovered the message and sent it later

device.offline: the device stayed silent past the threshold

{
  "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": "Branch 1"},
  "since": "2026-11-07T06:05:00Z"
}

since is the last time the device was reached.

device.online: the device is back after being 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": "Branch 1"},
  "since": "2026-11-07T06:42:09Z"
}

since is the time the device came back.

webhook.test: the test button in the app

{
  "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": "Branch 1"}
}

There is no message and no since. A test is not queued and is not retried.

Unknown fields: later releases may add new fields. The receiver must ignore fields it does not know, and must not fail on an unknown type (answer 2xx and skip it).

Headers

Header Meaning
X-TonPing-Event-Id Equal to event_id in the body. Use it to avoid processing twice
X-TonPing-Timestamp Unix seconds when that attempt was sent (a retry gets a new value)
X-TonPing-Signature v1= followed by the lowercase hex HMAC-SHA256
Content-Type application/json; charset=utf-8
User-Agent TonPing-Webhook/1

Header names are case-insensitive. Compare them case-insensitively.

Verify the signature step by step

The secret is the device’s whsec_..., shown in the “Forwarding” tab of the app.

  1. Read the raw body exactly as received. Do not decode the JSON and re-encode it before computing, because the bytes would change (Thai text and emoji are UTF-8).
  2. Read X-TonPing-Timestamp. It must be digits only.
  3. Compute HMAC-SHA256(secret, timestamp + "." + raw body) as lowercase hex.
  4. Prefix it with v1= and compare it to X-TonPing-Signature with a constant-time function (hash_equals in PHP). If it does not match, answer 401.
  5. Check that timestamp is within 300 seconds (5 minutes) of the current time. If not, answer 401. This stops replay of old requests.
  6. Decode the JSON, then dedupe by event_id.

Test vector to check your own code: secret whsec_test, timestamp 1700000000, body {} gives

v1=35495024f4ef3f94e5a93e22221544c4b75e9a42300cd965ab81cb85cd994e91

The tonping_verify function in tonping.php (PHP 7.3 or later) does steps 1 to 5 and decodes the JSON. It returns the event as an array, or null if the signature or time is wrong. Deduplication (step 6) is in tonping_handle_once, in the same tonping.php file:

/**
 * 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;
}

Deduplication

Delivery is at-least-once. One event can arrive more than once, for example when your answer is slow and the server retries, or when the system owner re-sends it from the admin page. A re-send uses the same event_id.

The receiver must remember the event_id values it has processed. If it sees one again, answer 200 without processing it again (if you answer with an error, the server keeps retrying until 24 hours are up).

Trade-off in the PHP example

receive.php records the event_id only after handle_event finishes without throwing an exception. If it fails it answers 500 so TonPing retries, so no event is lost. But if two copies of the same request arrive at the same time, both may run the handler. That is within the at-least-once contract. A real system should record the event_id under a UNIQUE key in the same transaction as its own work, for example in MySQL:

START TRANSACTION;
INSERT INTO seen_events (event_id) VALUES (?);  -- event_id is UNIQUE, so a duplicate fails (or use INSERT ... ON DUPLICATE KEY and check the row count)
-- your work, then COMMIT; on a duplicate, ROLLBACK and answer 200

Responses and retries

  • Answer 2xx within 10 seconds and it counts as delivered. Accept first, then do heavy work afterwards.
  • Redirects (301/302/307) are not followed and count as a failure. Set the real destination URL directly.
  • Every kind of failure (cannot connect, timeout, 3xx, 4xx, 5xx) is retried after 10 seconds, then the wait doubles each time (10 seconds, 20, 40, 80 …) up to 1 hour per attempt.
  • When a job is 24 hours old and still has not succeeded, the server stops trying and the job becomes failed. The system owner can re-send it from the admin page.

Ordering

Messages from the same device are delivered in the order the server received them. The next job for that device waits until the previous one succeeds or becomes failed. So if your endpoint keeps rejecting one message, the following messages from that device can wait up to 24 hours. Different devices are not ordered against each other.

When your endpoint goes down and comes back, within the 24-hour retry window it receives everything, in order per device.

Rotate the secret

Rotate the secret from the app (“Forwarding” tab). The old secret stops working immediately. Requests sent after that are signed with the new secret. Change the value on the receiving side as soon as you rotate. Otherwise requests in the overlap get 401 and are retried on the schedule above, and they pass once the receiver uses the new secret.

Keep the secret in an environment variable or a config file outside the repo. Do not print it to a log.

Use the PHP example

The PHP example is three files. Nothing extra to install (PHP 7.3 or later, tested on 7.4 and 8.3). Download them into one folder:

What each file does:

  • tonping.php: tonping_verify($secret, $rawBody, $headers, time()) returns the event as an array, or null if the signature or time is wrong; plus tonping_is_seen / tonping_mark_seen / tonping_handle_once for deduplication
  • receive.php: a receiver that checks the signature, dedupes, then calls handle_event(array $event), which you write yourself. It answers with the codes in the table below
  • selftest.php: a self-test

The full receive.php receiver:

<?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');

Codes receive.php answers with:

Code When What TonPing does next
200 Processed successfully, or an event_id that was already recorded Counts as delivered
400 The signature is valid but the body has no event_id (a string) Retries on the schedule
401 The signature, timestamp or JSON is invalid Retries on the schedule
405 Not a POST (not a TonPing request)
500 TONPING_WEBHOOK_SECRET is not set / the dedupe file cannot be written / handle_event threw an exception Retries on the schedule

Set the secret TONPING_WEBHOOK_SECRET for PHP

receive.php reads the secret with getenv('TONPING_WEBHOOK_SECRET'). If it is not set, every request gets 500, so TonPing keeps retrying until the job is 24 hours old and becomes failed. Set it, then test with the test button in the app before going live.

PHP-FPM: put it in the pool file (for example /etc/php/8.3/fpm/pool.d/www.conf) and reload php-fpm. Note that clear_env = yes is the default, which wipes the system environment before it reaches PHP, so an export in the shell or systemd will not reach PHP. Use this env[...] line only:

env[TONPING_WEBHOOK_SECRET] = whsec_...

nginx (pass it as a FastCGI param in the location ~ \.php$ block):

fastcgi_param TONPING_WEBHOOK_SECRET "whsec_...";

Apache (mod_php, or mod_fcgid/proxy_fcgi, in the vhost file or .htaccess):

SetEnv TONPING_WEBHOOK_SECRET "whsec_..."

A config file that holds the secret must be readable only by root and the administrator. Do not put it in a repo.

Where event_id values are stored (deduplication)

receive.php keeps the processed event_id values in a file. The default is tonping-seen-ids next to the script. Change it with the variable TONPING_SEEN_STORE (set the same way as the secret above):

env[TONPING_SEEN_STORE] = /var/lib/tonping/seen-ids
  • The file must be outside the web root, or the web server must deny access to it. If it stays next to the script in the web root, deny it, for example Apache .htaccess:

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

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

  • The user that runs PHP must be able to write the file (and create it in that folder if it does not exist yet)

  • If the file cannot be opened or locked, receive.php writes a short error_log message and answers 500 (it never answers 200 without deduplication), and TonPing retries

Set it and run (in the folder where you saved the files):

export TONPING_WEBHOOK_SECRET='whsec_...'
php -S 0.0.0.0:8080 receive.php    # local testing; in production put it behind a web server with https

Test the example without a TonPing server (open a terminal in the folder that holds the three files):

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

You should see SELFTEST PASS.