Receiving webhooks with the SDK
A webhook endpoint does three things: it verifies the request is genuine, returns 200 quickly and queues the heavy work. Meta and TikTok sign the body with HMAC-SHA256, so the signature is checked against the raw bytes; Telegram does not sign the body and only sends an X-Telegram-Bot-Api-Secret-Token header. The socialift.webhooks module has ready-made functions for all three.
Updated:
In short: what should a webhook endpoint do?
For Meta: on GET, return hub.challenge as plain text via verify_challenge(); on POST, read the raw body, check X-Hub-Signature-256 with verify_request(), split it into events with parse_events(), queue them and return 200. For TikTok use verify_tiktok_request() and parse_tiktok_event(); for a Telegram bot, verify_telegram_request(). Filter repeated deliveries with dedupe_key() or event.key.
This page is a technical how-to. For an overview of Socialift's webhook capabilities, see the Webhook integration page.
How do the platforms sign webhooks?
| Platform | Header | Verification | Redelivery | SDK functions |
|---|---|---|---|---|
| Meta (Facebook, Instagram) | X-Hub-Signature-256: sha256=<hex> | HMAC-SHA256: key is the App Secret, message is the raw body | Immediately, then with decreasing frequency over 36 hours | verify_challenge, verify_request, parse_events, dedupe_key |
| TikTok | Tiktok-Signature: t=<unix>,s=<hex> | HMAC-SHA256: key is the Client Secret, message is <t>.<raw body> | Up to 72 hours with exponential backoff | verify_tiktok_request, parse_tiktok_event |
| Telegram Bot API | X-Telegram-Bot-Api-Secret-Token | No signature: compare with the secret_token given to setWebhook | Repeats on non-2XX responses and gives up after a reasonable number of attempts | verify_telegram_request |
Redelivery rules come from the platforms' documentation and may change — sources are listed at the end of the page.
FastAPI: a Meta webhook — verification and events
import asyncio
import json
import os
from fastapi import FastAPI, Request, Response
from socialift.webhooks import parse_events, verify_challenge, verify_request
app = FastAPI()
events: asyncio.Queue = asyncio.Queue() # in production: Redis, RQ, Celery, etc.
@app.get("/webhooks/meta")
async def meta_verify(request: Request):
challenge = verify_challenge(dict(request.query_params), os.environ["META_VERIFY_TOKEN"])
if challenge is None:
return Response(status_code=403)
return Response(challenge, media_type="text/plain") # the bare string, not JSON
@app.post("/webhooks/meta")
async def meta_receive(request: Request):
body = await request.body() # raw bytes
if not verify_request(os.environ["FACEBOOK_APP_SECRET"], body, request.headers):
return Response(status_code=403)
for event in parse_events(json.loads(body)):
events.put_nowait(event)
return Response(status_code=200)verify_challenge() returns hub.challenge when hub.mode=subscribe and hub.verify_token match, otherwise None. The response must be exactly the challenge string.
verify_request() needs the raw bytes before your framework parses the body — re-serialised JSON breaks the signature. A missing or empty header yields False, and the comparison uses hmac.compare_digest.
request.headers is case-insensitive. If you pass a plain dict, the key must be X-Hub-Signature-256 or x-hub-signature-256.
Each Page is subscribed separately: await fb.subscribe_page(page_id, page_token, fields=['leadgen', 'feed']). Meta requires the pages_manage_metadata and pages_show_list permissions for this; without the subscription no events arrive, even with the webhook configured at app level.
WebhookEvent: what does parse_events() return?
Meta sends two formats: changes (leadgen, feed, comments) and messaging (for example, Instagram Direct messages). parse_events() turns both into a single stream of WebhookEvent objects.
object_type- The payload's
object, for examplepageorinstagram. field- The
fieldvalue forchanges(leadgen,comments,feed, …);messagesformessagingentries andmessages_standbyforstandby. object_idandtimeentry.id(usually a Page or Instagram account ID) andentry.time(unix).value- The event itself: a
changes[].valueor amessaging[]item. leadgen_id,form_id,page_id- Convenience properties for Lead Ads events; when
valuehas nopage_id,object_idis returned. key- A stable key for catching repeated deliveries — the same as
dedupe_key(event).
FastAPI: a TikTok webhook
import json
import os
from fastapi import FastAPI, Request, Response
from socialift.webhooks import parse_tiktok_event, verify_tiktok_request
app = FastAPI()
@app.post("/webhooks/tiktok")
async def tiktok_receive(request: Request):
body = await request.body()
if not verify_tiktok_request(os.environ["TIKTOK_CLIENT_SECRET"], body, request.headers):
return Response(status_code=403)
event = parse_tiktok_event(json.loads(body))
if event.event == "authorization.removed":
print("Access revoked:", event.user_openid) # delete this user's tokens
return Response(status_code=200)TikTok sends one event per request: event.event is the event name, event.user_openid the user and event.create_time a unix timestamp. TikTok delivers content as a JSON string; the SDK parses it into the event.content dict and, if the string is malformed, keeps it as {'raw': ...}.
tolerance (300 seconds by default): if the timestamp differs from the current time by more than that, the request is rejected, which protects against replays of old requests. Keep the server clock in sync; tolerance=0 disables the check and is not recommended.
TikTok's documentation spells the header Tiktok-Signature. request.headers handles that; with a plain dict the SDK looks for the TikTok-Signature or tiktok-signature key.
event.key is a dedupe key based on publish_id, video_id or comment_id, or on a hash of the body when those are absent.
FastAPI: a Telegram bot webhook
import os
from fastapi import FastAPI, Request, Response
from socialift import Telegram
from socialift.webhooks import verify_telegram_request
app = FastAPI()
SECRET = os.environ["TELEGRAM_WEBHOOK_SECRET"] # 1–256 chars: A-Z, a-z, 0-9, _ and -
async def register_webhook(): # once, at deploy time
async with Telegram.from_env() as bot:
await bot.set_webhook("https://example.uz/webhooks/telegram",
secret_token=SECRET, allowed_updates=["message"])
@app.post("/webhooks/telegram")
async def telegram_receive(request: Request):
if not verify_telegram_request(request.headers, SECRET):
return Response(status_code=403)
update = await request.json()
print(update.get("message", {}).get("text")) # put it on a queue
return Response(status_code=200)The argument order differs from Meta: verify_telegram_request(headers, secret_token). With an empty secret_token the function always returns False, so a webhook never stays open without a secret.
Telegram does not sign the body, so the secret is the only protection: without it anyone who knows the URL can post a fake update. The comparison runs in constant time (compare_digest).
Once a webhook is set, get_updates() stops working. If updates do not arrive, check last_error_message and pending_update_count in await bot.get_webhook_info().
Why return 200 quickly and queue the work?
Platforms retry deliveries. Meta retries a failed delivery immediately and then with decreasing frequency over the next 36 hours. TikTok retries for up to 72 hours with exponential backoff and states plainly that the same event may arrive more than once. Telegram repeats the request after any non-2XX response.
Writing to a CRM, downloading files or calling a slow API inside the handler delays the response; if the platform treats the delivery as failed, it sends the event again and duplicates multiply. The right order is: verify the signature → write the event to a queue (Redis, RQ, Celery or a database table) → return 200 immediately. A separate worker does the processing.
Meta may batch several events (up to 1,000) into one request but does not guarantee it — handle each event individually.
Idempotency: process each event exactly once
import asyncio
from socialift.webhooks import WebhookEvent, dedupe_key
seen: set[str] = set() # in production: a UNIQUE column in the DB or Redis SET NX
async def worker(events: asyncio.Queue):
while True:
event: WebhookEvent = await events.get()
key = dedupe_key(event) # same as event.key
if key not in seen:
seen.add(key)
if event.field == "leadgen" and event.leadgen_id:
print("New lead:", event.leadgen_id, "form:", event.form_id)
events.task_done()Meta provides no event ID, so dedupe_key() builds the key from the content: from leadgen_id, mid, comment_id, media_id or post_id when present, otherwise from a hash of value plus object_id and the time. Meta resends exactly the same body, so the fallback catches duplicates too.
An in-memory set is wiped on restart and is not shared between workers. In production write the key to a table with a UNIQUE column (INSERT ... ON CONFLICT DO NOTHING) and process the event only if the insert succeeded.
Production checklist
- Expose the endpoint over HTTPS only — TikTok requires HTTPS for the callback URL.
- Verify the signature on the raw bytes before parsing; answer invalid signatures with 403 and log them.
- Keep the App Secret, Client Secret, verify token and Telegram secret in environment variables.
- Return 200 immediately and queue the processing.
- Store the dedupe key in a UNIQUE database column.
- On Meta, subscribe every Page with
subscribe_page(). - Meta's data deletion callback arrives with a
signed_requestin the POST body, not withX-Hub-Signature-256— decode it withsocialift.crypto.parse_signed_request(signed_request, app_secret)and answer 400 when it returnsNone.
Webhook URLs in the Socialift dashboard
If you connect accounts through the Socialift dashboard and add your own Meta or TikTok app under “Kalitlar” (Keys), the dashboard shows a dedicated Webhook URL and verify token for that app once the key is saved — copy them into the platform console.
Every app gets its own URL because Meta and TikTok sign events with that specific app's secret. If you receive webhooks on your own server with the SDK, the examples on this page are all you need.
Frequently asked questions
My Meta webhook signature doesn't match — why?
There are usually three causes: the body is not raw (the framework parsed and rebuilt the JSON), the App Secret belongs to a different app, or a proxy rewrites the body. Pass the result of await request.body() and the secret of the app the webhook is configured in to verify_request().
Meta says “The URL couldn't be validated”. What should I do?
The GET response must be exactly the hub.challenge string — not JSON, but text/plain. Check that hub.verify_token matches the value in the console and that the endpoint is reachable over HTTPS. verify_challenge() returns None on a mismatch, in which case answer 403.
The TikTok signature is correct but verify_tiktok_request() returns False. Why?
Most often it is clock skew: if the server time differs from TikTok's timestamp by more than 300 seconds, the request is rejected. Sync the clock with NTP. Also make sure client_secret is not empty and pass the headers as request.headers.
How do I secure a Telegram webhook?
Pass a secret_token to set_webhook() and check every request with verify_telegram_request(request.headers, secret). Telegram echoes the string in the X-Telegram-Bot-Api-Secret-Token header. The secret is 1–256 characters long and may contain only A-Z, a-z, 0-9, _ and -.
The same event arrived twice — is that a bug?
No, it is expected: Meta, TikTok and Telegram all retry deliveries. Process each event once using dedupe_key() or event.key, and respond quickly.
Do webhook requests count in the Socialift “Statistika” (Usage) page?
No. Webhooks are called by the platforms, so they are not recorded against any key. The SDK helpers (socialift.webhooks) make no network calls at all — they run only on your server.