Incoming message
Event incoming_message fires when a customer messages a chat device linked to your webhook, or when mail is accepted for a linked email sender address.
Header:
X-Kirisan-Event: incoming_message Unified envelope
WhatsApp, Telegram, WABA, and Email share the same top-level shape:
| Field | Type | Meaning |
|---|---|---|
event | string | Always incoming_message |
device | string | Chat device token/number, or email receiving address |
channel | string | whatsapp, telegram, waba, or email |
sender | object | Who sent the message — see below |
message | object | Message body — see below |
metadata | object | Channel-specific extra fields |
timestamp | integer | Unix seconds |
production | boolean | true for live traffic; false for Webhooks → Test |
sender
| Field | Meaning |
|---|---|
sender | Reply target — WhatsApp/WABA phone, Telegram chat id, or email From address |
name | Display name after normalization (use for {{name}}) |
details | Channel-specific bag — see per-channel sections |
message
| Field | Meaning |
|---|---|
type | text, image, video, document, location, callback, button_reply, list_reply, and other media types |
text | Primary line — body, caption, or callback data (use for {{message}}) |
file | Optional — url, filename, filetype, mime_type, file_id |
location | Optional — latitude, longitude, coordinate |
button | Optional — id and text for button/list replies |
More {{…}} shortcuts: Resources → Variables → Incoming. Dashboard tour: Variables.
WhatsApp example
{
"event": "incoming_message",
"device": "your-whatsapp-device-token",
"channel": "whatsapp",
"sender": {
"sender": "6281234567890",
"name": "Ada",
"details": {
"type": "personal",
"sender": "6281234567890",
"pushname": "Ada",
"from_me": false
}
},
"message": {
"type": "text",
"text": "Hi, I need help with my order."
},
"metadata": {
"inboxid": 0,
"isgroup": false,
"isforwarded": false
},
"timestamp": 1735689600,
"production": true
} WhatsApp sender.details may also include senderlid, member, memberlid, and type: "group" for group chats.
Media messages set message.type to image, video, audio, or file and populate message.file.url.
Telegram example
{
"event": "incoming_message",
"device": "your-telegram-bot-token",
"channel": "telegram",
"sender": {
"sender": "-1001234567890",
"name": "Alex",
"details": {
"type": "group",
"chat_id": "-1001234567890",
"user_id": "555444333",
"username": "alex_test",
"first_name": "Alex",
"last_name": "",
"from_me": false
}
},
"message": {
"type": "text",
"text": "Hello from the group."
},
"metadata": {
"update_id": 100002,
"message_id": "1001",
"chat_type": "supergroup"
},
"timestamp": 1735689600,
"production": true
} For callback queries (inline button taps), message.type is callback and message.text holds the callback data. metadata.callback_query_id may be present.
In groups, sender.sender is the chat id (reply target), not the user id.
WABA example
WABA uses the same envelope as WhatsApp and Telegram:
{
"event": "incoming_message",
"device": "your-waba-device-token",
"channel": "waba",
"sender": {
"sender": "6281234567890",
"name": "Ada",
"details": {
"type": "personal",
"sender": "6281234567890",
"from_me": false
}
},
"message": {
"type": "text",
"text": "Hello — WABA text message."
},
"metadata": {
"message_id": "wamid.HBgLNjI4...",
"waba_type": "text"
},
"timestamp": 1735689600,
"production": true
} Interactive replies use message.type button_reply or list_reply with a message.button object. metadata may include interactive_type, button_reply_id, or list_reply_id.
Email example
Email uses the same envelope. device is the receiving address (your registered sender). sender.sender is the From address.
{
"event": "incoming_message",
"device": "support@example.com",
"channel": "email",
"sender": {
"sender": "alice@example.com",
"name": "Alice",
"details": {
"type": "personal",
"from": "alice@example.com",
"to": "support@example.com",
"from_me": false
}
},
"message": {
"type": "text",
"text": "Hi, I need help with my order."
},
"metadata": {
"inbound_id": 42,
"subject": "Order question",
"status": "accepted_inbox",
"to": "support@example.com"
},
"timestamp": 1735689600,
"production": true
} metadata.subject holds the email subject. metadata.status is accepted_inbox or accepted_spam. When attachments are stored, the first file appears in message.file and the full list in metadata.attachments.
Attachments and downloading files
When the channel plan includes attachments and storage quota is active, Kirisan saves inbound media to your Files library and puts a downloadable link on the webhook payload.
message.file field | Meaning |
|---|---|
url | Short-lived presigned GET (Cloudflare R2). Valid for about 24 hours from delivery — download promptly. |
filename | Original display name (for example invoice.pdf) |
filetype | Coarse kind: image, video, audio, or file |
mime_type | MIME type when known (for example application/pdf) |
file_id | Kirisan Files row id after store (string). If storage/plan does not allow saving, Telegram may only expose the provider file id and no hosted url. |
Without active storage (or an attachment-capable plan), WhatsApp may still include a provider/CDN url; Telegram often has metadata only and no downloadable Kirisan URL.
Example payload (PDF document)
{
"event": "incoming_message",
"device": "your-telegram-device-token",
"channel": "telegram",
"sender": {
"sender": "357496317",
"name": "Ada",
"details": {
"type": "personal",
"chat_id": "357496317",
"user_id": "357496317",
"username": "ada",
"first_name": "Ada",
"last_name": "",
"from_me": false
}
},
"message": {
"type": "document",
"text": "Here is the invoice",
"file": {
"url": "https://….r2.cloudflarestorage.com/…/object-key?X-Amz-Algorithm=AWS4-HMAC-SHA256&…",
"filename": "invoice.pdf",
"filetype": "file",
"mime_type": "application/pdf",
"file_id": "1842"
}
},
"metadata": {
"update_id": 609668173,
"message_id": "110",
"chat_type": "private"
},
"timestamp": 1735689600,
"production": true
} Email with several attachments: use message.file for the first file, and metadata.attachments[] (each with url, filename, mime_type, file_id) for the full list.
Download the attachment
Treat message.file.url as a normal HTTPS GET. No Kirisan API token is required — the signature is in the query string. Save the response body using filename (or a name you choose).
cURL
# $URL = message.file.url from the webhook JSON
# $NAME = message.file.filename (fallback: attachment.bin)
curl -fsSL -o "$NAME" "$URL" Node.js
import { writeFile } from 'node:fs/promises';
async function downloadIncomingFile(payload) {
const file = payload?.message?.file;
const url = String(file?.url || '').trim();
if (!url) throw new Error('No message.file.url — check plan attachments and storage quota');
const name = String(file.filename || 'attachment.bin').replace(/[/\]/g, '_');
const res = await fetch(url);
if (!res.ok) throw new Error(`Download failed: HTTP ${res.status}`);
const buf = Buffer.from(await res.arrayBuffer());
await writeFile(name, buf);
return { name, bytes: buf.length, mime: file.mime_type, fileId: file.file_id };
} Python
import pathlib
import requests
def download_incoming_file(payload: dict) -> pathlib.Path:
file = (payload.get("message") or {}).get("file") or {}
url = (file.get("url") or "").strip()
if not url:
raise ValueError("No message.file.url — check plan attachments and storage quota")
name = (file.get("filename") or "attachment.bin").replace("/", "_").replace("\", "_")
r = requests.get(url, timeout=60)
r.raise_for_status()
path = pathlib.Path(name)
path.write_bytes(r.content)
return path If the GET returns 403 or 404, the presigned URL likely expired — keep a copy when you first receive the webhook, or open the file again from Resources → Files in the dashboard.
Related
- Webhooks overview — setup and delivery rules
- Verification — headers and signing secret
- Submission payload — autoreply form completions
- Message status — WABA delivery receipts
- Files — library and size limits
- Webhooks → Test — preview sample JSON per channel