Idempotency
How idempotency keys, a durable claim store, payload fingerprints, and bounded retry budgets limit duplicate posts, and where they cannot.
Publishing is a public write. Social SDK’s idempotency model lets an application replay the same publish intent without dispatching it again, but only when the client has a durable IdempotencyStore. It cannot prove that a platform did or did not complete a write whose response was lost.
Keys need a store
A publish request accepts an application-chosen idempotencyKey. Derive it from your own durable identity for the intent, such as a draft ID, so every retry of the same intent carries the same key.
const social = createSocial({ backend, idempotencyStore });
const result = await social.posts.publish(
{
targets: [{ account }],
content: { text: "Release day" },
idempotencyKey: draftId,
},
{ authorization: { tenantId, principalId } },
);
The client deduplicates only when both idempotencyKey and createSocial({ idempotencyStore }) are present. Without a store, every call dispatches. Adapters also receive a derived per-target key. The Zernio adapter sends it as a request header and the Post for Me adapter sends it as external_id. Direct platform adapters do not send it, so the SDK makes no provider-side deduplication promise.
MemoryIdempotencyStore from @opencoredev/social-sdk/testing is for tests and local development. It loses every claim when the process exits. Production needs a store whose claim and saveOutcome are atomic in shared durable storage.
The server package’s SQL and Redis stores implement this contract without automatic claim expiry. Retain unresolved claims until you can reconcile them; deleting one can turn a later retry into a new provider write.
If saving a target’s outcome fails, the publish call preserves the provider’s known outcome and adds a warnings entry with code idempotency-outcome-not-persisted and the target index. Configure onPersistenceDiagnostic on createSocial to receive the same signal with the operation name. The callback receives no storage error, credential, caller key, or account identity, and a callback failure does not change delivery outcomes. Reconcile the durable record before expecting another call to replay the known result.
What a claimed key does
The first call with a key claims it and records each target’s outcome as it finishes. A later call with the same key and the same payload does not dispatch. It returns the saved outcome for each target. A target that was claimed but has no saved outcome, for example because the process crashed mid-dispatch, comes back as unknown with reason ambiguous-submission.
The claim is scoped by options.authorization.tenantId from the call, so pass the authenticated tenant on every publish. Two tenants using the same key get separate claims. Calls without a tenant share one scope.
Fingerprints detect changed intent
The claim stores a fingerprint of every prepared target: account, content, options, reply reference, and schedule. Reusing a key with different content, options, or a different set of targets throws idempotency_conflict before any dispatch. A key therefore cannot be reused with a subset of the original targets; posts.retryFailed takes the full original request instead.
Retry budgets are bounded
Calls accept retryBudget: { maxAttempts, maxElapsedMs }. The default is one attempt within 30 seconds. Reads may retry within that budget. Public writes are dispatched once; the transport does not resend a mutation whose response was lost.
Unknown is not failure
A timeout, cancellation, or transport failure after dispatch produces an unknown outcome, because the platform may have completed the write. Do not retry it with a new key until you have evidence the write did not happen.
An unknown outcome may have no delivery reference. When it has one, reconcile with posts.getDelivery only if the adapter declares posts.status as available for that platform. Several direct adapters, including X, Bluesky, and LinkedIn, do not implement it. Without a reference or status support, check the account’s recent posts or the provider dashboard.
const outcome = result.outcomes[0];
if (outcome?.state === "unknown") {
if (outcome.delivery !== undefined && supportsStatus(outcome.account)) {
const reconciled = await social.posts.getDelivery(outcome.delivery, {
authorization: { tenantId, principalId },
});
// Act on reconciled.state, and on reconciled.retryDisposition when it is "failed".
} else {
// No status route: search recent posts on the account before deciding anything.
}
}
supportsStatus stands for your own check against social.capabilities() for posts.status.
A failed outcome carries a retryDisposition. never means do not retry, after-delay gives a wait in milliseconds, after-reconnect needs new credentials, and reconcile-first means the write state is still uncertain.
Retrying after a confirmed non-write
Because a claimed key replays its saved outcomes, sending the same key again with posts.publish does not retry anything.
When the idempotencyStore implements compareAndSetOutcome and the client is created with recovery: outcomeRecovery() from @opencoredev/social-sdk/server, pass the full original request to posts.reconcile to poll pending targets, and to posts.retryFailed to re-send only targets whose saved outcome proves nothing is live. Both write into the original claim, so published targets are never sent twice. See reconcile and retry.
Without that store support, use a new key and include only the targets you have confirmed were not written. Keep the original key and outcomes for the targets that published.
Job runners with at-least-once semantics pair well with a durable store: the runner may re-execute, and the stored claim returns the recorded outcomes instead of dispatching again. See the Trigger.dev, Inngest, and Restate patterns.