For developers

API Reference

One endpoint sends to every channel you've connected — LINE, Telegram, Discord, Slack, Messenger, Instagram, WhatsApp — with the same request shape every time. Everything below is the real API, not a simplified preview.

Create a free accountOpen the consoleOpen Swagger UI

Every example on this page comes in 8 languages: curl · Node.js · PHP · Python · C# · Java · Go · Ruby — switch tabs on any code box.

1. Authenticate your requests

Every call is scoped to one app, identified by a token you generate yourself from that app's Settings tab — no OAuth flow, no separate developer account.

  1. 1

    Open your app's Settings tab

    Console → Applications → pick an app → Settings.

  2. 2

    Generate an API Token

    Shown once, at generation time — copy it somewhere safe. Generating again immediately revokes the old one.

  3. 3

    Send it as a header

    Include it as X-Ting-Api-Token on every request below. No "Bearer" prefix, nothing else to log in with.

The API Token card on an app's Settings tab in the Ting console

2. Try sending your first message

Copy the code below, swap in your real token and URL from the Settings tab, and run it — the message lands on whatever channel you've connected right away.

curl -X POST <TING_API_URL>/apps/v1/notify \
  -H "X-Ting-Api-Token: tapp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "message": "Your order #1042 has shipped!",
    "channels": ["LINE", "TELEGRAM"]
  }'

You'll get a response shaped like this back

JSON
{
  "success": true,
  "sendLogId": 8842
}

Sends are asynchronous: this call just queues the send and answers immediately (with a sendLogId) — it does not wait for delivery to finish before responding. The actual sending happens entirely in the background. To learn the outcome, ask GET /apps/v1/sends/{id} with that sendLogId (see "Check send status"), or listen for a "message.sent" event on your Ting Events Webhook in real time.

3. The full call: send → read the response → handle errors

This is the code to actually put in your system — a reusable notify() that checks the HTTP status, reads sendLogId on success and throws an error carrying the code (INVALID_TOKEN, MESSAGE_TOO_LONG, ...) on failure. No Ting SDK to install — only the HTTP client that ships with each language.

# Print the response body AND the HTTP status on the last line
curl -sS -X POST <TING_API_URL>/apps/v1/notify \
  -H "X-Ting-Api-Token: $TING_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"message": "Hello from curl", "channels": ["TING"]}' \
  -w "\nHTTP %{http_code}\n"

# 200 → {"success":true,"sendLogId":8842}
# 401 → {"error":"INVALID_TOKEN"}
# 400 → {"error":"MESSAGE_REQUIRED" | "MESSAGE_TOO_LONG" | "INVALID_MEDIA_TYPE" | "DUPLICATE_RECIPIENT" | "REPLY_SINGLE_CHANNEL_ONLY" | ...}  (full list in the error table below)

What each language needs

LanguageRequirements
curlPreinstalled on macOS/Linux/Windows 10+ — export TING_API_TOKEN before running.
Node.jsNode 18 or newer (fetch is built in), no packages needed.
PHPPHP 8.1 or newer with the curl extension (enabled on almost every host), no Composer needed.
PythonPython 3.9 or newer + pip install requests
C#.NET 6 or newer — System.Net.Http.Json is built in.
JavaJDK 11 or newer (java.net.http) + Jackson (com.fasterxml.jackson.core:jackson-databind) for JSON.
GoGo 1.18 or newer — standard library only.
RubyRuby 3 or newer — net/http and json are built in.

Every language calls the same endpoint, sends the same header (X-Ting-Api-Token) and gets the same JSON back — if yours isn't listed, just make an HTTPS POST with that header using any HTTP library. Always keep the token in a server-side environment variable, never in source code or a client-side app.

4. More examples

Building on the basic request above — each example below changes exactly one thing, to show what else you can do.

Send to a specific channel only

Set "channels" to target just one or a few, instead of broadcasting to everything enabled.

curl
curl -X POST <TING_API_URL>/apps/v1/notify \
  -H "X-Ting-Api-Token: tapp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "message": "New comment on your post",
    "channels": ["LINE"]
  }'

Attach an image

Upload the file first via /media (see "Attach images/video/files"), then pass the URL it returns as mediaUrl — sends as a real native image on every channel that supports one, not just a link.

curl
curl -X POST <TING_API_URL>/apps/v1/notify \
  -H "X-Ting-Api-Token: tapp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "message": "New arrival just dropped!",
    "mediaUrl": "<TING_API_URL>/media/123",
    "mediaType": "IMAGE",
    "channels": ["TELEGRAM"]
  }'

A product card with simple tags (every channel)

Put <img>, <h3>, <b> and <button> in the message — Ting turns it into a Flex Message on LINE, an image + inline buttons on Telegram, Block Kit on Slack, without you writing each platform separately (see "Message guide").

curl
curl -X POST <TING_API_URL>/apps/v1/notify \
  -H "X-Ting-Api-Token: tapp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "message": "<img src=\"https://cdn.example.com/tshirt.jpg\"><h3>White T-shirt</h3><p>350 THB</p><button href=\"https://shop.example.com/buy\">Buy now</button>",
    "channels": ["LINE", "TELEGRAM", "SLACK"]
  }'

Fully custom HTML/CSS (Ting Notifications)

Followers who receive through the Ting Notifications see real HTML/CSS like a web page — and you can set the card's height yourself with data-ting-height.

curl
curl -X POST <TING_API_URL>/apps/v1/notify \
  -H "X-Ting-Api-Token: tapp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "message": "<div data-ting-height=\"180\" style=\"padding:16px;border-radius:14px;background:linear-gradient(135deg,#4f46e5,#7c6cf0);color:#fff;font-family:sans-serif\"><strong style=\"font-size:16px\">🎉 Flash sale!</strong><p style=\"margin:8px 0 0\">20% off, today only</p></div>",
    "channels": ["TING"]
  }'

Target specific recipients

Set "recipients" per channel to reach only the people you name, instead of everyone on that channel — for TING you can pass the follower's "TNG#######" code (the same one a Command Ting payload carries) directly.

curl
curl -X POST <TING_API_URL>/apps/v1/notify \
  -H "X-Ting-Api-Token: tapp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "message": "Your VIP discount code is ready",
    "channels": ["LINE", "TING"],
    "recipients": {
      "LINE": ["U4af4980629..."],
      "TING": ["TNG3049182"]
    }
  }'

5. Full reference: send a notification

One endpoint, every channel. Omit "channels" to broadcast to everything enabled on the app; omit a channel's key in "recipients" to broadcast to everyone who has ever messaged it on that channel.

POST/apps/v1/notify

The one URL your backend calls, for every app

Headers

HeaderValue
X-Ting-Api-Tokentapp_...Required — identifies which app this send belongs to.
Content-Typeapplication/jsonRequired.

Example request

Example 1 — third-party channels (LINE, Telegram …)

Name the channel codes enabled on the app — several at once is fine; everyone subscribed on those channels receives it (omit channels to send to every enabled channel).

curl -X POST <TING_API_URL>/apps/v1/notify \
  -H "X-Ting-Api-Token: tapp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "message": "Your order #1042 has shipped!",
    "channels": ["LINE", "TELEGRAM"]
  }'

Example 2 — the Ting Notifications

channels: ["TING"] reaches every follower of this app inside the Ting Notifications (a push on their phone, full-HTML rich messages supported). To target specific followers add recipients: { "TING": ["TNG…"] } with their TNG codes.

curl -X POST <TING_API_URL>/apps/v1/notify \
  -H "X-Ting-Api-Token: tapp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "message": "Your order #1042 has shipped! Open the app to track it.",
    "channels": ["TING"]
  }'

Replace <TING_API_URL> with the real URL shown on your app's Settings tab, under "API Endpoints" — it's the same for every request, so copy it once from there.

