Skip to content
Social SDK Cloud, the hosted SDK, is taking early-access signups.Join the list
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.

To post to social media from Express, create one Social SDK client when the app starts and call social.posts.publish from a route handler, with the draft ID as the idempotency key. The result holds one outcome per destination, so the route can return each post’s state. Webhook routes need express.raw() instead of express.json(), because signature checks read the untouched body. Publish from a route shows the code. For scheduled posts, have a durable job runner such as Trigger.dev or Inngest run the same publish call at the publish time.

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

An idempotencyKey only stops a retried request from posting twice when the client has an idempotencyStore. Give it a durable store that every process shares. The Threads adapter also needs a durable workflowStore, because it keeps each post’s progress there between calls. The Neon guide includes Postgres implementations of both.

import { createSocial } from "@opencoredev/social-sdk";
import { threads } from "@opencoredev/social-sdk/threads";
import { idempotencyStore, threadsWorkflowStore } from "./stores.js"; // your durable stores

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

This client holds one Threads account’s token, so it can only publish as THREADS_USER_ID. The adapter rejects a reference to any other account. To publish for several accounts, load each account’s credentials from your encrypted credential store and build a client for that account. See authentication.

Publish from a route

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

export const publishRouter = Router();

function parseBody(body: unknown): { text: string; draftId: string } | undefined {
  if (typeof body !== "object" || body === null) return undefined;
  if (!("text" in body) || !("draftId" in body)) return undefined;
  const { text, draftId } = body;
  if (typeof text !== "string" || text.length === 0) return undefined;
  if (typeof draftId !== "string" || draftId.length === 0) return undefined;
  return { text, draftId };
}

publishRouter.post("/publish", json(), async (req, res) => {
  const tenant = await requireTenant(req); // your auth middleware result
  const input = parseBody(req.body);
  if (!input) {
    res.status(400).send("Expected text and draftId");
    return;
  }
  const accountId = await resolveAuthorizedAccount(tenant); // your lookup

  const result = await social.posts.publish(
    {
      targets: [
        {
          account: connectedAccountRef({
            backend: "default",
            platform: "threads",
            accountId,
          }),
        },
      ],
      content: { text: input.text },
      idempotencyKey: input.draftId,
    },
    // Scopes the idempotency key to this tenant, so tenants can't collide on a draft ID.
    { authorization: { tenantId: tenant.id } },
  );

  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.

If a retry returns unknown, the first attempt may have posted. Stop there and check the account before you publish again, even under a new draft ID. See idempotency.

Reconcile processing outcomes

Save each outcome’s delivery reference with the publication, then read it again later. For Threads, getDelivery continues a workflow that is still processing. It never replays a workflow whose state is unknown; it reports unknown again, and that draft needs review.

publishRouter.post("/reconcile", json(), async (req, res) => {
  const tenant = await requireTenant(req);
  const saved = await loadDelivery(tenant.id, req.body.deliveryId); // your storage, scoped to the tenant
  if (!saved?.delivery) {
    res.status(404).send("No delivery reference");
    return;
  }
  if (!(await hasActiveAccountGrant(tenant.id, saved.delivery))) {
    res.status(403).send("Account grant revoked");
    return;
  }
  const outcome = await social.posts.getDelivery(saved.delivery, {
    authorization: { tenantId: tenant.id },
  });
  await saveDeliveryState(saved.id, outcome.state);
  res.json({ state: outcome.state });
});

hasActiveAccountGrant is your server-owned lookup. It returns true only for an active grant matching the tenant and the delivery reference’s exact backend, platform, and accountId. Check it on every reconciliation request. Owning the saved delivery does not prove current account access, and this client has no AuthorizationPolicy, so passing tenantId alone does not enforce a grant. See tenant authorization.

Webhooks need the raw body

This route receives Zernio webhooks, so it only applies when you publish through the Zernio managed backend. Threads sends its own webhooks to a direct integration: verify those with verifyMetaWebhook and answer the subscription check with answerMetaWebhookChallenge, as the events guide shows. The raw-body setup below is the same for both.

express.json() consumes and reparses the body, which breaks signature verification. Use express.raw() on the events route only. Its limit rejects a larger body before it is buffered, and 1mb matches the verifier’s default. It leaves req.body as a Buffer, which is a Uint8Array. The verifier expects a web Headers object, so copy Node’s header record into one, keeping repeated headers:

import type { IncomingHttpHeaders } from "node:http";
import { Router, raw } from "express";
import { SocialError } from "@opencoredev/social-sdk";
import { verifyZernioWebhook } from "@opencoredev/social-sdk/server";

export const eventsRouter = Router();

function toHeaders(source: IncomingHttpHeaders): Headers {
  const headers = new Headers();
  for (const [name, value] of Object.entries(source)) {
    if (typeof value === "string") headers.set(name, value);
    else if (Array.isArray(value)) for (const entry of value) headers.append(name, entry);
  }
  return headers;
}

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: toHeaders(req.headers),
      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 October 28, 2026