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

Hono

Publish to social platforms from Hono routes on Node or Bun using web-standard Request and Response with Social SDK.

To post to social media from Hono, create one Social SDK client at startup and call social.posts.publish from a route handler, with the draft ID as the idempotency key. The same app runs on Node 22.12+ and Bun, and webhook routes verify the raw bytes from c.req.raw. 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.

Hono’s fetch-native design fits Social SDK directly: routes receive a standard Request, webhook verification reads its raw bytes, and the same app runs on Node 22.12+ or Bun without adapters.

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

Publish from a route

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 Neon guide includes a Postgres implementation.

import { Hono } from "hono";
import { createSocial, connectedAccountRef } from "@opencoredev/social-sdk";
import { bluesky } from "@opencoredev/social-sdk/bluesky";
import { idempotencyStore } from "./stores.js"; // your durable IdempotencyStore

const social = createSocial({
  backend: bluesky({
    auth: {
      service: process.env.BLUESKY_SERVICE!,
      did: process.env.BLUESKY_DID!,
      accessJwt: process.env.BLUESKY_ACCESS_JWT!,
    },
  }),
  idempotencyStore,
});

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 };
}

const app = new Hono();

app.post("/publish", async (c) => {
  const tenant = await requireTenant(c); // your auth middleware
  const input = parseBody(await c.req.json().catch(() => null));
  if (!input) return c.text("Expected text and draftId", 400);

  const result = await social.posts.publish(
    {
      targets: [
        {
          account: connectedAccountRef({
            backend: "default",
            platform: "bluesky",
            accountId: await resolveAuthorizedAccount(tenant),
          }),
        },
      ],
      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 } },
  );

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

export default app;

This client holds one Bluesky session, so it can only publish as BLUESKY_DID. 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.

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.

Webhooks from the raw request

This route receives Zernio webhooks, so it only applies when you publish through the Zernio managed backend. Bluesky itself sends no webhooks; its events arrive over WebSocket streams, as the events guide explains.

Hono exposes the untouched request as c.req.raw. Read its bytes before any JSON parsing and pass its Headers object as is. The route reads the body before anything is authenticated, so put Hono’s bodyLimit middleware in front of it. It rejects a larger Content-Length up front and stops a chunked body once it passes the limit, so the handler never buffers more than the verifier’s 1 MiB default:

import { bodyLimit } from "hono/body-limit";
import { SocialError } from "@opencoredev/social-sdk";
import { verifyZernioWebhook } from "@opencoredev/social-sdk/server";

app.post(
  "/events",
  bodyLimit({ maxSize: 1024 * 1024, onError: (c) => c.text("payload too large", 413) }),
  async (c) => {
    const body = new Uint8Array(await c.req.raw.arrayBuffer());

    try {
      await verifyZernioWebhook({
        secret: process.env.ZERNIO_WEBHOOK_SECRET!,
        headers: c.req.raw.headers,
        body,
      });
    } catch (error) {
      if (error instanceof SocialError && error.code === "unauthorized") {
        return c.text("unauthorized", 401);
      }
      throw error;
    }

    // decode, resolve tenants, accept into your durable inbox
    return c.text("accepted", 202);
  },
);

See the events guide for the decode, quarantine, and worker steps.

Mount the example handler

The repository’s apps/example app has a Request/Response handler with account selection, publishing, reconciliation, and webhook intake. It is a private workspace package. It is not part of @opencoredev/social-sdk and is not published to npm, so there is no subpath to import it from. Copy it into your project instead.

The handler opens a local PGlite database on its first request and applies the Drizzle migrations from apps/example/drizzle. The database module finds them at ../../drizzle relative to src/db/client.ts, so copy both directories and keep them side by side. From your project root, with a checkout of the repository at ../social-sdk:

mkdir -p src/social-example
cp -R ../social-sdk/apps/example/src src/social-example/src
cp -R ../social-sdk/apps/example/drizzle src/social-example/drizzle

That gives this layout. Without drizzle/, every API request fails with HTTP 500 because the migrations cannot be found.

src/
  app.ts
  social-example/
    src/       # app.ts, db/, frameworks/, ...
    drizzle/   # 0000_init.sql and meta/_journal.json

Install the copy’s dependencies, which come from apps/example/package.json:

npm install @electric-sql/pglite @neondatabase/serverless drizzle-orm
pnpm add @electric-sql/pglite @neondatabase/serverless drizzle-orm
yarn add @electric-sql/pglite @neondatabase/serverless drizzle-orm
bun add @electric-sql/pglite @neondatabase/serverless drizzle-orm
nub add @electric-sql/pglite @neondatabase/serverless drizzle-orm
aube add @electric-sql/pglite @neondatabase/serverless drizzle-orm

Then mount it:

import { Hono } from "hono";
import { createExampleHandler } from "./social-example/src/app.js";
import { createHonoFetch } from "./social-example/src/frameworks/hono.js";

const example = createHonoFetch(createExampleHandler());
const app = new Hono();
app.all("/social/*", (c) => example(c.req.raw));

If you compile with tsc instead of running TypeScript directly, copy drizzle/ next to the compiled social-example/src as well, so the relative path still resolves.

With the server below on port 3030, GET /social/accounts lists two mock accounts and POST /social/publish publishes to them. The example starts with a local mock backend and a demo session. Replace the session with your own authentication before it touches real accounts. It also answers only requests for localhost:3030 and 127.0.0.1:3030. On another host or port, pass allowedHosts and allowedOrigins to createExampleHandler, or it returns HTTP 421. Framework recipes documents what the handler provides.

The example’s POST /api/connect/callback takes a JSON body with attemptId, callbackUrl, and returnedState. It is not the URL a provider redirects to. Providers redirect the browser with a GET to the redirect URI you registered, so add your own GET route at that URI. In it, authenticate the session, take the tenant and principal from the session, and pass the full callback URL and returned state to ConnectionManager.discover, then select the accounts the user picks. OAuth redirects carry no webhook signature, so keep this route separate from webhook verification. Authentication describes begin, discover, and select.

Run on Node or Bun

// Bun
import app from "./app.js";
Bun.serve({ port: 3030, fetch: app.fetch });
// Node
import { serve } from "@hono/node-server";
import app from "./app.js";
serve({ fetch: app.fetch, port: 3030 });

Notes

  • Both runtimes are tested SDK targets; the SDK is ESM-only.
  • Cloudflare Workers is not a tested SDK runtime. For Workers-fronted apps, run the SDK in a Node or Bun service behind the Worker.
  • Keep one client instance per process so per-backend concurrency limits apply.

Last updated on October 28, 2026