Body fields

FieldType
messagestring, requiredThe text to send — up to 8,000 characters (plain text or HTML alike; see the Rich Message guide).
channelsstring[]Channel types to target. Omit or empty = every channel enabled on this app.
recipientsobjectPer-channel list of subscriber ids to target instead of broadcasting to everyone.
tagsstring[]Target only subscribers carrying at least one of these tags (case-insensitive) instead of every subscriber — applies to any channel with no explicit recipients of its own. A subscriber matching more than one tag still only gets sent to once. Not used with a reply or a LINE broadcast.
agentstringWho's sending — shown on the Chat tab entry this creates.
broadcastbooleanLINE only. true = one call to LINE's own broadcast API, reaching every friend of your Official Account and ignoring recipients. Omitted/false = a normal push to subscribers. Cannot be combined with replyToken.
replyTokenstringSetting this makes the send a reply. Take the token from the Chat tab (LINE) or a /ting message run (Discord App). One shared field for both platforms, because a reply goes through exactly one channel to exactly one chat — channels must name that single channel (LINE or DISCORD_APP) and recipients, if sent, a single id; anything else is a 400.
includeExpiredbooleanDefaults to false. Set true to still reach subscribers whose expiry has passed (e.g. a renewal reminder) — see "Manage subscribers" below. A banned subscriber is never included regardless.
delaySecondsnumber (optional)Hold the send in the queue for this many seconds before it becomes eligible (0–604800, up to 7 days) — "remind me in 10 minutes" with no timer on your side. Omit or 0 = send as soon as the queue reaches it.

Channel type values

TING — The Ting Notifications itself — full HTML/CSS, see belowLINE — LINETELEGRAM — TelegramDISCORD_APP — Discord (bot, per-member DM)DISCORD_WEBHOOK — Discord (channel webhook)SLACK — SlackMESSENGER — MessengerINSTAGRAM — InstagramWHATSAPP — WhatsApp

What makes the Ting Notifications different

Every other channel only renders bold/italic/links, whatever that platform's own syntax allows. A follower who receives through TING instead sees your message rendered with real, full HTML/CSS — gradients, rounded corners, shadows, flexbox, a full-screen embedded iframe, a horizontal product carousel — and can also use "Command Ting": a button that fires a webhook straight back into your own system when tapped (see below). None of it needs any extra token or OAuth setup on the follower's side.

See every Ting Notifications-only feature →

Example response

JSON
{
  "success": true,
  "sendLogId": 8842
}

Sends are asynchronous: this call just queues the send and answers immediately (with a sendLogId) — it does not wait for delivery to finish before responding. The actual sending happens entirely in the background. To learn the outcome, ask GET /apps/v1/sends/{id} with that sendLogId (see "Check send status"), or listen for a "message.sent" event on your Ting Events Webhook in real time.

Errors

Error
INVALID_TOKENMissing, malformed, or revoked API token.
MESSAGE_REQUIRED"message" was empty or missing.
MESSAGE_TOO_LONG"message" was longer than 8,000 characters.
DUPLICATE_RECIPIENTThe same recipient id appears more than once in one channel's list (e.g. recipients.LINE has "U1234" twice)
REPLY_SINGLE_CHANNEL_ONLYreplyToken was sent but channels does not name exactly one channel (omitted, several, or ALL) — a reply goes through one channel only.
REPLY_SINGLE_RECIPIENT_ONLYreplyToken was sent but recipients lists more than one id, or more than one channel — a reply reaches one person only.
REPLY_TOKEN_UNSUPPORTED_CHANNELreplyToken was sent for a channel that has no reply concept — only LINE and DISCORD_APP accept one.
REPLY_TOKEN_WITH_BROADCASTreplyToken and broadcast: true in the same request — replying to one person and broadcasting to everyone are different modes; pick one.
NO_MATCHING_ENABLED_CHANNELNone of the requested channels are enabled on this app.

A free-trial app can send up to the daily limit shown on its Settings tab; a topped-up app sends without limit. A blocked send comes back as success:false with a reason in Detail, per channel or recipient — it never fails the whole request.

6. Check send status: what's done, what's pending, what failed

/notify only answers "queued" with a sendLogId — this is the read side of the queue. Ask about any sendLogId whenever you like to see who received it and who failed and why, or list every send on the app to build your own dashboard. Uses the same token as /notify and only ever shows that app's sends.

GET/apps/v1/sends/{sendLogId}

Status of one send — sendLogId is the value /notify returned

Example: wait until the send finishes (polling)

curl "<TING_API_URL>/apps/v1/sends/8842" \
  -H "X-Ting-Api-Token: tapp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

Example response

JSON
{
  "id": 8842,
  "status": "COMPLETED",
  "done": true,
  "error": null,
  "createdAt": "2026-09-09T05:51:40.120Z",
  "startedAt": "2026-09-09T05:51:41.004Z",
  "completedAt": "2026-09-09T05:51:42.610Z",
  "summary": { "total": 3, "sent": 2, "failed": 1, "pending": 0 },
  "channels": [
    {
      "channelType": "LINE",
      "channelName": "LINE OA หลัก",
      "status": "COMPLETED",
      "total": 2, "processed": 2, "sent": 2, "failed": 0,
      "failureReason": null,
      "startedAt": "2026-09-09T05:51:41.004Z",
      "completedAt": "2026-09-09T05:51:42.100Z",
      "failures": []
    },
    {
      "channelType": "TING",
      "channelName": "Ting Notifications",
      "status": "COMPLETED",
      "total": 1, "processed": 1, "sent": 0, "failed": 1,
      "failureReason": null,
      "startedAt": "2026-09-09T05:51:41.010Z",
      "completedAt": "2026-09-09T05:51:42.610Z",
      "failures": [
        { "externalId": "3f0c2c1e-...", "displayName": "Nan", "detail": "ผู้รับยังไม่ได้เปิดรับการแจ้งเตือนบนเครื่อง (ไม่มี push token)" }
      ]
    }
  ]
}

Possible status values

status
PENDINGQueued, not started yet (normally under 1–2 seconds).
PROCESSINGSending — watch channels[].processed / summary.pending for live progress.
COMPLETEDEvery channel has finished (individual recipients may still have failed — see summary.failed and channels[].failures).
VALIDATION_FAILEDThe request was rejected up front, nobody received anything — error carries the code, e.g. NO_MATCHING_ENABLED_CHANNEL.
FAILEDThe whole job failed inside Ting (very rare) — error carries the reason.

Key response fields

