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

Convex

Publish to social platforms from Convex actions and store connections, deliveries, and webhook events in Convex tables.

Convex applications call Social SDK from actions running in the Node runtime, and keep the durable state the SDK deliberately does not own in Convex tables: connections, publication records, delivery references, and the webhook inbox.

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

Select Node 22 or 24

Convex runs Node actions on Node.js 20 by default, which is below the SDK’s Node 22.12 minimum. Set node.nodeVersion in convex.json to "22" or "24". The setting needs the convex package 1.27.0 or later:

{
  "$schema": "./node_modules/convex/schemas/convex.schema.json",
  "node": { "nodeVersion": "24" }
}

Run npx convex dev or npx convex deploy to apply it. For a few minutes after the change, some actions may still run on the old version. Self-hosted Convex backends ignore this setting and use the Node version in the backend’s .nvmrc, so check that file instead. These details come from Convex’s project configuration and runtimes documentation as of October 5, 2026.

Create the client in one module

The SDK requires the Node runtime, so every file that imports it starts with the "use node" directive. Store credentials in Convex environment variables, never in tables or client code.

Actions can run more than once, so an idempotencyKey must be backed by a durable idempotencyStore. Without one, a retried action publishes again. Implement the IdempotencyStore contract with Convex functions: claim must be a single mutation that inserts the claim or returns the existing one, so two actions can’t both get a new claim, and saveOutcome patches one target’s outcome into it. The Neon guide shows the same contract in Postgres.

"use node";

import type { ActionCtx } from "./_generated/server";
import { createSocial } from "@opencoredev/social-sdk";
import { x } from "@opencoredev/social-sdk/x";
import { convexIdempotencyStore } from "./idempotency"; // your IdempotencyStore on Convex mutations

export function createSocialClient(ctx: ActionCtx) {
  return createSocial({
    backend: x({
      auth: { userId: process.env.X_USER_ID!, accessToken: process.env.X_ACCESS_TOKEN! },
    }),
    idempotencyStore: convexIdempotencyStore(ctx),
  });
}

This client holds one X account’s token, so it can only publish as X_USER_ID. The adapter rejects a reference to any other account. Your accounts table can list many accounts, but with this client the authorized row’s accountId must equal X_USER_ID. To publish for several accounts, load each account’s credentials from encrypted storage and build a client for that account. See authentication.

Publish from a Node action

"use node";

import { v } from "convex/values";
import { connectedAccountRef } from "@opencoredev/social-sdk";
import { action } from "./_generated/server";
import { api, internal } from "./_generated/api";
import { createSocialClient } from "./socialClient";

export const publish = action({
  args: { draftId: v.id("drafts"), text: v.string() },
  handler: async (ctx, args) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) throw new Error("Unauthenticated");

    // Resolve the tenant's authorized account from your own table.
    const account = await ctx.runQuery(api.accounts.authorizedFor, {
      subject: identity.subject,
    });
    if (!account) throw new Error("No authorized account");
    if (account.accountId !== process.env.X_USER_ID) throw new Error("No credentials for account");

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

    // Persist every outcome; a mutation records it durably.
    await ctx.runMutation(internal.deliveries.record, {
      tenantId: account.tenantId,
      draftId: args.draftId,
      outcomes: result.outcomes.map((o) => ({
        state: o.state,
        delivery: o.delivery ?? null,
      })),
    });

    return result.status;
  },
});

With the durable store, a retried action with the same draft ID returns the first result instead of posting again. If that result is unknown, the first attempt may have posted. Stop there and check the account before you publish again, even under a new draft ID.

Reconcile processing outcomes

The X adapter has no delivery-status read. An X publish settles during the call and never returns processing, and posts.getDelivery throws a SocialError with code unsupported_capability for an X reference. Treat an X unknown as the end of automation: mark the draft for review and check the account’s posts before anything publishes again.

Reconciliation applies when you publish through a backend that implements posts.status, such as Threads, Instagram, TikTok, YouTube, or a managed backend; the capability matrix lists them. Build createSocialClient for that backend, store the outcome’s delivery reference as above, and reconcile from a later action instead of polling in the background:

"use node";

import { v } from "convex/values";
import { internalAction } from "./_generated/server";
import { internal } from "./_generated/api";
import { createSocialClient } from "./socialClient"; // built for a backend with posts.status

export const reconcile = internalAction({
  args: { deliveryId: v.id("deliveries") },
  handler: async (ctx, args) => {
    const saved = await ctx.runQuery(internal.deliveries.get, { id: args.deliveryId });
    if (!saved?.delivery) return; // no reference to read; leave the row for review

    // Recheck the current grant; the account may have been disconnected or reassigned.
    const authorized = await ctx.runQuery(internal.accounts.authorizedForDelivery, {
      tenantId: saved.tenantId,
      backend: saved.delivery.backend,
      platform: saved.delivery.platform,
      accountId: saved.delivery.accountId,
    });
    if (!authorized) return; // leave the row for review without continuing the workflow

    const social = createSocialClient(ctx);
    const outcome = await social.posts.getDelivery(saved.delivery, {
      authorization: { tenantId: saved.tenantId },
    });
    await ctx.runMutation(internal.deliveries.update, {
      id: args.deliveryId,
      state: outcome.state,
    });
  },
});

internal.accounts.authorizedForDelivery is an application-owned query. It returns true only when the tenant still has an active grant for the exact backend, platform, and account ID. Removed, disconnected, or reassigned accounts return false. The client above has no SDK authorization policy, so passing tenantId alone does not enforce ownership. Recheck the grant on every scheduled run before a status call, which can continue a provider publishing workflow.

Convex scheduled functions or crons can trigger reconciliation at the cadence you choose. The schedule belongs to your application; the SDK never polls on its own. Adapters that keep a local workflow, such as Threads and Instagram, also need a durable workflowStore shared by every action, because the default in-memory store disappears when the action ends.

Webhooks through an HTTP action

Convex HTTP actions expose the raw request, which webhook verification requires. The action reads the body before anything is authenticated, so cap it while reading. request.arrayBuffer() would buffer the whole body first, and a Content-Length check alone misses chunked requests. readBody stops at the verifier’s 1 MiB default and the action answers a larger body with 413:

import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";

/** Reads at most `maxBytes` of the body, or returns undefined when it is larger. */
async function readBody(request: Request, maxBytes = 1024 * 1024): Promise<Uint8Array | undefined> {
  if (Number(request.headers.get("content-length")) > maxBytes) return undefined;
  if (request.body === null) return new Uint8Array();

  const reader = request.body.getReader();
  const chunks: Uint8Array[] = [];
  let size = 0;
  for (;;) {
    const { done, value } = await reader.read();
    if (done) break;
    size += value.byteLength;
    if (size > maxBytes) {
      await reader.cancel();
      return undefined;
    }
    chunks.push(value);
  }

  const body = new Uint8Array(size);
  let offset = 0;
  for (const chunk of chunks) {
    body.set(chunk, offset);
    offset += chunk.byteLength;
  }
  return body;
}

const http = httpRouter();

http.route({
  path: "/events",
  method: "POST",
  handler: httpAction(async (ctx, request) => {
    const body = await readBody(request);
    if (body === undefined) return new Response("payload too large", { status: 413 });
    // Verify against raw bytes, decode, then accept into an "inbox" table.
    // Quarantine events whose accounts resolve to no authorized tenant.
    return new Response("accepted", { status: 202 });
  }),
});

export default http;

Model the durable inbox from the events guide as a Convex table, with a mutation that inserts the event and its dedupe key in one transaction.

Notes

  • Only "use node" actions can run the SDK; queries and mutations cannot make network calls.
  • Keep platform tokens in Convex environment variables and pass account selection through your own authorization queries.
  • Action retries are safe only because the idempotency claims and outcomes live in Convex tables. A retry that finds a claim without a saved outcome returns unknown, which needs review, not another publish.

Last updated on October 5, 2026