Event webhook¶
The event webhook sends an HTTP request to another service each time the real-time listener applies an edit or a deletion to the archive.
What fires¶
There are two events:
| Event | Fires when |
|---|---|
message_edited |
The listener applied an edit and the message text changed. |
message_deleted |
The listener processed a deletion for a message that is in the archive and not already marked deleted. Each archived message fires at most once. |
The webhook never fires for:
- new messages, reactions, pins or chat actions such as joins and title changes
- edits and deletions that the listener skipped, or that its mass-operation protection blocked because too many arrived at once
- an edit that only changes formatting
- changes found by the scheduled
SYNC_DELETIONS_EDITSsweep
Prerequisites¶
The webhook runs inside the real-time listener, so it needs:
ENABLE_LISTENER=trueLISTEN_EDITS=trueformessage_edited. This is the default.LISTEN_DELETIONS=trueformessage_deleted. This is off by default.
When a prerequisite is missing, startup logs the matching warning or warnings and the backup keeps running. The ENABLE_LISTENER=false warning is logged on its own. Otherwise startup logs one line for each selected event whose LISTEN_* flag is off. These warnings appear only when the webhook settings themselves are valid.
EVENT_WEBHOOK_ENABLED has no effect: ENABLE_LISTENER=false
message_deleted webhooks will never fire: LISTEN_DELETIONS=false
message_edited webhooks will never fire: LISTEN_EDITS=false
Message content leaves the archive
The request body can carry message text, chat titles and sender names to the target. Point the webhook only at a service you trust. The backup never logs the URL, the headers or the body, so a token in the URL or in a header stays out of the logs.
Settings¶
Set these in the backup service's environment, for example in .env.
| Variable | Default | What it does |
|---|---|---|
EVENT_WEBHOOK_ENABLED |
false |
Turns the webhook on. Accepts 1/true/yes/on and 0/false/no/off. Any other value stops startup with an error. |
EVENT_WEBHOOK_URL |
empty | Target URL. Required. Must be http:// or https:// with a hostname. With http:// the message text and your headers, tokens included, travel in cleartext. Use it only on a trusted, isolated network, and https:// everywhere else. |
EVENT_WEBHOOK_METHOD |
POST |
POST or PUT, in any case. |
EVENT_WEBHOOK_HEADERS |
empty | A JSON object whose values are all strings, sent unchanged on every request. When the object has no Content-Type, the backup adds Content-Type: application/json; charset=utf-8. |
EVENT_WEBHOOK_EVENTS |
message_edited,message_deleted |
Which events fire, comma-separated, in any case. |
EVENT_WEBHOOK_CHAT_IDS |
empty | Comma-separated chat ids to fire for. Empty means every chat the listener processes. |
EVENT_WEBHOOK_BODY_TEMPLATE |
empty | Request body with placeholders. Empty means the default body. |
EVENT_WEBHOOK_CHAT_IDS does not add the -100 prefix for you, unlike the backup's chat filters. Write each id the way the archive stores it. A channel or supergroup id starts with -100, for example -1001234567890.
The URL and the headers are static. Only the body is templated.
A bad setting turns the webhook off quietly
Only an invalid EVENT_WEBHOOK_ENABLED stops startup. A bad URL, method, headers, events or chat id list logs one warning that names the variable and ends with event webhook disabled. The backup keeps running without the webhook. Check the logs after every change to an EVENT_WEBHOOK_* value.
When the configuration is valid, startup logs the method and the selected events, for example:
When EVENT_WEBHOOK_CHAT_IDS is set, the line ends with , restricted to N chat(s), where N is the number of ids parsed.
Placeholders¶
| Placeholder | message_edited |
message_deleted |
|---|---|---|
event |
message_edited |
message_deleted |
account_id |
Archive account id | Archive account id |
chat_id |
Chat id as the archive stores it | Chat id as the archive stores it |
chat_title |
Chat title from the archive | Chat title from the archive |
message_id |
Telegram message id | Telegram message id |
sender_id |
Sender's Telegram id | Sender's Telegram id |
sender_name |
Sender name from the archive | Sender name from the archive |
date |
Telegram's edit time, with an offset, such as 2026-09-28T10:00:00+00:00. Blank when the edit event carries no edit time, which the archive accepts only for a message that was never edited before. |
Time the deletion was observed, in UTC with no offset and with microseconds, such as 2026-09-28T10:00:00.400369 |
text |
The new text | The archived text of the deleted message |
old_text |
The previous archived text | Blank |
new_text |
The new text | Blank |
media_type |
Media type of the edited message | Archived media type |
chat_title always comes from the archived chat. For a private chat it is First Last (@username) when a name is stored, the bare username when only the username is stored, and blank when the archive holds neither. A chat the archive does not hold also renders blank.
Template syntax¶
- A placeholder is
{name}or{name|filter}. - Every other brace passes through unchanged, so a JSON template keeps its own braces.
- Substitution runs in one pass. A value that contains
{event}stays as the literal text{event}. - A missing value renders as an empty string. So does an unknown placeholder name.
- Dates render as ISO-8601. A deletion date carries microseconds and an edit date does not, so a receiver that parses
dateshould accept both forms.
The filters are:
| Filter | Effect |
|---|---|
jsonescape |
Escapes the value for use inside a JSON string. Adds no quotes. UTF-8 and emoji stay as they are. |
urlencode |
Percent-encodes every character that is not a letter, a digit or one of _.-~. |
raw |
Inserts the value unchanged. |
A placeholder without a filter, or with an unknown filter, gets the automatic filter. It follows the Content-Type header:
| Content-Type | Automatic filter |
|---|---|
application/json or any +json type |
jsonescape |
application/x-www-form-urlencoded |
urlencode |
| anything else | raw |
jsonescape adds no quotes, so write them yourself around string values: "message":"{text}", not "message":{text}. With raw, keeping the body valid is up to you.
Default body¶
With EVENT_WEBHOOK_BODY_TEMPLATE empty, the body is this JSON:
{"event":"{event}","account_id":{account_id},"chat_id":{chat_id},"chat_title":"{chat_title}","message_id":{message_id},"sender_id":"{sender_id}","sender_name":"{sender_name}","date":"{date}","text":"{text}","old_text":"{old_text}","new_text":"{new_text}","media_type":"{media_type}"}
account_id, chat_id and message_id are JSON numbers. sender_id is a string. A field with no value renders as "".
A deletion rendered with the default body looks like this. The values are invented:
{"event":"message_deleted","account_id":1,"chat_id":-1001234567890,"chat_title":"Team \"chat\"","message_id":42,"sender_id":"777","sender_name":"Sender A","date":"2026-09-28T10:00:00.400369","text":"hello\nworld {event}","old_text":"","new_text":"","media_type":""}
The jsonescape filter escaped the quotes in the title and the line break in the text. The {event} inside the message text stayed as literal text.
Recipes¶
Each recipe goes in .env next to the listener settings. Wrap any value that contains JSON in single quotes, so the .env parser keeps it exactly as written. Every recipe assumes the prerequisites:
Generic JSON receiver¶
Leave the template empty to get the default body.
ntfy, JSON body to the server root¶
The topic is part of an ntfy URL, and the URL cannot change per event. Publishing JSON to the server root lets the body set the topic and a title that names the event and the chat.
EVENT_WEBHOOK_URL=https://ntfy.example.com
EVENT_WEBHOOK_BODY_TEMPLATE='{"topic":"archive-events","title":"{event} in {chat_title}","message":"{text}"}'
ntfy, plain text to a topic URL¶
The text/plain type selects the raw filter, so the text goes out as it is.
EVENT_WEBHOOK_URL=https://ntfy.example.com/archive-events
EVENT_WEBHOOK_HEADERS='{"Content-Type":"text/plain; charset=utf-8","Title":"Telegram archive"}'
EVENT_WEBHOOK_BODY_TEMPLATE={event} in {chat_title}: {text}
ntfy with an access token¶
Add the token as a header to either ntfy recipe. EVENT_WEBHOOK_HEADERS holds every header in one JSON object. With the plain-text recipe, add Authorization to that recipe's object. On its own, the backup adds the JSON Content-Type for you.
Form-encoded receiver¶
The form type selects the urlencode filter for every placeholder.
EVENT_WEBHOOK_URL=https://hooks.example.com/telegram
EVENT_WEBHOOK_HEADERS='{"Content-Type":"application/x-www-form-urlencoded"}'
EVENT_WEBHOOK_BODY_TEMPLATE=event={event}&chat={chat_title}&text={text}
Delivery¶
The listener hands each request to a background task and never waits for it.
- Each attempt has a 5-second timeout.
- There are up to 3 attempts. The second waits 1 second and the third waits 4 seconds.
- The backup retries three kinds of failure: transport errors, such as a timeout or a refused connection, HTTP 429, and any 5xx response.
- Any status below 300 counts as success.
- A 3xx or any other 4xx response is a permanent failure. Redirects are never followed, so set
EVENT_WEBHOOK_URLto the final URL. - When 100 deliveries are already in flight, new events are dropped.
- Nothing is stored for redelivery. A failed or dropped event is gone, and the archive itself is unaffected.
- Shutdown cancels any delivery still in flight.
After the last attempt fails, the log shows one line with the event and the HTTP status or the error type:
An unexpected error inside the delivery task, one that is not an HTTP failure or a transport error, logs this line instead and also counts as a failure:
The listener keeps sent, failed and dropped counters in memory only. They are never logged or exposed. At the default log level the failure line is the only record. With LOG_LEVEL=DEBUG, each delivery also logs Event webhook delivered (<event>) and each drop logs Event webhook dropped: <n> deliveries already in flight. See Monitoring and troubleshooting for reading the logs.