FieldType
donebooleantrue once nothing more will change (COMPLETED / FAILED / VALIDATION_FAILED) — polling this one field is enough.
summaryobjectAcross every channel: total (recipients planned), sent, failed, pending (not reached yet).
channels[]arrayPer channel: status, total/processed/sent/failed, failureReason (when the whole channel couldn't be attempted, e.g. no subscribers yet) and start/finish times.
channels[].failures[]array | nullOnly the recipients that failed: externalId, displayName and detail (the failure reason or the provider's response). Successes are never listed — they're just counted in sent (so a 10,000-recipient broadcast doesn't come back as 10,000 rows). [] = everyone succeeded, null = channel not finished or never attempted. Capped at 200.
errorstring | nullThe validation code or job error message when status is VALIDATION_FAILED / FAILED.

GET/apps/v1/sends

Every send on the app, newest first — for a dashboard or to find the failed ones

curl
curl "<TING_API_URL>/apps/v1/sends?status=FAILED&from=2026-09-09T00:00:00Z&page=1&pageSize=20" \
  -H "X-Ting-Api-Token: tapp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
QueryType
statusstringFilter by status (e.g. FAILED or COMPLETED). Omit for all.
from / toISO date-timeCreation time range (createdAt).
page / pageSizeintPagination — pageSize up to 100, default 20.
JSON
{
  "items": [
    {
      "id": 8842,
      "status": "COMPLETED",
      "done": true,
      "error": null,
      "source": "API",
      "createdAt": "2026-09-09T05:51:40.120Z",
      "completedAt": "2026-09-09T05:51:42.610Z",
      "summary": { "total": 3, "sent": 2, "failed": 1, "pending": 0 }
    },
    {
      "id": 8841,
      "status": "VALIDATION_FAILED",
      "done": true,
      "error": "NO_MATCHING_ENABLED_CHANNEL",
      "source": "API",
      "createdAt": "2026-09-09T05:40:02.551Z",
      "completedAt": "2026-09-09T05:40:03.000Z",
      "summary": { "total": 0, "sent": 0, "failed": 0, "pending": 0 }
    }
  ],
  "totalCount": 2,
  "page": 1,
  "pageSize": 20
}

PATCH/apps/v1/sends/{sendLogId}/message

Rewrite a message you already sent, by the id you got back when sending

curl
curl -X PATCH "<TING_API_URL>/apps/v1/sends/8842/message" \
  -H "X-Ting-Api-Token: tapp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{"message":"<b>แก้ไข</b> ร้านปิด 22:00 น."}'
JSON
{
  "success": true,
  "updatedCount": 1240,
  "editedAt": "2026-09-09T06:12:03.480Z"
}

Ting App messages only — anything already handed to LINE, Telegram, Discord, Slack, Messenger, Instagram, WhatsApp or email lives on that platform and can't be changed after the fact, and neither can the push banner the phone already showed. What changes is what's readable in the app. One edit applies to every recipient of that send, broadcasts included; editing it for only some of them isn't possible. Readers see an "edited" marker and can open the earlier versions.

Don't want to poll? Turn on the Ting Events Webhook (see "Receive webhooks") and Ting sends a "message.sent" event the moment each recipient is reached — this endpoint is for after-the-fact checks, reporting pages, or systems that can't receive webhooks.

7. Attach an image, video or file

Upload the file to Ting once, get a URL that stays valid for 1 year, then pass that URL as mediaUrl when calling /notify — Ting sends it as a real native image/video on every channel that supports one (LINE, Telegram, Discord, Slack, Messenger, Instagram, WhatsApp, the Ting Notifications), not just a link.

POST/apps/{code}/media

multipart/form-data — {code} is the app code shown on the Settings tab

Form fields

FieldType
type"IMAGE" | "VIDEO" | "FILE"Kind of file — FILE is any document (PDF, Word, Excel, ...).
filefileThe file itself — an image (JPEG/PNG/WebP/GIF), a video (MP4) or a document, up to 100 MB.

Example request

curl -X POST <TING_API_URL>/apps/{code}/media \
  -H "X-Ting-Api-Token: tapp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -F "type=IMAGE" \
  -F "file=@./promo.jpg"

Example response

JSON
{
  "id": 123,
  "url": "<TING_API_URL>/media/123",
  "mediaType": "IMAGE",
  "width": 1200,
  "height": 630,
  "fileSizeBytes": 184320,
  "expiresAt": null
}

Pass the response's url as mediaUrl (with mediaType matching the type you uploaded) in your next /notify call. The same URL can be reused as many times as you like for the life of the file — it lives on the media destination you configured on the Settings tab (e.g. Google Drive or your own storage) and is kept for 1 year from upload (see expiresAt in the response), then deleted automatically, with a console notice 7 days beforehand. Upload again if you still need it after that.

8. Message guide: three ways to write one

The same message field works at three levels — Ting detects which one you sent and converts it to what each platform can actually render. Write it once, send it everywhere.

1

Plain text

No tags at all — delivered as-is on every platform. URLs in the text become clickable wherever the app supports it.

Every channel

message
สินค้าที่คุณสั่งจัดส่งแล้ว ติดตามพัสดุได้ที่ลิงก์ด้านล่าง
https://shop.example.com/track/1042
2

A small set of HTML tags, for every platform

Bold, italic, headings, links, one image and any number of buttons — LINE gets a real Flex Message, Telegram an image + inline buttons, Slack a Block Kit message, Discord an embed, WhatsApp an image + its own markdown.

Every channel (converted automatically)

message (HTML tags)
<img src="https://cdn.example.com/tshirt.jpg">
<h3>เสื้อยืดสีขาว</h3>
<p>ราคา <b>350 บาท</b><i>ส่งฟรี</i></p>
<button href="https://shop.example.com/buy">สั่งซื้อ</button>
<button href="https://shop.example.com/detail">ดูรายละเอียด</button>
3

Full HTML/CSS

Followers on the Ting Notifications see your message rendered in a real WebView — gradients, shadows, flexbox, a horizontal product carousel, a tap-to-open full-screen iframe, and a card height you control.

TING only

message (full HTML/CSS)
<div data-ting-height="420" style="padding:16px;border-radius:16px;background:#fff;font-family:sans-serif;">
  <div style="display:flex;overflow-x:auto;gap:12px;">
    <div style="flex:0 0 200px;border:1px solid #e2e8f0;border-radius:12px;overflow:hidden;">
      <img src="https://cdn.example.com/tshirt.jpg" style="width:100%;height:120px;object-fit:cover;">
      <div style="padding:10px;"><b>เสื้อยืดสีขาว</b><br>350 บาท</div>
    </div>
    <div style="flex:0 0 200px;border:1px solid #e2e8f0;border-radius:12px;overflow:hidden;">
      <img src="https://cdn.example.com/hoodie.jpg" style="width:100%;height:120px;object-fit:cover;">
      <div style="padding:10px;"><b>ฮู้ดดี้สีดำ</b><br>890 บาท</div>
    </div>
  </div>
  <a href="https://shop.example.com" style="display:block;margin-top:14px;padding:10px;border-radius:999px;background:#4f46e5;color:#fff;text-align:center;text-decoration:none;font-weight:700;">ดูสินค้าทั้งหมด</a>
</div>

Tags converted for every platform

Any other tag is not formatted on other platforms (its text content is kept) — on the Ting Notifications everything works.

<b> <strong> — bold<i> <em> — italic<u> — underline<s> <del> — strikethrough<code> — monospace<a href> — link<br> <p> <div> — line break<h1>–<h6> — heading<img src> — image (one per message)<button href> — link button (as many as you like)

Setting the card height on the Ting Notifications

The Ting Notifications estimates a card's height from its content. If a card gets clipped or leaves too much empty space, wrap the message in <div data-ting-height="..."> with a pixel value (40–4000) and the app uses that instead — and never put page-level CSS (100vh, body) inside a card, since it's rendered inside the chat list, not as a whole page.

HTML
<div data-ting-height="320">
  ...เนื้อหาการ์ด...
</div>

Tips

  • Image and button URLs must be full (https://…) and publicly reachable — each platform fetches the image itself, server-side.
  • If you want the image to be a real "image message" on every channel, use mediaUrl (uploaded via /media) rather than an <img> in the text.
  • Messages can be up to 8,000 characters, plain text or HTML alike.
  • Use the console's "Test send" page to preview every platform before sending for real — it costs no quota.
Read the full message guide, with a live HTML preview box →

9. Manage subscribers

Every subscriber has a Status ("ACTIVE" or "BANNED"), an optional Tag (your own free-text label), and an optional ExpiresAt. A banned subscriber is never sendable; an expired one is skipped unless a send sets includeExpired. New subscribers a channel discovers on its own get an ExpiresAt from the app's default policy (Settings tab → "New subscriber policy") — null there means no expiry at all.

GET/apps/{code}/channels/{channelId}/subscribers

Paginated; searchable by name, id or tag (search=).

curl "<TING_API_URL>/apps/{code}/channels/LINE/subscribers?search=nan" \
  -H "X-Ting-Api-Token: tapp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
JSON
{
  "items": [
    {
      "externalId": "U4af4980629...",
      "displayName": "Nan",
      "sourceType": "user",
      "status": "ACTIVE",
      "tags": ["VIP"],
      "expiresAt": null,
      "isExpired": false
    }
  ],
  "totalCount": 1,
  "page": 1,
  "pageSize": 20
}

GET/apps/{code}/channels/{channelId}/subscribers/{externalId}

Get one subscriber

curl "<TING_API_URL>/apps/{code}/channels/LINE/subscribers/U4af4980629..." \
  -H "X-Ting-Api-Token: tapp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

Subscriber fields

FieldType
externalIdstringThe platform's own id for this subscriber (LINE userId, Telegram chatId, Messenger PSID, WhatsApp wa_id, ...).
displayNamestring | nullReal name/profile name where the platform provides one.
sourceTypestring"user"/"private" for a 1-1 chat, "group"/"room"/"supergroup" otherwise.
status"ACTIVE" | "BANNED"A ban blocks every send to this subscriber, with no override.
tagsstring[]Your own free-text labels — any number of them. Used for display/organization, and as a real send target via /notify's tags.
expiresAtstring | nullISO date-time, or null for no expiry (sendable indefinitely).
isExpiredbooleanConvenience flag — true when expiresAt is set and already in the past.

PUT/apps/{code}/channels/{channelId}/subscribers/{externalId}

Every field is independently optional — send only what you want to change.

Update request fields

FieldType
tagsstring[]Omit (null/undefined) to leave unchanged; send [] to clear every tag — this always replaces the whole set, never adds to it.
status"ACTIVE" | "BANNED"Omit to leave unchanged.
expiresAtstringNew expiry (ISO date-time). Omit to leave unchanged. Ignored when clearExpiresAt is true.
clearExpiresAtbooleanSet true to remove the expiry entirely (send indefinitely) — takes priority over expiresAt.
curl -X PUT "<TING_API_URL>/apps/{code}/channels/LINE/subscribers/U4af4980629..." \
  -H "X-Ting-Api-Token: tapp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "tags": ["VIP"],
    "expiresAt": "2026-12-31T00:00:00Z"
  }'
JSON
{
  "externalId": "U4af4980629...",
  "displayName": "Nan",
  "sourceType": "user",
  "status": "ACTIVE",
  "tags": ["VIP"],
  "expiresAt": "2026-12-31T00:00:00Z",
  "isExpired": false
}

10. Generate a join PIN from your own system

When an app is set to "private room", the app code alone isn't enough to join — a PIN is required too. Normally the owner mints those by hand in the console. This endpoint lets your system mint one the moment somebody signs up, and show it to that person to type into the Ting app.

POST/apps/v1/join-pins

Issues one fresh PIN for the app this token belongs to. No request body.

curl
curl -X POST "<TING_API_URL>/apps/v1/join-pins" \
  -H "X-Ting-Api-Token: tapp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
JSON
{
  "id": 481,
  "pin": "2YJ30V",
  "createdAt": "2026-09-18T10:48:49.009Z",
  "expiresAt": "2026-09-19T10:48:49.009Z",
  "status": "ACTIVE"
}

What comes back

FieldType
idnumberThis PIN's reference number, used if you later revoke it from the console
pinstringA 6-character code, digits and uppercase letters — this is what the person types in
createdAtstring (ISO 8601)When the PIN was issued
expiresAtstring (ISO 8601)When it stops working — always 24 hours after it was issued
statusstringState at the time of the call; a freshly minted PIN is always ACTIVE

Status values

status
ACTIVEStill usable — nobody has used it and it hasn't expired
USEDSomeone already joined with it; it cannot be reused
REVOKEDThe owner cancelled it before anyone used it
EXPIRED24 hours passed without anyone using it

Errors

error
APP_NOT_PRIVATEThis app isn't a private room, so it doesn't need PINs — turn on the private-room switch on the Settings tab first
INVALID_TOKENThe X-Ting-Api-Token header is missing or wrong (HTTP 401)

One PIN admits one person, once. To let 50 staff in, call this 50 times and hand each person their own PIN. Don't broadcast a single PIN to everyone — the first person to type it consumes it.

11. Receive webhooks: every event in one shape

Configure one URL per app (Settings tab → "Ting Events Webhook") and Ting POSTs to it whenever a chat message comes in or goes out, or a new follower joins — whichever platform it came from — as the same normalized JSON, HMAC-signed so you can verify it's really from Ting.

The Ting Events Webhook card on an app's Settings tab in the Ting console

Headers on every call Ting makes to you

HeaderValue
X-Ting-Eventmessage.received | message.sent | subscriber.joined | subscriber.updated | subscriber.expiredWhich kind of event this is (same value as the body's event field).
X-Ting-Signaturehex stringHMAC-SHA256 of the raw body, using your webhook secret — omitted if you haven't set a secret.
Content-Typeapplication/jsonThe body is always JSON.

Event types

Event
message.receivedA follower sent a message (from any channel) — data.conversation.externalId is who sent it, data.message.text is the text.
message.sentTing delivered a message — from your /notify calls and from console replies alike. Use it to confirm delivery in real time.
subscriber.joinedA brand-new follower joined (scanned the QR / tapped follow / messaged the bot for the first time) — data.subscriber.externalId + displayName let you greet them right away (see the recipe below). Fires only on the first join, never on a re-join.
subscriber.updatedA follower changed their display name — an update to an existing follower, not a new join; don't re-send a welcome for it.
subscriber.expiredA follower's subscription expired (their configured expiry date has passed) — Ting checks daily at 01:00 Bangkok time and fires one event per follower, once. data.subscriber.expiresAt is when it lapsed; if the expiry is extended and lapses again, a new event fires.

Payload shape

message.received
{
  "event": "message.received",
  "app": { "code": "M4rZTA260953", "name": "Demo Shop" },
  "channel": { "type": "LINE", "name": "LINE OA หลัก" },
  "platform": null,
  "data": {
    "conversation": { "externalId": "U4af4980629..." },
    "message": {
      "direction": "IN",
      "text": "สวัสดีครับ สินค้าชิ้นนี้ยังมีของไหม",
      "hasMedia": false,
      "mediaUrl": null,
      "answeredBy": null,
      "createdAt": "2026-09-04T07:12:33.000Z"
    }
  },
  "timestamp": "2026-09-04T07:12:33.100Z"
}
subscriber.joined
{
  "event": "subscriber.joined",
  "app": { "code": "M4rZTA260953", "name": "Demo Shop" },
  "channel": { "type": "TING", "name": "Ting Notifications" },
  "platform": "ANDROID",
  "data": {
    "subscriber": {
      "externalId": "3f0c2c1e-6b8a-4c1f-9c1e-8f2a9d0b7e21",
      "displayName": "Nan",
      "language": "th"
    }
  },
  "timestamp": "2026-09-09T02:15:40.512Z"
}
subscriber.expired
{
  "event": "subscriber.expired",
  "app": { "code": "M4rZTA260953", "name": "Demo Shop" },
  "channel": { "type": "LINE", "name": "LINE Official" },
  "data": {
    "subscriber": {
      "externalId": "U4af4980629d2c1b7f8e5a3c0b9d1e2f3",
      "displayName": "Nan",
      "expiresAt": "2026-09-08T17:00:00.000Z"
    }
  },
  "timestamp": "2026-09-09T18:00:03.218Z"
}

Example webhook endpoint (signature check + event routing, complete)

The code below is a ready-to-run server on your side: read the raw body → verify the HMAC → branch on the event type. Respond 200 as fast as you can and do the heavy work afterwards. Ting records every delivery on the console's Logs → Event Webhooks page for later inspection.

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

const app = express();
const SECRET = process.env.TING_WEBHOOK_SECRET;   // from Settings → Ting Events Webhook

// express.raw keeps the exact bytes Ting signed — do NOT use express.json() here
app.post('/ting/events', express.raw({ type: '*/*' }), (req, res) => {
  const expected = crypto.createHmac('sha256', SECRET).update(req.body).digest('hex');
  const given = req.get('X-Ting-Signature') ?? '';
  if (given.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected))) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  switch (event.event) {
    case 'message.received':
      console.log(`[${event.channel.type}] ${event.data.conversation.externalId}: ${event.data.message.text}`);
      break;
    case 'message.sent':
      // delivery outcome of a /notify call or a console reply
      break;
    case 'subscriber.joined':
      console.log(`new follower ${event.data.subscriber.displayName} via ${event.channel.type}`);
      break;
  }
  res.sendStatus(200);   // answer quickly — do slow work after responding
});

app.listen(3000);

Verifying: compute an HMAC-SHA256 of the exact raw request body using your webhook secret, hex-encode it (lowercase), and compare to X-Ting-Signature in constant time — don't re-serialize the JSON first, since that can change the byte-for-byte body the signature was computed over.

12. Command Ting: buttons your followers press

A button a Ting Notifications follower taps that fires a webhook directly to your own endpoint — you never call an API for this, just configure it in the console and wait for the request to arrive.

  1. 1

    Configure the command in the console

    App page → "Commands" tab → "Add command". Name it, give it a target endpoint URL, and add whichever parameters you want the follower to fill in.

  2. 2

    A follower taps the command in the Ting Notifications

    Picks the command from the list, fills in the form (if any), and confirms.

  3. 3

    Ting POSTs to your endpoint immediately

    Sent from Ting's own server, never from the follower's device — within 30 seconds.

Headers on every call Ting makes to this endpoint

HeaderValue
X-Ting-Signaturehex stringHMAC-SHA256 of the raw body, using the same signing secret as the Ting Events Webhook (see "Receive webhooks" above — one secret signs both, generated automatically the first time you save a command, even if you've never set an Events Webhook URL). Verify the same way: compute HMAC-SHA256 of the raw body with your secret and compare in constant time.
Content-Typeapplication/jsonBody is always JSON.

The payload shape

HMAC-signed the same way as the Ting Events Webhook above (X-Ting-Signature header above) — this endpoint is just as internet-reachable as that one, so you can verify a call genuinely came from Ting rather than anyone who guesses the URL.

Verify the signature exactly the way you would for the Ting Events Webhook — see the full HMAC-check example in "Receive webhooks" above, and use the same secret.

JSON
{
  "userCode": "TNG3049182",
  "appName": "Demo Shop",
  "commandName": "cancelOrder",
  "commandLabel": "ยกเลิกออเดอร์",
  "language": "th",
  "platform": "ANDROID",
  "values": {
    "orderId": "1042",
    "reason": "Changed my mind",
    "urgent": true,
    "photo": "<TING_API_URL>/media/456",
    "where": { "lat": 13.7563, "lng": 100.5018 }
  }
}

Payload fields

FieldType
userCodestringThe follower who tapped the command's own id ("TNG#######" format) — the same code shown on that follower's own profile screen.
appNamestringThe name of the app this command belongs to — useful if one endpoint backs commands on more than one of your apps.
language"th" | "en"The language that follower has the app set to — use it to pick the language of your reply.
valuesobjectWhat the follower filled in, keyed by the parameter names you configured. A checkbox sends a real boolean, decimal/rating send a number (rating is an integer 1-5), multiselect sends an array of strings, camera/image/video/file/signature send the uploaded file's URL, location sends { lat, lng }; every other type (including scan) sends a string — any type can be null if it wasn't required and was left blank.

Configurable parameter types

string — Text (sent as a string)textarea — Multi-line text (sent as a string)int — Number (sent as a string)decimal — Decimal number (sent as a number, e.g. 199.5)phone — Phone number (sent as a string, loosely validated)email — Email (sent as a string)date — Date (sent as a string)time — Time (sent as a string, HH:mm)datetime — Date & time (sent as an ISO8601 string, e.g. 2026-09-16T14:30:00.000)checkbox — Checkbox (sent as a real boolean)radio — A predefined choice (sends the chosen option's value as a string)select — Dropdown (sends the chosen option's value as a string, null if not required and nothing was picked)multiselect — Multi-select (sends an array of strings in option order, [] if nothing was ticked)rating — Star rating 1-5 (sent as an integer 1 to 5)scan — Scan a QR code or barcode (sent as the decoded string)camera — Take a photo with the camera (sent as an image URL)image — Pick an image from the device (sent as an image URL)video — Video (sent as a video URL)file — A document — PDF/Word/Excel (sent as a file URL)signature — Signature (sent as a PNG image URL)location — Current GPS position (sent as { lat, lng })

What your endpoint should respond

HTTP 2xx = success. The response body (up to 500 characters) is shown to the follower as a short toast right away — answer with "ok" or a short sentence, never a URL or internal detail. To send a full result (an HTML card, an image), have the endpoint call /notify back to that follower instead — see the "check server status" recipe below.

HTTP
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8

ok

Ting waits at most 30 seconds for your endpoint to respond — anything longer counts as failed, with no automatic retry. For long-running work, answer "ok" first and deliver the result afterwards via /notify.

13. Pages: your own forms and UI on Ting

Write your own HTML/CSS/JavaScript page in the console and let Ting host it — delivered into the Ting app as an iframe card that runs JavaScript in full (unlike a plain message card, which can't). Ideal for input forms, confirmation screens or any interactive UI, with no server of your own required.

  1. 1

    Create a page in the console

    App → "Pages" tab → "New page": give it a name and a slug (the tail of the link — a-z, 0-9 and - only), then write the code in the editor. It must be a complete HTML document with CSS/JS in the same file; three templates (blank / form / confirm-cancel buttons) get you started and the live preview updates as you type.

  2. 2

    Attach it to a Command

    "Command Ting" tab → Add command → under "What happens when a follower taps it" pick "Send page: <your page>" — no endpoint to fill in and no params to ask for before the tap, because the page is the form.

  3. 3

    A follower taps the command

    Ting sends the page into that follower's room as an iframe card right away, with the tapping follower's userCode and the command name attached to the page's query string.

  4. 4

    The page posts to you

    JavaScript inside the page reads the query string and fetches your own server directly — Ting never receives or stores what was typed.

With Command Ting (no server needed)

The easy way: pick the page from the dropdown on the command form — Ting sets the endpoint to one of its own (of the form /api/pages/{appCode}/{slug}/send) and handles everything. Configuring through the API instead? Use the commandEndpoint value returned by the page management API (below) as the command's endpoint.

Pick "Custom" instead and it's a regular Command Ting (section 12): Ting POSTs to your endpoint with the params the follower filled in, and your endpoint can then send a page back via /notify (below) if it wants to.

What the page receives (query string)

Whenever Ting sends a page to someone, the link in the card is filled in for that person:

URL
<TING_API_URL>/p/AbC123xyz/order-form?userCode=TNG3049182&command=confirmOrder&ts=1789540329&sig=9f2c1e…a41b
ParameterType
userCodestringThe receiving follower's code ("TNG#######" — the same code shown on their profile screen and sent in Command Ting payloads)
commandstringInternal name of the command that caused this send — for a /notify send it's the agent value instead
tsnumberWhen Ting issued this link (unix seconds) — use it to limit the link's age when verifying
sigstringHMAC-SHA256 (hex) of userCode|command|ts with the app's signing secret — have the page forward it to your server to prove the form was opened from a Ting-issued link (see "Verifying it came from Ting" below)

Read them in the page's JavaScript and post the filled form to your own server:

JavaScript
const params = new URLSearchParams(location.search);
const userCode = params.get('userCode');   // "TNG3049182"
const command  = params.get('command');    // "confirmOrder"
const ts       = params.get('ts');         // "1789540329"  (unix seconds, when Ting issued the link)
const sig      = params.get('sig');        // HMAC-SHA256 hex — verified on YOUR server, never here

document.getElementById('form').addEventListener('submit', async (e) => {
  e.preventDefault();
  const data = Object.fromEntries(new FormData(e.target).entries());
  await fetch('https://your-server.com/api/submit', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ userCode, command, ts, sig, ...data }),
  });
});

The page runs on a sandboxed origin (the browser sends Origin: null) — your server must allow it via CORS (e.g. Access-Control-Allow-Origin: *) or the browser blocks the fetch.

Close the page and return to the app

When the user is finished on your page (form submitted, order confirmed, and so on), link to tingapp://close. The Ting App closes the page and drops them back in the conversation they came from, instead of leaving them to find the back arrow. In an ordinary browser the link does nothing, so it's always safe to include.

HTML
<a href="tingapp://close">ปิด</a>

Sending a page yourself via /notify

For sends that aren't a reaction to a tap — no card HTML to write, just name the page and Ting composes the iframe card, filling in each recipient's userCode. Every other /notify field (delaySeconds, includeExpired, ...) works as usual.

HTTP
POST <TING_API_URL>/apps/v1/notify
X-Ting-Api-Token: tapp_xxxxxxxxxxxxxxxx
Content-Type: application/json

{
  "page": "order-form",
  "message": "กรุณากรอกฟอร์มยืนยันคำสั่งซื้อ",
  "pageHeight": 420,
  "channels": ["TING"],
  "recipients": { "TING": ["TNG3049182"] }
}
FieldType
pagestringSlug of a page in this app — an unknown slug returns 400 PAGE_NOT_FOUND
messagestring (optional)A short line shown above the iframe — omit for the page alone
pageHeightnumber (optional)Height of the iframe inside the card, px (200–1200, default 420)
channelsstring[]Use ["TING"] — pages only render inside the Ting Notifications app; other channels just get the text

Writing the card yourself (advanced)

If your endpoint composes the card HTML itself, these placeholders are filled in per recipient at send time (Ting Notifications channel only); the values are HTML-encoded.

Placeholder
{{userCode}}The receiving follower's code
{{command}}The command that fired (or the send's agent)
{{ts}}When the card was issued (unix seconds)
{{signature}}HMAC-SHA256 of userCode|command|ts, per recipient
HTML
<div data-ting-height="420" style="background:#fff;border-radius:16px;padding:8px;">
  <iframe src="<TING_API_URL>/p/AbC123xyz/order-form?userCode={{userCode}}&command={{command}}&ts={{ts}}&sig={{signature}}"
          style="display:block;width:100%;height:400px;border:0;border-radius:12px;"></iframe>
</div>

Verifying it came from Ting

Every page link Ting sends out carries ts and sig. Have the page forward userCode, command, ts and sig along with the form data, and recompute the HMAC on your server with the app's signing secret (the same one X-Ting-Signature uses — app details → Settings → Ting Events Webhook). A match proves Ting issued this link for that follower, rather than someone guessing the URL:

HMAC
sig = HMAC-SHA256( secret, userCode + "|" + command + "|" + ts )   → lowercase hex

secret   = Signing secret ของแอป (Settings → Ting Events Webhook — ตัวเดียวกับที่ใช้ตรวจ X-Ting-Signature)
example  = HMAC-SHA256( secret, "TNG3049182|confirmOrder|1789540329" )
const crypto = require('crypto');

function verifyTingPage({ userCode, command, ts, sig }, secret, maxAgeSeconds = 3600) {
  const expected = crypto.createHmac('sha256', secret)
    .update(`${userCode}|${command}|${ts}`)
    .digest('hex');
  const fresh = Math.abs(Date.now() / 1000 - Number(ts)) <= maxAgeSeconds;
  return fresh && sig.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expected, 'hex'));
}

