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-sdkpnpm add @opencoredev/social-sdkyarn add @opencoredev/social-sdkbun add @opencoredev/social-sdknub add @opencoredev/social-sdkaube add @opencoredev/social-sdkCreate 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.