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

Trigger.dev

Run scheduled social publishing and delivery reconciliation as durable Trigger.dev tasks with Social SDK idempotency keys and explicit outcomes.

To schedule a social post with Trigger.dev, save the draft, then trigger a publishing task with delay set to the publish time. When the delay ends, the task calls Social SDK’s posts.publish with the draft ID as the idempotency key, so with a durable idempotency store a retried run does not post twice: it returns the saved result, or unknown if the first attempt died before saving one. Scheduled publish as a task shows the code, and a cron task follows up on posts that are still processing.

Trigger.dev runs durable tasks with retries and delays you configure. You can use it to call SocialScheduler.runDue or keep the application-owned workflow below. Social SDK starts no background worker and never retries an uncertain public write automatically.

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

Share the client and stores

Task runs can land on different machines, minutes apart, so everything the SDK needs between runs must live in durable storage that every run shares:

  • The client’s idempotencyStore claims each publish before any platform call. A retried run finds that claim instead of posting twice.
  • The Threads adapter’s workflowStore holds each post between posts.publish and the later status reads. The default in-memory store loses it when the run’s process exits.

The Neon guide has Postgres implementations of both, with their schema. Construct the client once in a module both tasks import:

import { Pool } from "pg";
import { createSocial } from "@opencoredev/social-sdk";
import { threads } from "@opencoredev/social-sdk/threads";
import { postgresIdempotencyStore, postgresThreadsWorkflowStore } from "../src/stores";

const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 5 });

export const threadsUserId = process.env.THREADS_USER_ID!;

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

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, so the task checks the draft’s account against it. To publish for several accounts, load each account’s credentials from encrypted storage and build a client for that account.

Scheduled publish as a task

Store the draft and its target account in your database, then let the task publish at the scheduled moment. The draft ID is the idempotency key. With the durable store, a retried run returns the first result instead of posting again.

import { AbortTaskRunError, task } from "@trigger.dev/sdk";
import { connectedAccountRef } from "@opencoredev/social-sdk";
import { social, threadsUserId } from "./social";

export const publishScheduled = task({
  id: "publish-scheduled-post",
  retry: { maxAttempts: 3 },
  run: async (payload: { draftId: string }) => {
    const draft = await loadDraft(payload.draftId); // your storage
    if (draft.status !== "scheduled") return { skipped: true };
    if (draft.accountId !== threadsUserId)
      throw new AbortTaskRunError("No credentials for this draft's account");

    const account = connectedAccountRef({
      backend: "default",
      platform: "threads",
      accountId: draft.accountId,
    });
    if (!(await hasActiveAccountGrant(draft.tenantId, account))) {
      await flagForReview(payload.draftId);
      throw new AbortTaskRunError("Account grant revoked");
    }

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

    // Your storage saves complete outcomes and marks any unknown outcome for review.
    await saveOutcomes(payload.draftId, result.outcomes);
    return { status: result.status };
  },
});

Trigger it with a delay when the user schedules:

await publishScheduled.trigger({ draftId }, { delay: draft.publishAt });

For Threads, posts.publish stores the workflow and then makes the Threads API calls before it returns. The outcome is published when Threads finishes, processing when media is still being processed or another run holds the workflow, and unknown when a write may or may not have reached Threads.

Reconciliation as a scheduled task

Threads, Instagram, TikTok, and YouTube can return processing. Reconcile those deliveries on a schedule instead of polling inline:

import { schedules } from "@trigger.dev/sdk";
import { social } from "./social";

export const reconcileDeliveries = schedules.task({
  id: "reconcile-deliveries",
  cron: "*/5 * * * *",
  run: async () => {
    const pending = await loadProcessingDeliveries(100); // processing, with the current draft review flag
    for (const row of pending) {
      if (row.reviewRequired) continue;
      if (!(await hasActiveAccountGrant(row.tenantId, row.delivery))) {
        await flagDeliveryForReview(row.id);
        continue;
      }
      const outcome = await social.posts.getDelivery(row.delivery, {
        authorization: { tenantId: row.tenantId },
      });
      await saveDeliveryState(row.id, outcome.state);
    }
  },
});

hasActiveAccountGrant is your server-owned lookup of an active grant for the tenant and the reference’s exact backend, platform, and accountId. Run it inside each task attempt before publishing or reconciling. loadProcessingDeliveries must join each delivery to its draft and return the current reviewRequired flag. Exclude review-flagged drafts from its query where possible; the loop also skips them explicitly. Restoring a grant does not clear that flag. Only an explicit review decision may make the draft eligible for automatic continuation again.

flagForReview and flagDeliveryForReview must idempotently move the associated tenant-scoped draft to review, preventing automatic publishing while preserving its saved outcome and delivery reference. This client has no AuthorizationPolicy; tenantId scopes idempotency but does not enforce a grant.

getDelivery is not a pure read for Threads. When a workflow is still processing, it claims the workflow and continues the remaining Threads calls. It never replays a workflow whose state is unknown; it reports unknown again. Leave unknown rows out of this query and review them instead.

Notes

  • Trigger.dev tasks run on Node, a tested SDK runtime; keep the SDK client at module scope so warm task runs reuse it. The samples import from @trigger.dev/sdk; the older @trigger.dev/sdk/v3 path still works but is deprecated.
  • Keep platform tokens in Trigger.dev environment variables, scoped per deployment environment.
  • Task retries plus a durable SDK idempotency store are the supported pattern for at-least-once schedulers publishing at-most-once content. Trigger.dev’s own idempotencyKey trigger option stops a duplicate trigger from starting a second run; it is separate from the SDK’s claim, which protects the platform write.

Last updated on October 27, 2026