Webhooks
Webhooks: Real-Time Events From Tesselith
Updated
Get a signed HTTP request when posts publish or fail, queues change or channels disconnect. Verify Standard Webhooks signatures, handle retries, replay.
Webhooks let Tesselith tell your systems when something happens, instead of your code asking over and over. Add an HTTPS endpoint and choose the events you care about. Within seconds of a post publishing, failing or being scheduled, Tesselith sends your URL a small signed JSON request. Signatures follow the open Standard Webhooks specification, so you can verify them with an official library in your language instead of hand-rolling HMAC code. Webhooks are included on the Pro, Team and Agency plans.
Events
| Event | Sent when | data |
|---|---|---|
post.created | A post is created in the app, the API or by an AI assistant | post_id |
post.scheduled | A post is given a time, by a date or a queue slot | post_id, scheduled_at |
post.published | A post finished publishing; status is published or partially_published when some channels failed | post_id, status |
post.failed | Every channel failed to publish | post_id |
post.deleted | A post is deleted | post_id |
queue.updated | A channel's queue changed (schedule edited, posts reordered, queue paused or resumed) | channel_id |
channel.connected | A social account is connected | channel_id |
channel.disconnected | A social account is removed | channel_id |
channel.reconnect_required | A network revoked or expired the authorization; posts to it will fail until someone reconnects | channel_id |
Leave the event list empty to receive everything, including event types added later. Pick specific events if you'd rather opt in to each one.
What a request looks like
POST /hooks/tesselith HTTP/1.1
Host: example.com
Content-Type: application/json
User-Agent: Tesselith-Webhooks/1.0 (+https://tesselith.com)
webhook-id: msg_2T4sC9xPq8LmVb1Nz0aQe
webhook-timestamp: 1793361000
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
{"type":"post.published","timestamp":"2026-11-03T14:30:00.512Z","data":{"post_id":"V1StGXR8_Z5jdHi6B-myT","status":"published"}}Payloads are deliberately thin. They say what happened and which object it happened to, not the whole object. When you need the post's current state, fetch it from GET /v1/posts/{post_id}. Deliveries can arrive late or out of order after retries, and an id-only payload means you always act on current data rather than a snapshot from an hour ago.
Verifying signatures
Anyone can send a POST to a public URL, so check every request before trusting it. The signature is an HMAC-SHA256 over {webhook-id}.{webhook-timestamp}.{raw body}, keyed with your endpoint's secret. Verification libraries also reject timestamps more than five minutes off, which stops someone replaying an old request. Use the raw request body exactly as received: parsing the JSON and re-serializing it changes the bytes and breaks the signature.
Node.js (Express)
// npm install standardwebhooks express
import express from "express"
import { Webhook } from "standardwebhooks"
const wh = new Webhook(process.env.TESSELITH_WEBHOOK_SECRET!) // "whsec_…"
const app = express()
app.post(
"/hooks/tesselith",
express.raw({ type: "application/json" }), // keep the raw bytes
(req, res) => {
let event
try {
event = wh.verify(req.body.toString("utf8"), req.headers as Record<string, string>)
} catch {
return res.status(401).send("bad signature")
}
res.sendStatus(204) // acknowledge first…
void handle(event, req.header("webhook-id")!) // …then do the work
}
)Next.js route handler
// app/api/tesselith/route.ts
import { Webhook } from "standardwebhooks"
const wh = new Webhook(process.env.TESSELITH_WEBHOOK_SECRET!)
export async function POST(req: Request) {
const body = await req.text()
try {
const event = wh.verify(body, Object.fromEntries(req.headers)) as {
type: string
data: { post_id?: string; channel_id?: string; status?: string }
}
if (event.type === "post.failed") await alertTeam(event.data.post_id!)
} catch {
return new Response("bad signature", { status: 401 })
}
return new Response(null, { status: 204 })
}Python (Flask)
# pip install standardwebhooks flask
import os
from flask import Flask, request
from standardwebhooks import Webhook, WebhookVerificationError
wh = Webhook(os.environ["TESSELITH_WEBHOOK_SECRET"]) # "whsec_…"
app = Flask(__name__)
@app.post("/hooks/tesselith")
def tesselith():
try:
event = wh.verify(request.get_data(), dict(request.headers))
except WebhookVerificationError:
return "bad signature", 401
if event["type"] == "post.published":
mark_published(event["data"]["post_id"], event["data"]["status"])
return "", 204Official Standard Webhooks libraries also exist for Go, Ruby, PHP, Rust, Java, Kotlin and C#. They all take the same secret and the same three headers.
Verifying by hand
If you can't use a library: strip the whsec_ prefix and base64-decode the rest to get the key bytes. Compute HMAC-SHA256 over id + "." + timestamp + "." + body and base64-encode it. The webhook-signature header holds one or more space-separated v1,<signature> values, and the request is valid if any of them equals yours (compare in constant time). Reject timestamps more than 5 minutes from your clock.
Responding and retries
Any 2xx status within 15 seconds counts as delivered. Anything else is a failure: a 3xx, 4xx or 5xx status, a timeout, a refused connection or a TLS error. Redirects are not followed. Failures retry on this schedule, measured from the previous attempt:
| Attempt | Delay after the previous one |
|---|---|
| 1 | Immediately |
| 2 | 5 seconds |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 5 hours |
| 7 | 10 hours |
| 8 | 10 hours |
That's eight attempts over about 27 hours. After the last one the delivery is marked failed, and you can still resend it by hand from the delivery log. Because a delivery can be retried, the same event can reach you more than once. Store the webhook-id of each event you've processed and skip ones you've seen. Every retry and every manual resend uses the same id.
Automatic disabling
If an endpoint fails every scheduled delivery for five days in a row, Tesselith switches it off, marks its pending deliveries failed, and notifies and emails the workspace's owners and admins. A single success at any point resets the clock. Turn it back on from its settings once it's fixed. Test sends and manual resends never count toward disabling.
Secrets and rotation
Each endpoint has its own secret, shown when it's created and revealable later in its settings. To rotate, click Rotate secret. For the next 24 hours every request is signed with both the old and new secrets (two values in webhook-signature), so your handler keeps verifying while you deploy the new secret. After 24 hours only the new secret signs.
Testing and debugging
- Send test event delivers a signed
webhook.testrequest right away and shows the HTTP status and response inline. It's the fastest way to check your verification code. - The delivery log keeps every delivery for 30 days: its status (pending, succeeded, failed), each attempt's status code, duration and error, plus the request body and the first 4 KB of your response. Filter by event or status.
- Resend replays any delivery immediately with the same
webhook-id, so your deduplication still works. - For local development, expose your machine with a tunnel and use its public HTTPS URL. Tesselith refuses private and loopback addresses even at send time, so a plain
localhostURL won't work.
Managing endpoints with the API
Keys with the webhook:manage scope can manage endpoints in code. GET /v1/webhooks lists endpoints, POST /v1/webhooks creates one and returns its secret once, and DELETE /v1/webhooks/{id} removes one (its pending deliveries are dropped). A workspace can have up to 20 endpoints.
curl -s -X POST https://api.tesselith.com/v1/webhooks \
-H "Authorization: Bearer $TESSELITH_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/hooks/tesselith",
"description":"Slack alerts",
"event_types":["post.published","post.failed","channel.reconnect_required"]}'Recipes
- Alert on failures: forward
post.failedandchannel.reconnect_requiredto a Slack or Teams channel so nobody learns about a dead account from a missing post. - Close the loop in a CMS: when
post.publishedarrives for a post your CMS created, store the post id against the article and mark it as shared. - Keep a warehouse in sync: on
post.published, callGET /v1/analytics/posts/{post_id}a day later and load the numbers into your reporting. - Watch the queue: on
queue.updated, check that each channel still has posts lined up for the coming week and nudge the team if one runs dry.
Questions developers ask
- Why didn't my webhook arrive?
- Open the endpoint in Settings → Developers → Webhooks and check the delivery log. It shows every attempt with the status code or error. The common causes are a URL that isn't public HTTPS, a handler that takes longer than 15 seconds, or verification failing because the body was parsed before verifying.
- Are webhook events delivered in order?
- Usually, but not guaranteed. A retry can arrive after a later event. Use the payload's timestamp and fetch the object's current state from the API rather than relying on order.
- Which IP addresses do webhooks come from?
- Don't allowlist by IP. Verify the signature instead: it proves the request came from Tesselith and wasn't modified, wherever it was sent from.
- Can I receive webhooks for only one channel?
- Endpoints subscribe by event type for the whole workspace. Filter by channel in your handler, using the channel ids on the post you fetch.