Process webhooks
Verify raw webhooks from managed backends and direct platforms, quarantine unmapped tenants, deduplicate events, and process them durably.
Verify before parsing. Zernio uses an X-Zernio-Signature HMAC-SHA256 digest over the untouched request bytes. Post for Me uses its Post-For-Me-Webhook-Secret shared-secret header; its verification does not authenticate the body. Both checks require the endpoint secret on the server and bound the body size.
@opencoredev/social-sdk/server exports one verifier per provider:
verifyZernioWebhook(input: {
secret: string;
headers: Headers;
body: Uint8Array;
maxBytes?: number; // defaults to 1 MiB
}): Promise<VerifiedWebhook>;
verifyPostForMeWebhook takes the same input. Pass a web Headers object and the raw body bytes. Fetch-based frameworks already have request.headers; in Node frameworks, use new Headers(req.headers as Record<string, string>). On success the verifier resolves to a VerifiedWebhook with valid: true. On any failure, including a missing secret, a bad signature, or an oversized body, it throws a SocialError with code unauthorized. The Zernio and Post for Me adapters expose the same check as adapter.webhooks.verify({ headers, body }, context), using the adapter’s webhookSecret.
After verification, decode the provider event, resolve every referenced account ID to application tenants, and atomically accept it into a durable inbox. Events with no common authorized tenant are quarantined. Use a provider event ID for Zernio; Post for Me falls back to an exact-body digest when the provider omits an event ID. Duplicate acceptance returns duplicate, while processing remains a separate recoverable worker step.
import { SocialError } from "@opencoredev/social-sdk";
import { acceptWebhook, decodeWebhook, verifyZernioWebhook } from "@opencoredev/social-sdk/server";
export async function receiveEvent(request: Request): Promise<Response> {
const body = new Uint8Array(await request.arrayBuffer());
try {
await verifyZernioWebhook({
secret: process.env.ZERNIO_WEBHOOK_SECRET!,
headers: request.headers,
body,
});
} catch (error) {
if (error instanceof SocialError && error.code === "unauthorized") {
return new Response("unauthorized", { status: 401 });
}
throw error;
}
const event = await decodeWebhook({ provider: "zernio", backend: backendName, body });
const result = await acceptWebhook({ event, endpointId, inbox, resolveTenants });
return new Response(result.state, { status: 202 });
}
Only the verify call sits inside the try, so a failure in decoding or in your inbox is not reported as an authentication error.
Persist the inbox state and the application effect together where possible. A worker may crash after acceptance; replay pending events and make the effect idempotent. Never trust a payload tenant field, assign an unmapped account to a default tenant, or log credentials, signatures, message content, or private account data. See the authentication guide, capability matrix, and integration checklist.
Verify direct platform webhooks
Instagram, Threads, X, YouTube, TikTok, and LinkedIn deliver their own webhooks when you use a direct adapter. @opencoredev/social-sdk/server exports a verifier for each signed POST and a helper for each GET handshake. Every verifier checks the untouched request bytes with Web Crypto, compares signatures in constant time, bounds the body at 1 MiB by default, and throws SocialError with code unauthorized on any failure. The handshake helpers throw unauthorized, or not_found for YouTube, with operation webhooks.challenge.
| Platform | Handshake | Signature header | Secret | Timestamp check |
|---|---|---|---|---|
| Instagram, Threads | answerMetaWebhookChallenge echoes hub.challenge after matching hub.verify_token |
X-Hub-Signature-256: sha256=<hex> |
Meta app secret | None; Meta does not sign a timestamp |
| X | answerXWebhookChallenge returns {"response_token":"sha256=<base64>"} for crc_token |
X-Twitter-Webhooks-Signature-OAuth2, or legacy X-Twitter-Webhooks-Signature |
OAuth 2.0 client secret or OAuth 1.0 consumer secret | None; X does not sign a timestamp |
| YouTube | answerYouTubeWebhookChallenge echoes hub.challenge for a topic you list |
X-Hub-Signature: sha1=<hex> (sha256, sha384, and sha512 are also accepted) |
The hub.secret you subscribed with |
None; WebSub defines no timestamp |
| TikTok | None | TikTok-Signature: t=<seconds>,s=<hex> over <t>.<body> |
App client secret | 300 seconds by default |
answerLinkedInWebhookChallenge returns {"challengeCode","challengeResponse"} as JSON |
X-LI-Signature: <hex> over hmacsha256=<body> |
App client secret | None; LinkedIn does not sign a timestamp |
import { SocialError } from "@opencoredev/social-sdk";
import {
acceptWebhook,
answerMetaWebhookChallenge,
decodePlatformWebhook,
verifyMetaWebhook,
} from "@opencoredev/social-sdk/server";
export async function instagramWebhook(request: Request): Promise<Response> {
const url = new URL(request.url);
try {
if (request.method === "GET") {
const answer = await answerMetaWebhookChallenge({
verifyToken: process.env.META_WEBHOOK_VERIFY_TOKEN!,
query: url.searchParams,
});
return new Response(answer.body, { status: answer.status, headers: answer.headers });
}
const body = new Uint8Array(await request.arrayBuffer());
await verifyMetaWebhook({
secret: process.env.META_APP_SECRET!,
headers: request.headers,
body,
});
const event = await decodePlatformWebhook({ platform: "instagram", backend: "default", body });
const result = await acceptWebhook({ event, endpointId, inbox, resolveTenants });
return new Response(result.state, { status: 200 });
} catch (error) {
if (error instanceof SocialError && error.code === "unauthorized") {
return new Response("unauthorized", { status: 401 });
}
throw error;
}
}
The direct adapters expose the same POST check as adapter.webhooks.verify({ headers, body }, context) when you pass webhookSecret, and adapter.webhooks.decode returns the decoded event. Without webhookSecret, verification always fails. The handshakes are not part of the adapter interface because they need the query string, so call the helpers directly.
decodePlatformWebhook turns one verified delivery into one SocialEvent. None of these platforms sends a delivery ID (LinkedIn’s notificationId identifies a notification, not a delivery), so identity is body-digest and id is the SHA-256 of the body. Meta can batch many updates into one delivery. The event keeps all of them in data, and type is specific only when every update maps to the same type; otherwise it is unknown and originalType lists the source types. Keys that look like credentials are redacted.
A body without the platform’s documented event container throws invalid_input with operation webhooks.decode; the error never includes the payload. Instagram needs object: "instagram" and a non-empty entry array. Threads needs a values object with a field and a value object; it has no object or entry wrapper. X needs at least one *_events array, a user_event object, or an X Activity API data object with an event_type. TikTok needs an event, and when content is present it must be a string holding serialized JSON for an object. A well-formed delivery whose event type the SDK does not map still decodes, as an unknown event with the source type in originalType, so new platform event types do not break your handler.
| Platform | Mapped types | Account IDs |
|---|---|---|
comments and live_comments to comment.received; incoming messages to message.received |
entry[].id |
|
| Threads | replies to comment.received; delete to post.removed; mentions and publish stay unknown |
Root post owner for replies, post owner for deletes, target_id for mentions |
| X | direct_message_events to message.received; tweet_delete_events to post.removed; user_event.revoke to account.updated |
for_user_id, or the revoking user |
| YouTube | New and updated videos stay unknown with data.videos; an Atom tombstone maps to post.removed |
yt:channelId |
| TikTok | post.publish.* and the older video.* events to publication.updated; authorization.removed to account.updated |
user_openid |
Organization social action COMMENT to comment.received; other actions and other event types stay unknown |
organizationalEntity |
For TikTok post.publish.* events, backendRecordId is the publish_id, so you can match the event to the delivery reference returned by posts.publish. occurredAt comes from create_time. Events that stay unknown still carry their payload in data; route them by originalType.
Platform behavior to plan for:
- Meta retries failed deliveries for up to 36 hours and TikTok for up to 72 hours. Both can deliver the same update twice, so keep the inbox deduplication.
- Meta’s Threads documentation does not say whether Threads signs with the Threads app secret or the Meta app secret. Configure the secret your app dashboard shows for Threads webhooks and test it with a real delivery before relying on it.
- YouTube only signs deliveries when you subscribe with
hub.secret. Without a secret there is nothing to verify, so treat the notification as a hint and read the video through the API. WebSub asks subscribers to answer a signature mismatch with a 2xx status and drop the delivery, so return 200 after a failed YouTube check instead of 401. YouTube’s guide does not document deletion notices; the decoder handles a standard Atom tombstone if the hub sends one. - X is replacing the Account Activity API with X Activity API. Deliveries use the same signature. An X Activity delivery decodes as
unknownwith itsevent_typeinoriginalTypeand no account IDs, soacceptWebhookquarantines it until you map it yourself. - X sends
X-Twitter-Webhooks-Signature-OAuth2when your app has an OAuth 2.0 client secret and may send both headers during migration. When the OAuth 2.0 header is present it alone decides, and the legacy header is ignored, so pass the OAuth 2.0 client secret once your app has one. - TikTok does not publish a replay window. The SDK rejects signatures more than 300 seconds from the current time; pass
toleranceSecondstoverifyTikTokWebhookorwebhookToleranceSecondsto the adapter to change it. - LinkedIn enables webhooks only for apps with an approved webhook use case, so the adapter declares
webhooks.verifyasapproval-dependent. Organization social action notifications also need the Community Management API, therw_organization_adminscope, and an organization administrator, and they fire for likes, comments, and shares only on public posts.ADMIN_COMMENT(the page’s own comment), edits, deletions, likes, shares, and mentions decode asunknownwith the action inoriginalType.occurredAtis the latestlastModifiedAtin the batch. - LinkedIn re-validates the endpoint every 2 hours and blocks it after three failed checks, so keep the GET handler fast; LinkedIn allows 3 seconds. For parent-child applications LinkedIn adds
applicationIdto the check; passsecretForApplicationto answer with that application’s client secret. LinkedIn does not document which secret signs POST deliveries for child applications. - LinkedIn retries failed deliveries every 5 minutes for 8 hours and keeps missed organization notifications for 60 days through the organization notifications API. It sends the same
notificationIdto each subscribed member, so one action can arrive more than once. - Bluesky has no webhooks. Events arrive over WebSocket streams you subscribe to, the relay firehose (
com.atproto.sync.subscribeRepos) or Jetstream, so the adapter declareswebhooks.verifyasunsupported-by-platform.
Sources, accessed 2026-09-24: Meta Webhooks getting started, Instagram webhooks, Threads webhooks, X webhooks, X webhooks quickstart, X Account Activity API, YouTube push notifications, WebSub, PubSubHubbub 0.4, TikTok webhook verification, TikTok webhooks overview, TikTok webhook events, LinkedIn webhook validation, LinkedIn organization social action notifications, and Bluesky: consuming the firehose.
Run the example worker
The example accepts signed Zernio and shared-secret Post for Me events at POST /api/events. Configure ZERNIO_WEBHOOK_SECRET or POST_FOR_ME_WEBHOOK_SECRET with the matching backend. The handler resolves the normalized backendRecordId against its durable delivery index, checks every event account against the session’s membership, and quarantines events without one matching saved publication. It ignores tenant fields in provider payloads.
Invoke POST /api/events/process explicitly to process at most 100 pending events. The worker reads current delivery status and commits its projection with inbox completion in one Postgres transaction. No background polling starts when a webhook arrives. POST /api/events/replay reports pending work without repeating completed effects.
post.removed reports native removal; backend-record.deleted reports provider-record deletion. They are distinct from the historical publishing outcome. The example keeps removal reports separately and exposes them through POST /api/events/reports with the publication’s idempotencyKey. Zernio warns that its listing-based deletion detection can produce false positives and does not retract those notices. Verify current native state before treating a report as permanent deletion. Older processing snapshots cannot overwrite a saved terminal publication.