app.post('/api/submit', (req, res) => {
  if (!verifyTingPage(req.body, process.env.TING_SIGNING_SECRET)) {
    return res.status(401).json({ error: 'not from Ting' });
  }
  // req.body.userCode is now trusted — save the form
  res.json({ ok: true });
});

Verify on your server only — never put the secret in page code (pages are public) — and cap the age via ts (e.g. 1 hour) so an old link can't be replayed. An app that has no signing secret yet (an older app that never set one) gets an empty sig — open the app's Settings once and the system generates it.

Good to know

  • Pages are served under a sandbox isolated from Ting: JavaScript, forms and popups work, but Ting's tokens/data are out of reach and localStorage is unavailable (keep state in JS variables or on your server).
  • Pages are public — anyone with the link can open one. Never put secrets (API keys, passwords) in page code; always authorize on your own server.
  • userCode alone only identifies the follower — the trust comes from checking sig on your server (above); reject form data that arrives without a sig or with one that doesn't match.
  • Set a meaningful <title> in the page — the Ting app uses it as the screen title when a user opens the page full-screen (the domain is never shown).

14. Command playground: try every one in the app

The "Ting Notifications" app inside the Ting Notifications carries 9 demo commands wired to a live example endpoint — tap them to see exactly what each param type delivers to your endpoint, and every way an endpoint can answer. The code behind the most complete one is below, written the way you would write it for real.

