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

Inngest

Drive event-based social publishing flows with durable Inngest steps, retries, and Social SDK idempotency keys.

To schedule a social post with Inngest, send the publish event with a future ts timestamp, and Inngest runs the publishing function at that time. Inside the function, Social SDK’s posts.publish claims the idempotency key before it calls the platform, so a retried step does not post twice. Scheduled publishing shows the call.

Inngest gives Social SDK applications durable, event-driven execution: a publish request becomes an event, each SDK call becomes a step with its own retry state, and status checks become follow-up steps instead of a background poller the SDK refuses to own.

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 and stores

The samples use the Inngest TypeScript SDK v4, where createFunction takes a configuration object with triggers and a handler. Version 3 took the trigger as a separate middle argument.

import { Inngest } from "inngest";

export const inngest = new Inngest({ id: "social-publishing" });

An Inngest function can run its steps on different processes, minutes apart. Everything the SDK needs between steps must live in durable storage that every process shares:

  • The client’s idempotencyStore claims each publish before any platform call. A step that crashes and runs again finds that claim instead of posting twice.
  • The Instagram adapter’s workflowStore holds each post’s container progress between the publish step and later status reads. The default in-memory store loses it when the process exits.

The Neon guide has Postgres implementations of the idempotency store and the Threads workflow store. An InstagramWorkflowStore has the same five methods (create, get, update, claim, release), so the same table design works with InstagramWorkflow records.

import { createSocial } from "@opencoredev/social-sdk";
import { instagram } from "@opencoredev/social-sdk/instagram";
import { idempotencyStore, instagramWorkflowStore } from "./stores"; // your durable stores

export const instagramAccountId = process.env.INSTAGRAM_ACCOUNT_ID!;

export const social = createSocial({
  backend: instagram({
    auth: { accessToken: process.env.INSTAGRAM_ACCESS_TOKEN!, accountId: instagramAccountId },
    workflowStore: instagramWorkflowStore,
  }),
  idempotencyStore,
});

This client holds one Instagram account’s token, so it can only publish as INSTAGRAM_ACCOUNT_ID. The adapter rejects a reference to any other account, so the function checks the draft’s account against it before publishing. To publish for several accounts, load each account’s credentials from encrypted storage and build a client for that account.

Publish as a durable function

Each step.run result is memoized, so a replayed function does not repeat a completed step. Inngest stores step results as JSON, so the publish step returns only the state and delivery reference.

import { NonRetriableError } from "inngest";
import { connectedAccountRef } from "@opencoredev/social-sdk";
import { inngest } from "./client";
import { instagramAccountId, social } from "../social";

const maxStatusChecks = 15;

export const publishPost = inngest.createFunction(
  { id: "publish-post", retries: 3, triggers: { event: "post/publish.requested" } },
  async ({ event, step }) => {
    const draftId: string = event.data.draftId;
    const draft = await step.run("load-draft", () => loadDraft(draftId)); // your storage
    if (draft.accountId !== instagramAccountId)
      throw new NonRetriableError("No credentials for this draft's account");
    const authorization = { tenantId: draft.tenantId };

    const published = await step.run("publish", async () => {
      const account = connectedAccountRef({
        backend: "default",
        platform: "instagram",
        accountId: draft.accountId,
      });
      if (!(await hasActiveAccountGrant(draft.tenantId, account))) {
        await flagForReview(draftId);
        throw new NonRetriableError("Account grant revoked");
      }
      const result = await social.posts.publish(
        {
          targets: [
            {
              account: connectedAccountRef({
                backend: "default",
                platform: "instagram",
                accountId: draft.accountId,
              }),
            },
          ],
          content: {
            text: draft.caption,
            media: [
              {
                kind: "image",
                mimeType: "image/jpeg",
                source: { kind: "https-url", url: draft.imageUrl },
              },
            ],
          },
          idempotencyKey: draftId,
        },
        { authorization },
      );
      const outcome = result.outcomes[0];
      return { state: outcome?.state ?? "unknown", delivery: outcome?.delivery ?? null };
    });

    await step.run("save-outcome", () =>
      saveDeliveryState(draftId, published.state, published.delivery),
    );

    // Instagram containers can stay processing for a while. Check a bounded number of times.
    let state = published.state;
    const delivery = published.delivery;
    for (let check = 0; state === "processing" && delivery && check < maxStatusChecks; check++) {
      await step.sleep(`wait-${check}`, "2m");
      state = await step.run(`status-${check}`, async () => {
        if (!(await hasActiveAccountGrant(draft.tenantId, delivery))) {
          await flagForReview(draftId);
          throw new NonRetriableError("Account grant revoked");
        }
        return (await social.posts.getDelivery(delivery, { authorization })).state;
      });
      await step.run(`save-status-${check}`, () => saveDeliveryState(draftId, state, delivery));
    }

    // unknown, or still processing after the last check: stop automation and flag for review.
    if (state === "unknown" || state === "processing")
      await step.run("flag-for-review", () => flagForReview(draftId));

    return { state };
  },
);

hasActiveAccountGrant is your server-owned lookup of an active grant matching the tenant and the reference’s exact backend, platform, and accountId. Keep it inside the publish and status callbacks so each new attempt checks current access. flagForReview must idempotently mark the tenant-scoped draft for review before the callback stops, preserving any saved delivery reference. A separate memoized authorization step could reuse a grant that has since been revoked. This client has no AuthorizationPolicy, so tenantId alone does not enforce the grant.

Your saveDeliveryState implementation must persist the state and delivery reference together, scoped to the draft’s verified tenant. Preserve an existing reference if a retry supplies null. A worker can then continue a processing delivery after these checks end.

Instagram image publishing accepts JPEG only, so the media item sets mimeType: "image/jpeg". Without it, the adapter rejects the post before any request.

Send the event from your API when a user hits publish:

await inngest.send({ name: "post/publish.requested", data: { draftId } });

Scheduled publishing

Delay the same function with a timestamp instead of running your own timer:

await inngest.send({
  name: "post/publish.requested",
  data: { draftId },
  ts: draft.publishAt.getTime(),
});

Inngest’s own idempotency option drops duplicate events for 24 hours, keyed by an expression such as event.data.draftId. That stops a double-sent event from starting a second run. It is separate from the SDK’s claim, which protects the platform write itself.

Notes

  • Inngest functions run in your own Node service, a tested SDK runtime; the serve handler mounts next to your other routes.
  • Step memoization plus a durable SDK idempotency store keep at-least-once execution from becoming duplicate posts. An unknown outcome still needs review.
  • Provider webhooks still follow the events guide; an accepted inbox row can emit an Inngest event to fan out processing.

Last updated on October 27, 2026