Skip to content

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:

FieldTypeDescription
eventIdstringEvent identifier. The same event always has the same eventId, so use it to skip duplicates.
typestringEvent type, see the table below.
idstringThe Zate session id, the same as the id header. Handy when one endpoint receives events from several sessions.
generatedIdstringTelegram user ID of the logged-in account.
dataobjectEvent contents, shaped by type.
createdAtstringWhen Zate created this event (ISO 8601).
typeWhen it's sentdata
messageA message is received, or your account sends one from another deviceMessage object
message.editA message is edited. A reaction (emoji) is not an edit, so it isn't sentMessage object plus edited
message.deleteMessages are deleted{ messageIds }
message.readMessages are read{ chatType, chatId, messageId, byMe }

Message Object ​

data for message and message.edit:

FieldTypeDescription
chatTypestringAlways private.
chatIdstringUser ID of the other person. Use it as user in Send Message to reply.
messageIdstringMessage ID.
fromMebooleantrue if your own account sent the message from another device (phone, Telegram Desktop).
userobjectThe other person, see the user object. Always the other person, even when fromMe: true.
typestringtext, image, video, audio, document, sticker, location, contact, poll, or unsupported.
textstringMessage text, or the caption for media.
replyToMessageIdstringID of the message being replied to. Empty if it's not a reply.
imageobjectWhen type = image: url, thumbnail, mimetype.
videoobjectWhen type = video: url, thumbnail, duration (seconds), mimetype.
audioobjectWhen type = audio: url, duration (seconds), mimetype, isVoice (true for voice notes).
documentobjectWhen type = document: url, name, size (bytes), mimetype.
stickerobjectWhen type = sticker: url, mimetype (image/webp).
locationobjectWhen type = location: latitude, longitude, name, address.
contactobjectWhen type = contact: name, phone.
pollingobjectWhen type = poll: name (the question) and options (the choices).
createdstringWhen the message was sent (ISO 8601).
editedstringOnly 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 ​

FieldTypeDescription
idstringTelegram user ID, the same as chatId.
namestringDisplay name.
usernamestringUsername without @. Empty if they don't have one.
phonestringPhone number. Empty unless they share it with your account.
isBotbooleantrue if the other side is a bot.
isContactbooleantrue if they're in your contacts.
picturestringPublic 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.delete doesn'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.read means every message up to messageId has been read. byMe: true means your account read that person's messages, byMe: false means 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 2xx response counts as success. Zate waits at most 10 seconds for an answer.
  • Network errors, timeouts, 429, and 5xx are retried after 2 seconds, 10 seconds, then 30 seconds. If it still fails, the event is dropped.
  • Other 4xx responses (like 401 for 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 eventId you'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 created first 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.