How to try it

  1. 1

    Follow "Ting Notifications" in the Ting Notifications

    Find it in the in-app Store, or scan the QR code on this website's home page

  2. 2

    Tap the ⚡ (Commands) icon in the room

    You'll see all 9 commands prefixed "ทดลอง:" (demo)

  3. 3

    Tap one, fill the form, watch the result

    Whatever the endpoint answers shows up right in the command sheet; any notification the endpoint pushes back lands in the same room

From the real app

The demo commands listed in the Ting Notifications room
The demo commands listed in the Ting Notifications room
The "table booking" form — every field type in one sheet
The "table booking" form — every field type in one sheet
The confirmation card the endpoint pushed back
The confirmation card the endpoint pushed back

The 9 demo commands

CommandParams to fill inWhat the endpoint doesWhat you get back
🔍 Demo: show what the endpoint receivedAll 11 types — string, int, date, time, checkbox, radio, camera, image, video, file, location (all optional)Nothing but echo the payload it receivedThe raw payload JSON + the X-Ting-Signature header value, shown in the command sheet
📨 Demo: message memessage (string)Calls /notify to the tapping follower with recipients.TING: [userCode]One plain-text notification
🧾 Demo: send me an HTML cardCalls /notify with an HTML message (inline styles + data-ting-height)An order-confirmation card
🖼️ Demo: send image / video / filekind (radio: IMAGE / VIDEO / FILE)Calls /notify with a public mediaUrl + mediaTypeA notification with an image, a video or a PDF attached
📷 Demo: take a photo, get it backphoto (camera), note (string)The app uploads the photo first, so values.photo is already a URL — the endpoint sends it back as mediaUrlThe photo you just took, as a notification
🍽️ Demo: table booking (every field type)name (string), guests (int), date, time, branch (radio), smoking (checkbox), note (string), locationReads each value in its real shape — int as a number, checkbox as a boolean, date as ISO 8601 ("2026-09-11T00:00:00.000"), time "HH:mm", location {lat, lng} — and builds a confirmation cardA booking card + Google Maps link from the location, and the received values shown in the command sheet
⏰ Demo: schedule a messageminutes (radio: 1 / 5 / 30), message (string)Calls /notify with a delay — Ting's queue sends it when due, no timer on your sideA notification that arrives when the time is up
💥 Demo: simulate a broken endpoint (HTTP 500)Answers HTTP 500The app shows the command as failed, with the status code and the endpoint's message
🖥️ Demo: iframe card (a real clock + tap counter)Calls /notify with an HTML message containing <iframe src="..."> embedding a tiny page with its own JavaScriptA card with a clock that actually ticks and a button that actually counts — unlike a plain HTML card, which can't run scripts at all (the WebView has JS off); the iframe's content is a separate document where JS runs normally

