---
title: Process webhooks
description: 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:

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

```ts
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](/authentication), [capability matrix](/reference/capabilities), and [integration checklist](/agents/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                   |
| LinkedIn           | `answerLinkedInWebhookChallenge` returns `{"challengeCode","challengeResponse"}` as JSON | `X-LI-Signature: <hex>` over `hmacsha256=<body>`                                | App client secret                                    | None; LinkedIn does not sign a timestamp |

```ts
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                                                                   |
| --------- | -------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| Instagram | `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`                                                                 |
| LinkedIn  | 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 `unknown` with its `event_type` in `originalType` and no account IDs, so `acceptWebhook` quarantines it until you map it yourself.
- X sends `X-Twitter-Webhooks-Signature-OAuth2` when 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 `toleranceSeconds` to `verifyTikTokWebhook` or `webhookToleranceSeconds` to the adapter to change it.
- LinkedIn enables webhooks only for apps with an approved webhook use case, so the adapter declares `webhooks.verify` as `approval-dependent`. Organization social action notifications also need the Community Management API, the `rw_organization_admin` scope, 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 as `unknown` with the action in `originalType`. `occurredAt` is the latest `lastModifiedAt` in 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 `applicationId` to the check; pass `secretForApplication` to 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 `notificationId` to 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 declares `webhooks.verify` as `unsupported-by-platform`.

Sources, accessed 2026-09-24: [Meta Webhooks getting started](https://developers.facebook.com/docs/graph-api/webhooks/getting-started), [Instagram webhooks](https://developers.facebook.com/docs/instagram-platform/webhooks), [Threads webhooks](https://developers.facebook.com/docs/threads/webhooks), [X webhooks](https://docs.x.com/x-api/webhooks/introduction), [X webhooks quickstart](https://docs.x.com/x-api/webhooks/quickstart), [X Account Activity API](https://docs.x.com/x-api/account-activity/introduction), [YouTube push notifications](https://developers.google.com/youtube/v3/guides/push_notifications), [WebSub](https://www.w3.org/TR/websub/), [PubSubHubbub 0.4](https://pubsubhubbub.github.io/PubSubHubbub/pubsubhubbub-core-0.4.html), [TikTok webhook verification](https://developers.tiktok.com/doc/webhooks-verification), [TikTok webhooks overview](https://developers.tiktok.com/doc/webhooks-overview), [TikTok webhook events](https://developers.tiktok.com/doc/webhooks-events), [LinkedIn webhook validation](https://learn.microsoft.com/en-us/linkedin/shared/api-guide/webhook-validation), [LinkedIn organization social action notifications](https://learn.microsoft.com/en-us/linkedin/marketing/community-management/organizations/organization-social-action-notifications), and [Bluesky: consuming the firehose](https://bsky.network/docs/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.
