Integrations
Verify signatures and process incoming webhooks
Verify the raw body, prevent duplicate processing and put events in a durable queue before responding.
The receiver must verify requests before using event content. Separate HTTP receipt from slow work such as calling a CRM, replying to customers or updating spreadsheets.
Who can do this
The person managing the webhook receiver service handles this part. You need access to that service's secret configuration and deduplication store. The receiver does not need additional Telegram permissions just to verify signatures.
Before you start
Store the signing secret in the WEBHOOK_SECRET environment variable. The actual value has the form whsec_…. Do not put it in source code or logs.
Preserve the raw body. Do not parse JSON and stringify it again before verifying the signature: whitespace and byte order are part of the signed data.
Where to start
Open Settings → API documentation → Receive webhooks to view the receiver guide. The events section describes data structures; the receiver guide provides platform-specific examples.
The app has 9 recipes: Node.js Express, Hono for Node.js/Bun/Deno, Python FastAPI, Go, PHP, Cloudflare Worker, n8n, Make and Zapier.
Steps
- Read the raw body and the headers below.
- Check the timestamp.
- Verify the signature.
- Write the event to a durable queue with deduplication.
- Return a
2xxresponse. - Process the business logic in the background.
| Header | Meaning |
|---|---|
webhook-id |
Delivery ID; unchanged when retrying or redelivering that delivery |
webhook-timestamp |
Signing time in Unix seconds |
webhook-signature |
One or more signatures in the form v1,<base64> |
Remove the whsec_ prefix, then base64-decode the secret. Calculate:
base64(HMAC-SHA256(secret_bytes, webhook-id + "." + webhook-timestamp + "." + raw_body))Accept the request if one valid signature matches, using a constant-time comparison. The default tolerance is 300 seconds, or 5 minutes, in either direction. During key rotation, the receiver can accept a signature from any key that is still valid.
Node.js
This example uses Express and the standardwebhooks library. inbox is an adapter you provide that writes durably and enforces a unique constraint on the delivery ID.
import express from "express";
import { Webhook } from "standardwebhooks";
const app = express();
const verifier = new Webhook(process.env.WEBHOOK_SECRET);
app.post("/telegram-events", express.raw({ type: "*/*" }), async (req, res) => {
let event;
try {
event = verifier.verify(req.body.toString("utf8"), req.headers);
} catch {
return res.sendStatus(401);
}
try {
await inbox.acceptOnce({
deliveryId: req.get("webhook-id"),
eventId: event.id,
event,
});
return res.sendStatus(204);
} catch {
return res.sendStatus(503);
}
});Python
This example uses FastAPI and the standardwebhooks library. The inbox adapter has the same responsibilities as in the example above.
import os
from fastapi import FastAPI, Request, Response
from standardwebhooks.webhooks import Webhook
app = FastAPI()
verifier = Webhook(os.environ["WEBHOOK_SECRET"])
@app.post("/telegram-events")
async def receive(request: Request):
body = await request.body()
try:
event = verifier.verify(body, dict(request.headers))
except Exception:
return Response(status_code=401)
try:
await inbox.accept_once(
delivery_id=request.headers["webhook-id"],
event_id=event["id"],
event=event,
)
except Exception:
return Response(status_code=503)
return Response(status_code=204)Result
Requests with invalid signatures do not enter the queue. Valid requests are stored before you acknowledge successful receipt.
Check
Deduplicate by webhook-id for retries of the same delivery. Also prevent duplicate side effects by event.id: replay creates a new delivery but keeps the event ID.
Return 2xx promptly, within the sender's 10-second limit. Do not acknowledge success before writing durably.
If something goes wrong
Check the raw body, machine clock, secret and whether a proxy is changing the body. Do not promise processing in arrival order. If business logic requires ordering, compare event data with the current state.
Do it with the API or MCP
Read webhooks.receiver_guide through tools_run, then use webhooks.test or webhooks_test to test the receiving path.
See also webhook failures.