The 9 demo endpoints live at https://api.mawin.one/api/demo/commands/{joinCode}/{action} with action = echo, text, card, media, photo, booking, schedule, fail, iframe — bound to the Ting Notifications app only, so they can't be called from elsewhere (signature won't match).

The endpoints behind all 9 demo commands

One server with all 9 endpoints — the same code the in-app commands actually call, written the way you would write it yourself, ready to copy as a starting point (X-Ting-Signature verification is identical to the "Receive Webhooks" section above and omitted here for readability).

const express = require('express');

const app = express();
app.use(express.json());

const TING_API = '<TING_API_URL>';
const TING_TOKEN = process.env.TING_API_TOKEN;   // tapp_...
const SAMPLES = 'https://ting.mawin.one/demo';   // any publicly fetchable URL works
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));

// One call to /notify — to the follower who tapped (recipients), or everyone
// in the room when recipients is omitted. Extra fields (mediaUrl, mediaType,
// delaySeconds) are passed straight through.
async function notify(body, userCode) {
  const res = await fetch(`${TING_API}/apps/v1/notify`, {
    method: 'POST',
    headers: { 'X-Ting-Api-Token': TING_TOKEN, 'Content-Type': 'application/json' },
    body: JSON.stringify({ channels: ['TING'], ...(userCode ? { recipients: { TING: [userCode] } } : {}), ...body }),
  });
  return res.json();   // { success, sendLogId }
}
const reply = (res, text) => res.type('text/plain').send(text);

