Skip to content
Social SDK megaphone markSocial SDK
Esc
↑↓navigate↵open⌘Jpreview
On this page

Express

Publish to social platforms from Express routes and verify webhooks against the raw request body with Social SDK.

Express applications construct one Social SDK client at startup and call it from route handlers. The one Express-specific requirement is webhook verification: it needs the untouched request bytes, so the events route must use the raw body parser.

Install

npm install @opencoredev/social-sdk
pnpm add @opencoredev/social-sdk
yarn add @opencoredev/social-sdk
bun add @opencoredev/social-sdk
nub add @opencoredev/social-sdk
aube add @opencoredev/social-sdk

Create the client once

import { createSocial } from "@opencoredev/social-sdk";
import { threads } from "@opencoredev/social-sdk/threads";

export const social = createSocial({
  backend: threads({
    auth: {
      userId: process.env.THREADS_USER_ID!,
      accessToken: process.env.THREADS_ACCESS_TOKEN!,
    },
  }),
});

Publish from a route

import { Router, json } from "express";
import { connectedAccountRef } from "@opencoredev/social-sdk";
import { social } from "../social.js";

export const publishRouter = Router();

publishRouter.post("/publish", json(), async (req, res) => {
  const tenant = await requireTenant(req); // your auth middleware result
  const accountId = await resolveAuthorizedAccount(tenant); // your lookup

  const result = await social.posts.publish({
    targets: [
      {
        account: connectedAccountRef({
          backend: "default",
          platform: "threads",
          accountId,
        }),
      },
    ],
    content: { text: req.body.text },
    idempotencyKey: req.body.draftId,
  });

  res.status(200).json({
    status: result.status,
    outcomes: result.outcomes.map((o) => ({ state: o.state })),
  });
});

Never take the account ID from the request body. Resolve it from your own tenant data so one tenant cannot publish through another’s account.

Reconcile uncertain outcomes

publishRouter.post("/reconcile", json(), async (req, res) => {
  const saved = await loadDelivery(req.body.deliveryId); // your storage
  const outcome = await social.posts.getDelivery(saved.ref);
  await saveDeliveryState(req.body.deliveryId, outcome.state);
  res.json({ state: outcome.state });
});

Webhooks need the raw body

express.json() consumes and reparses the body, which breaks signature verification. Use express.raw() on the events route only. It leaves req.body as a Buffer, which is a Uint8Array. The verifier expects a web Headers object, so convert Node’s header record:

import { Router, raw } from "express";
import { SocialError } from "@opencoredev/social-sdk";
import { verifyZernioWebhook } from "@opencoredev/social-sdk/server";

export const eventsRouter = Router();

eventsRouter.post("/events", raw({ type: "application/json", limit: "1mb" }), async (req, res) => {
  const body: Buffer = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);

  try {
    await verifyZernioWebhook({
      secret: process.env.ZERNIO_WEBHOOK_SECRET!,
      headers: new Headers(req.headers as Record<string, string>),
      body,
    });
  } catch (error) {
    if (error instanceof SocialError && error.code === "unauthorized") {
      res.status(401).send("unauthorized");
      return;
    }
    throw error;
  }

  // decode, resolve tenants, accept into your durable inbox
  res.status(202).send("accepted");
});

Register eventsRouter before any app-wide express.json() middleware. Otherwise the JSON parser consumes the body first and req.body is no longer a Buffer. Express 5 forwards the rethrown error to your error handler; on Express 4, wrap the handler or call next(error).

Follow the events guide for decoding, quarantine, and the inbox worker.

Notes

  • The SDK is ESM-only; run Express with "type": "module" on Node 22.12+ or Node 24.
  • Keep the client instance shared. Its per-backend concurrency limits work best with one instance per process.
  • Scheduled publishing belongs to a job runner such as Trigger.dev, not to an in-process timer that dies with the dyno.

Last updated on September 23, 2026