---
title: "Verify signatures and process incoming webhooks"
locale: en
url: https://tlg.builera.app/en/docs/integrations/webhook-receivers
verified: 2026-10-24
app_version: "2.0"
---

# 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

1. Read the raw body and the headers below.
2. Check the timestamp.
3. Verify the signature.
4. Write the event to a durable queue with deduplication.
5. Return a `2xx` response.
6. 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:

```text
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.

```javascript
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.

```python
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](https://tlg.builera.app/en/docs/reference/rest/webhooks.receiver_guide) through `tools_run`, then use [webhooks.test](https://tlg.builera.app/en/docs/reference/rest/webhooks.test) or `webhooks_test` to test the receiving path.

See also [webhook failures](https://tlg.builera.app/en/docs/troubleshooting/webhook-failures).