// 1. echo — answer with exactly what arrived (every param type, all optional)
app.post('/ting/demo/echo', (req, res) => {
  reply(res, 'Received:\n' + JSON.stringify(req.body, null, 2) + '\nX-Ting-Signature: ' + req.get('X-Ting-Signature'));
});

// 2. text — one string param in, one plain notification back to the tapper
app.post('/ting/demo/text', async (req, res) => {
  const { userCode, values } = req.body;
  await notify({ message: `📨 ${values.message}` }, userCode);
  reply(res, 'Sent — check this room\'s notifications');
});

// 3. card — an HTML body (inline styles only, JS is off in the app's WebView; data-ting-height sizes it)
app.post('/ting/demo/card', async (req, res) => {
  const { userCode, language } = req.body;
  const en = language === 'en';
  const card = `
<div data-ting-height="300" style="padding:24px;border-radius:20px;background:#fff;font-family:sans-serif;color:#0f172a;">
  <span style="padding:6px 14px;border-radius:999px;font-weight:700;font-size:13px;background:#dbeafe;color:#1d4ed8;">${en ? 'HTML card' : 'การ์ด HTML'}</span>
  <h2 style="font-size:20px;margin:14px 0 6px;">${en ? 'Order #1042 confirmed' : 'ยืนยันออเดอร์ #1042'}</h2>
  <table style="width:100%;border-collapse:collapse;font-size:14px;">
    <tr><td style="color:#64748b;padding:6px 0;">${en ? 'Item' : 'รายการ'}</td><td style="text-align:right;font-weight:600;">Latte × 2</td></tr>
    <tr><td style="color:#64748b;padding:6px 0;">${en ? 'Total' : 'ยอดรวม'}</td><td style="text-align:right;font-weight:600;">฿130</td></tr>
  </table>
  <a href="https://ting.mawin.one" style="display:block;margin-top:16px;text-align:center;padding:12px 0;border-radius:12px;background:#0f172a;color:#fff;font-weight:700;text-decoration:none;">${en ? 'Open website' : 'เปิดเว็บไซต์'}</a>
</div>`;
  await notify({ message: card }, userCode);
  reply(res, en ? 'Card sent' : 'ส่งการ์ดแล้ว');
});

// 4. media — a public mediaUrl + mediaType (IMAGE | VIDEO | FILE); Ting downloads and attaches it
app.post('/ting/demo/media', async (req, res) => {
  const { userCode, values } = req.body;
  const kind = values.kind;                       // radio -> "IMAGE" | "VIDEO" | "FILE"
  const file = { IMAGE: 'sample.jpg', VIDEO: 'sample.mp4', FILE: 'sample.pdf' }[kind];
  await notify({ message: `Sample ${kind.toLowerCase()}`, mediaUrl: `${SAMPLES}/${file}`, mediaType: kind }, userCode);
  reply(res, `Sent ${kind}`);
});

// 5. photo — camera/image params arrive as a URL the app already uploaded; send it straight back
app.post('/ting/demo/photo', async (req, res) => {
  const { userCode, values } = req.body;
  if (!values.photo) return reply(res, 'No photo attached');
  await notify({ message: `📷 ${values.note || 'Your photo'}`, mediaUrl: values.photo, mediaType: 'IMAGE' }, userCode);
  reply(res, 'Photo sent back: ' + values.photo);
});

// 6. booking — every scalar type + location, turned into a confirmation card
app.post('/ting/demo/booking', async (req, res) => {
  const { userCode, language, values } = req.body;
  const en = language === 'en';
  const name = values.name;                 // string
  const guests = values.guests;             // int      -> number
  const date = values.date.slice(0, 10);              // date     -> "2026-09-12T00:00:00.000" (ISO 8601)
  const time = values.time;                 // time     -> "19:00"
  const branch = values.branch;             // radio    -> the chosen option's value
  const smoking = values.smoking === true;  // checkbox -> boolean
  const note = values.note ?? '';           // optional -> missing when left blank
  const loc = values.location;              // location -> { lat, lng } | undefined
  const bookingNo = 'BK' + Date.now().toString().slice(-8);
  const row = (k, v) => `<tr><td style="padding:6px 0;color:#64748b;">${k}</td><td style="padding:6px 0;text-align:right;font-weight:600;">${esc(v)}</td></tr>`;
  const card = `
<div data-ting-height="${loc ? 420 : 360}" style="padding:24px;border-radius:20px;background:#fff;font-family:sans-serif;color:#0f172a;">
  <span style="padding:6px 14px;border-radius:999px;font-weight:700;background:#dcfce7;color:#166534;">✅ ${en ? 'Booking confirmed' : 'จองสำเร็จ'}</span>
  <h2 style="margin:14px 0 4px;">${bookingNo}</h2>
  <table style="width:100%;border-collapse:collapse;font-size:14px;">
    ${row(en ? 'Name' : 'ชื่อ', name)}${row(en ? 'Guests' : 'จำนวนคน', guests)}
    ${row(en ? 'Date' : 'วันที่', date)}${row(en ? 'Time' : 'เวลา', time)}
    ${row(en ? 'Branch' : 'สาขา', branch)}${row(en ? 'Smoking area' : 'โซนสูบบุหรี่', smoking ? '✓' : '✗')}
    ${note ? row(en ? 'Note' : 'หมายเหตุ', note) : ''}
  </table>
  ${loc ? `<a href="https://www.google.com/maps?q=${loc.lat},${loc.lng}" style="display:block;margin-top:14px;text-align:center;padding:11px 0;border-radius:12px;background:#dcfce7;color:#166534;font-weight:700;text-decoration:none;">📍 ${en ? 'Your location' : 'ตำแหน่งของคุณ'}</a>` : ''}
</div>`;
  await notify({ message: card }, userCode);
  reply(res, (en ? `Booking ${bookingNo} confirmed` : `จอง ${bookingNo} สำเร็จ`) + '\n\nvalues:\n' + JSON.stringify(values, null, 2));
});

