English
Receiving Messages (Webhook)
Sending works, now it's time to receive. Set up a webhook and Zate will tell your server whenever a message is received, edited, deleted, or read in your account's private chats. Great for auto-replies, customer service, or just keeping a log of conversations.
Prepare a Receiving Endpoint
Create an HTTP endpoint on your server that accepts a POST with a JSON body. Zate sends your token in the x-token header, so check it before processing anything:
js
// webhook.mjs, using Express
import express from 'express';
const app = express();
app.use(express.json());
const handled = new Set(); // In production, keep this in Redis or a database
app.post('/webhook/zate', (req, res) => {
if (req.header('x-token') !== process.env.ZATE_WEBHOOK_TOKEN) {
return res.sendStatus(401);
}
// Answer quickly: Zate only waits 10 seconds
res.sendStatus(200);
const event = req.body;
// The same event can arrive twice, skip the ones already processed
if (handled.has(event.eventId)) return;
handled.add(event.eventId);
if (event.type === 'message' && !event.data.fromMe) {
console.log(`${event.data.user.name}: ${event.data.text}`);
}
});
app.listen(3000);Set the Webhook
Call Set Webhook with your endpoint's URL and a secret token of your own:
bash
curl -X PUT "https://api-zate.azickri.com/session/webhook" \
-H "id: $ZATE_ID" \
-H "session-id: $ZATE_SESSION_ID" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/webhook/zate","token":"super-secret-123"}'From that moment on, new events are posted to your URL. Messages from before the webhook was set are not sent. To turn the webhook off, send an empty url and token.
Pick a token that's hard to guess
For example the output of openssl rand -hex 16. The token is the only way your server knows a request really comes from Zate, and it travels with every request, so use https if the URL goes over the internet.
Send a Test Message
Ask a friend to message your account (or send one from another account). Within seconds, your server receives an event like this:
json
{
"eventId": "68b3f0a2c1d4e5f6a7b8c9d0:message:1024",
"type": "message",
"id": "68b3f0a2c1d4e5f6a7b8c9d0",
"generatedId": "123456789",
"data": {
"chatType": "private",
"chatId": "111222333",
"messageId": "1024",
"fromMe": false,
"user": {
"id": "111222333",
"name": "Budi",
"username": "budi",
"phone": "",
"isBot": false,
"isContact": true,
"picture": "https://api-zate.azickri.com/media/68b3f0a2c1d4e5f6a7b8c9d0/111222333/5123456789012345678/profile/c2inRmxRIRLbgqB1x_4xXh.jpg"
},
"type": "text",
"text": "Hi, 20 chicken skewers please",
"replyToMessageId": "",
"created": "2026-10-12T12:00:00.000Z"
},
"createdAt": "2026-10-12T12:00:00.120Z"
}Event Contents
Every event has the same envelope:
| Field | Type | Description |
|---|---|---|
eventId | string | Event identifier. The same event always has the same eventId, so use it to skip duplicates. |
type | string | Event type, see the table below. |
id | string | The Zate session id, the same as the id header. Handy when one endpoint receives events from several sessions. |
generatedId | string | Telegram user ID of the logged-in account. |
data | object | Event contents, shaped by type. |
createdAt | string | When Zate created this event (ISO 8601). |
type | When it's sent | data |
|---|---|---|
message | A message is received, or your account sends one from another device | Message object |
message.edit | A message is edited. A reaction (emoji) is not an edit, so it isn't sent | Message object plus edited |
message.delete | Messages are deleted | { messageIds } |
message.read | Messages are read | { chatType, chatId, messageId, byMe } |
Message Object
data for message and message.edit:
| Field | Type | Description |
|---|---|---|
chatType | string | Always private. |
chatId | string | User ID of the other person. Use it as user in Send Message to reply. |
messageId | string | Message ID. |
fromMe | boolean | true if your own account sent the message from another device (phone, Telegram Desktop). |
user | object | The other person, see the user object. Always the other person, even when fromMe: true. |
type | string | text, image, video, audio, document, sticker, location, contact, poll, or unsupported. |
text | string | Message text, or the caption for media. |
replyToMessageId | string | ID of the message being replied to. Empty if it's not a reply. |
image | object | When type = image: url, thumbnail, mimetype. |
video | object | When type = video: url, thumbnail, duration (seconds), mimetype. |
audio | object | When type = audio: url, duration (seconds), mimetype, isVoice (true for voice notes). |
document | object | When type = document: url, name, size (bytes), mimetype. |
sticker | object | When type = sticker: url, mimetype (image/webp). |
location | object | When type = location: latitude, longitude, name, address. |
contact | object | When type = contact: name, phone. |
polling | object | When type = poll: name (the question) and options (the choices). |
created | string | When the message was sent (ISO 8601). |
edited | string | Only in message.edit: when the message was edited. |
Every media url and thumbnail is a public media link. Nothing is downloaded until the link is opened. Animated stickers point to their thumbnail image so they show up in an <img> tag.
User Object
| Field | Type | Description |
|---|---|---|
id | string | Telegram user ID, the same as chatId. |
name | string | Display name. |
username | string | Username without @. Empty if they don't have one. |
phone | string | Phone number. Empty unless they share it with your account. |
isBot | boolean | true if the other side is a bot. |
isContact | boolean | true if they're in your contacts. |
picture | string | Public link to their small profile photo. Empty if they have none or their privacy hides it. A new photo gets a new link. |
Other Events
json
{
"eventId": "68b3f0a2c1d4e5f6a7b8c9d0:message.edit:1024:1791806460",
"type": "message.edit",
"id": "68b3f0a2c1d4e5f6a7b8c9d0",
"generatedId": "123456789",
"data": {
"chatType": "private",
"chatId": "111222333",
"messageId": "1024",
"fromMe": false,
"user": { "id": "111222333", "name": "Budi", "username": "budi", "phone": "", "isBot": false, "isContact": true, "picture": "" },
"type": "text",
"text": "Hi, 30 chicken skewers please",
"replyToMessageId": "",
"created": "2026-10-12T12:00:00.000Z",
"edited": "2026-10-12T12:01:00.000Z"
},
"createdAt": "2026-10-12T12:01:00.090Z"
}json
{
"eventId": "68b3f0a2c1d4e5f6a7b8c9d0:message.delete:5821",
"type": "message.delete",
"id": "68b3f0a2c1d4e5f6a7b8c9d0",
"generatedId": "123456789",
"data": {
"messageIds": ["1024", "1025"]
},
"createdAt": "2026-10-12T12:02:00.050Z"
}json
{
"eventId": "68b3f0a2c1d4e5f6a7b8c9d0:message.read:out:111222333:1026",
"type": "message.read",
"id": "68b3f0a2c1d4e5f6a7b8c9d0",
"generatedId": "123456789",
"data": {
"chatType": "private",
"chatId": "111222333",
"messageId": "1026",
"byMe": false
},
"createdAt": "2026-10-12T12:03:00.070Z"
}message.deletedoesn't name the chat, because Telegram doesn't say which chat for private messages. Don't worry: private message IDs are unique per account. Deletions in basic groups sometimes come through too, so you can ignore IDs you don't recognize.message.readmeans every message up tomessageIdhas been read.byMe: truemeans your account read that person's messages,byMe: falsemeans that person read your messages.
Auto-Reply
chatId can be used directly as user to reply:
js
async function reply(chatId, text) {
await fetch('https://api-zate.azickri.com/message', {
method: 'POST',
headers: {
'id': process.env.ZATE_ID,
'session-id': process.env.ZATE_SESSION_ID,
'Content-Type': 'application/json',
},
body: JSON.stringify({ user: chatId, type: 'text', text }),
});
}
// Inside the webhook handler
if (event.type === 'message' && !event.data.fromMe && !event.data.user.isBot) {
await reply(event.data.chatId, 'Got it, your order is on the grill! 🔥');
}Don't reply to yourself
Always check fromMe. Messages you send from your phone also arrive as message events with fromMe: true, and replying to those can make the conversation loop on its own. Messages sent through Send Message itself are not sent to the webhook, since the response already gives you the messageId.
Delivery and Retries
- Events are sent one at a time per account, in the order Zate received them. If order really matters, sort by
created. - A
2xxresponse counts as success. Zate waits at most 10 seconds for an answer. - Network errors, timeouts,
429, and5xxare retried after 2 seconds, 10 seconds, then 30 seconds. If it still fails, the event is dropped. - Other
4xxresponses (like401for a wrong token) are not retried, since sending again wouldn't change anything. - At most 1,000 events wait per account. Above that, the oldest one is dropped first.
- A retry or a catch-up can send the same event twice. Skip any
eventIdyou've already processed.
Missed Messages
Zate remembers the last update position of each account. Every time it reconnects, whether after a server restart or a dropped connection, Zate asks Telegram what it missed and catches your webhook up.
- A webhook that was just turned on starts from that moment. Earlier history isn't sent.
- Reset Session or a new login also starts over.
- After a long downtime, caught-up messages can be hours old. If you build an auto-reply, check
createdfirst so you don't answer stale messages.
What Isn't Sent
- Messages in groups and channels. The webhook is for private chats only.
- Saved Messages, your account chatting with itself.
- Service messages, like screenshot notifications or "joined Telegram".
- Emoji reactions.
- Messages sent through Send Message.