Postato Docs
GuidesCookbook

Retry-safe posting

Combine idempotency and error handling into a publisher you can retry forever.

Retry-safe posting

The classic production pattern: build a publish function that's safe to call any number of times for the same logical post. Double-publishing a tweet is an incident; double-publishing the same customer-facing content erodes trust. Get this right once.

Contract

A single logical post has a stable business key (your UUID, your post-draft ID, a hash of the payload). Every retry for that key sends the SAME Idempotency-Key. Postato dedupes; the underlying network is called exactly once.

Reference implementation (TypeScript)

import crypto from 'node:crypto';

type PostInput = {
  businessKey: string;          // YOUR stable ID
  platform: string;
  accountId: string;
  status: 'publish' | 'scheduled' | 'draft';
  postType: string;
  content: string | Array<{ text: string; media?: Array<{ id: string }> }>;
  scheduledAt?: Date;
};

async function publishWithRetry(input: PostInput, workspaceId: string, apiKey: string) {
  const url = `https://api.postato.com.br/v1/workspaces/${workspaceId}/posts`;
  const body = JSON.stringify({
    platform: input.platform,
    accountId: input.accountId,
    status: input.status,
    postType: input.postType,
    content: input.content,
    scheduledAt: input.scheduledAt?.toISOString(),
  });

  // Stable idempotency key derived from the business key — same input, same key.
  const idempotencyKey = crypto
    .createHash('sha256')
    .update(input.businessKey)
    .digest('hex')
    .slice(0, 32);

  const maxAttempts = 5;
  let attempt = 0;

  while (true) {
    attempt++;
    let response: Response;
    try {
      response = await fetch(url, {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${apiKey}`,
          'Content-Type': 'application/json',
          'Idempotency-Key': idempotencyKey,
        },
        body,
      });
    } catch (err) {
      // Network-level error. Retry with backoff.
      if (attempt >= maxAttempts) throw err;
      await backoff(attempt);
      continue;
    }

    if (response.status >= 200 && response.status < 300) {
      return (await response.json()) as { id: string; status: string };
    }

    // 4xx that aren't 429 — the request is bad, don't retry.
    if (response.status >= 400 && response.status < 500 && response.status !== 429) {
      const body = await response.json().catch(() => ({}));
      throw new PermanentPostError(response.status, body);
    }

    // 429 — honor Retry-After.
    if (response.status === 429) {
      const retryAfter = Number(response.headers.get('retry-after') ?? 1);
      await sleep(retryAfter * 1000);
      continue;
    }

    // 5xx — backoff and retry.
    if (attempt >= maxAttempts) {
      const body = await response.json().catch(() => ({}));
      throw new TransientPostError(response.status, body);
    }
    await backoff(attempt);
  }
}

function backoff(attempt: number) {
  const cap = 30_000;
  const base = 500;
  const jitter = Math.random() * 500;
  return sleep(Math.min(cap, base * 2 ** attempt) + jitter);
}

function sleep(ms: number) {
  return new Promise((r) => setTimeout(r, ms));
}

class PermanentPostError extends Error {
  constructor(public readonly status: number, public readonly body: unknown) {
    super(`Permanent error ${status}`);
  }
}
class TransientPostError extends Error {
  constructor(public readonly status: number, public readonly body: unknown) {
    super(`Transient error ${status}`);
  }
}

What this protects against

FailureOutcome
Lambda timeout mid-requestRetry with same Idempotency-Key → Postato returns the original response.
Network blip between client and PostatoRetry → same result.
429 rate limitWait Retry-After, retry.
500 transientExponential backoff, up to 5 attempts.
400 bad payloadThrows immediately. Don't loop on broken data.
Postato downtime > 5 retriesThrows TransientPostError. Dead-letter the job and alert.

Choosing the business key

Good keys:

  • Your internal draft UUID (stable across retries).
  • hash(userId + scheduledAt + contentHash): stable for the same logical intent.

Bad keys:

  • Date.now(): changes every retry, defeats the whole pattern.
  • User-visible identifiers that may be edited: if the user edits the post, the key must change or Postato returns the old result.

Persisting the result

After success, store postId alongside your businessKey in your DB. That way, if your service restarts with a half-complete batch, you can check "did I already publish this?" before calling Postato again. Saves a round-trip.

const existing = await db.posts.findByBusinessKey(input.businessKey);
if (existing?.postatoPostId) return existing;

const result = await publishWithRetry(input, workspaceId, apiKey);
await db.posts.upsert({ businessKey: input.businessKey, postatoPostId: result.id });
return result;

Testing the pattern

Three test cases at minimum:

  1. Normal publish: returns 202, stored in your DB.
  2. Retry after transient failure: mock 500 twice then 202; result is stored once.
  3. Idempotency dedupe: call twice in a row; Postato's second response matches the first (test with mcp-tools.json and a test account).

Invest in these tests early. Bugs in retry logic bite in the middle of campaigns, at the worst possible time.

On this page