// 7. schedule — delaySeconds holds the send in Ting's queue; no timer on your side
app.post('/ting/demo/schedule', async (req, res) => {
  const { userCode, values } = req.body;
  const minutes = Number(values.minutes);   // radio -> "1" | "5" | "30"
  await notify({ message: `${values.message || "Time's up!"}`, delaySeconds: minutes * 60 }, userCode);
  reply(res, `Scheduled — arrives in ${minutes} min`);
});

// 8. fail — any non-2xx shows in the app as a failed command, with this text
app.post('/ting/demo/fail', (req, res) => {
  res.status(500).type('text/plain').send('Simulated failure: backend is down');
});

// 9. iframe — the card's own HTML has JS off (see "card" above), but an
//    <iframe src="..."> loads a separate page, so THAT page's JavaScript
//    runs fine — use this for a live widget, a map, or a small tool page
//    instead of a static card.
app.post('/ting/demo/iframe', async (req, res) => {
  const { userCode } = req.body;
  const card = `
<div data-ting-height="260" style="padding:12px;border-radius:20px;background:#fff;">
  <iframe src="https://your-site.com/live-widget" style="display:block;width:100%;height:236px;border:0;border-radius:12px;"></iframe>
</div>`;
  await notify({ message: card }, userCode);
  reply(res, 'iframe card sent');
});

app.listen(3000);

15. Real-world recipes

Both of these run for real on our own "Ting Notifications" app — the images below are actual phone screenshots, not mockups. Drop the code on your server, swap in your token and URL, and they work as-is.

Command Ting → reply with a card

Check server status from one button in the app

A follower taps "Check server status" in the Ting Notifications → Ting POSTs to your endpoint → the endpoint checks its own systems and pushes a status card (HTML) back to that one follower via /notify — all within a few seconds, without the follower typing anything.

  1. 1

    Configure the command in the console

    App → "Commands" tab → add a command named "Check server status" with the URL of the endpoint below. No parameters needed.

  2. 2

    The endpoint receives the payload

    You get userCode (the tapping follower's TNG code), appName and language in the body.

  3. 3

    Run your checks, push the card back

    Call /notify with channels: ["TING"] and recipients.TING: [userCode] — the TNG code works directly, no lookup needed.

  4. 4

    Answer "ok" to Ting

    That short text shows as a toast for the follower; the real card lands in the app's chat immediately.

Actual screen in the Ting Notifications

The server status card as shown in the Ting Notifications: all systems normal, database connected, uptime and time of check

Your endpoint's code

const express = require('express');

const app = express();
app.use(express.json());

const TING_API = '<TING_API_URL>';
const TING_TOKEN = process.env.TING_API_TOKEN;   // tapp_...

app.post('/ting/commands/check-status', async (req, res) => {
  const { userCode, appName, language, values } = req.body;

  // 1. Run your own checks
  const dbOk = await checkDatabase();
  const uptimeMin = Math.floor(process.uptime() / 60);

  // 2. Push a rich status card back to the follower who tapped the command
  const ok = dbOk;
  const title = language === 'en'
    ? (ok ? 'All systems normal' : 'Problem detected')
    : (ok ? 'ระบบทำงานปกติ' : 'ระบบมีปัญหา');
  const card = `
<div data-ting-height="230" style="padding:20px;border-radius:16px;background:#fff;font-family:sans-serif;text-align:center;">
  <span style="padding:6px 16px;border-radius:999px;font-weight:700;background:${ok ? '#dcfce7' : '#fee2e2'};color:${ok ? '#16a34a' : '#dc2626'};">${title}</span>
  <h3 style="margin:16px 0 4px;">${appName}</h3>
  <p style="margin:0;color:#64748b;font-size:14px;">Database: ${dbOk ? 'OK' : 'DOWN'} · Uptime: ${uptimeMin} min</p>
</div>`;

  await fetch(`${TING_API}/apps/v1/notify`, {
    method: 'POST',
    headers: { 'X-Ting-Api-Token': TING_TOKEN, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      message: card,
      channels: ['TING'],
      recipients: { TING: [userCode] },
    }),
  });

  // 3. Short acknowledgement — shown to the follower as a toast (max 500 chars)
  res.type('text/plain').send('ok');
});

app.listen(3000);
Webhook → greeting

Greet every new follower automatically (Thai + English)

Every time someone follows your app for the first time, Ting sends a "subscriber.joined" event to your webhook — the code below sends a bilingual welcome back to that one person, on the channel they joined through (an HTML card from the Ting Notifications, plain text from LINE/Telegram/others).

  1. 1

    Set up the Ting Events Webhook

    App Settings tab → enter your endpoint URL and generate a secret.

  2. 2

    Handle the subscriber.joined event

    Verify the signature as in the "Receive webhooks" example, then branch on event === "subscriber.joined".

  3. 3

    Send the greeting back

    Call /notify with channels: [channel.type] and recipients: { [channel.type]: [externalId] } from the payload — so only the person who just joined gets it.

The push arrives the moment they follow

The phone's system notification banner showing the welcome message from the Ting Notifications app

Actual screen in the Ting Notifications

The bilingual welcome card as shown in the Ting Notifications

The greeting function (called from the handler in "Receive webhooks")

const TING_API = '<TING_API_URL>';
const TING_TOKEN = process.env.TING_API_TOKEN;   // tapp_...

function greeting(appName, name, richHtml) {
  const who = name ? ` คุณ${name}` : '';
  const whoEn = name ? `, ${name},` : '';
  const th = `ยินดีต้อนรับ${who} สู่ ${appName}! ขอบคุณที่กดติดตามนะ 🎉 ตั้งแต่นี้เราจะส่งการแจ้งเตือนและข่าวสารสำคัญมาหาคุณที่นี่`;
  const en = `Welcome${whoEn} to ${appName}! Thanks for following 🎉 We'll keep you posted with important updates right here from now on.`;
  if (!richHtml) return `${th}\n\n${en}`;   // LINE/Telegram/...: plain text
  return `
<div data-ting-height="360" style="padding:24px;border-radius:20px;background:#fff;font-family:sans-serif;text-align:center;">
  <span style="padding:8px 18px;border-radius:999px;font-weight:700;background:#ede9fe;color:#7c3aed;">🎉 ยินดีต้อนรับ · Welcome</span>
  <h3 style="margin:18px 0 12px;">${appName}</h3>
  <p style="text-align:left;font-size:14px;line-height:1.6;margin:0 0 10px;">${th}</p>
  <p style="text-align:left;font-size:13px;line-height:1.6;margin:0;color:#64748b;border-top:1px solid #e2e8f0;padding-top:10px;">${en}</p>
</div>`;
}

// call this from the 'subscriber.joined' branch of your /ting/events handler
async function sendWelcome(event) {
  const { type } = event.channel;
  const { externalId, displayName } = event.data.subscriber;

  await fetch(`${TING_API}/apps/v1/notify`, {
    method: 'POST',
    headers: { 'X-Ting-Api-Token': TING_TOKEN, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      message: greeting(event.app.name, displayName, type === 'TING'),
      channels: [type],
      recipients: { [type]: [externalId] },   // only the person who just joined
    }),
  });
}

Caution: whatever your endpoint returns to Ting (Command Ting and webhooks alike) is never shown to followers beyond a short toast, but still never put URLs, tokens or internal details in it — keep your Ting token in a server-side environment variable only, never in a mobile app or a web page users can reach.

Or: get each platform's raw payload instead

Every channel's own setup section has its own "Forward raw webhook" field. Paste a URL there and Ting relays the exact, unconverted body and headers it received from that platform — LINE, Telegram, Discord, and the rest — to your backend in parallel, signature headers included. Reach for this when you'd rather parse each platform's native format yourself; reach for the unified events webhook above when you want one shape for everything.

See every channel in the Docs →

Ready to start sending?

14 days free, full API access, no credit card required

Create a free account