# Idempotency & Retries

Retry post creation safely with Idempotency-Key, and understand how publishing is retried.


Networks fail. A request can time out after the server has already created your post, and if you simply retry you might publish twice and pay twice. Relaycast handles this with idempotency keys on the client side and automatic retries on the publishing side.

## Idempotency keys

Send an `Idempotency-Key` header with `POST /v1/posts`. The first request with a given key creates the post. Any later request with the same key, in the same project, returns that original post instead of creating a new one.

```bash tab="cURL"
curl -X POST https://social-publish-api.onrender.com/v1/posts \
  -H "x-access-key: $RELAYCAST_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-1042-announcement" \
  -d '{
    "content": "Thanks for order #1042!",
    "targets": [{ "accountId": "8f1c2a4e-5b6d-4e7f-9a0b-1c2d3e4f5a6b" }],
    "publishNow": true
  }'
```

```ts tab="Node.js"
import { randomUUID } from "node:crypto";

async function createPost(body: unknown, key = randomUUID()) {
  for (let attempt = 1; ; attempt++) {
    try {
      const res = await fetch("https://social-publish-api.onrender.com/v1/posts", {
        method: "POST",
        headers: {
          "x-access-key": process.env.RELAYCAST_API_KEY!,
          "Content-Type": "application/json",
          "Idempotency-Key": key, // same key on every attempt
        },
        body: JSON.stringify(body),
      });
      if (res.status === 429 || res.status >= 500) throw new Error(`HTTP ${res.status}`);
      return await res.json();
    } catch (err) {
      if (attempt >= 4) throw err;
      await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt));
    }
  }
}
```

```python tab="Python"
import os
import time
import uuid
import requests

def create_post(body, key=None):
    key = key or str(uuid.uuid4())
    for attempt in range(1, 5):
        try:
            res = requests.post(
                "https://social-publish-api.onrender.com/v1/posts",
                headers={
                    "x-access-key": os.environ["RELAYCAST_API_KEY"],
                    "Idempotency-Key": key,  # same key on every attempt
                },
                json=body,
                timeout=30,
            )
            if res.status_code == 429 or res.status_code >= 500:
                raise RuntimeError(f"HTTP {res.status_code}")
            return res.json()
        except (requests.RequestException, RuntimeError):
            if attempt == 4:
                raise
            time.sleep(2 ** attempt)
```

### How replays behave

| Situation | Response |
| --- | --- |
| First request with a key | `201 Created` with the new post |
| Same key again | `200 OK` with the **original** post, as it is now |
| Two requests with the same key at the same time | One creates the post; the other returns it with `200` |
| No key | Every request creates a new post |

A replay doesn't charge credits, re-run validation or queue anything again. It returns the post's current state, so a replay sent a few minutes later may already show targets as `published`.

### Choosing keys

- Use a value that identifies the **intent**, such as `newsletter-2026-10-05` or the ID of the record in your system that triggered the post. A random UUID generated once per logical post also works.
- Generate the key **before** the first attempt and reuse it for every retry of that post.
- Keys are compared as exact strings and scoped to your project. Different projects can use the same key independently.
- Keys don't expire. A key keeps pointing at its post until that post is deleted.

> [!WARNING]
> The request body is not compared on replay. If you reuse a key with a different body, you get the original post back and the new body is ignored. Use a new key for every distinct post.

### What to retry

Retry with the same key on network errors, timeouts, `429` and `5xx`. Don't retry `400`, `401`, `402` or `404` without changing something first, since the same request will fail the same way. See [Errors](/docs/errors).

Failed validation (`400`) doesn't store the key, so you can fix the body and retry with the same key.

## Publish retries

After a post is accepted, each target is published by a background worker. If a platform returns a temporary error (a timeout, a `5xx`, or a rate limit), the target goes back to `queued` and is retried automatically with exponential backoff, up to 5 attempts in total. The `attempts` field on each target shows how many have been made, and `errorCode` / `errorMessage` show the last error while it waits.

Permanent errors, such as rejected content, a media URL returning `404`, or revoked access, fail the target straight away without retrying.

When a target fails for good:

- its `status` becomes `failed`,
- its credit is refunded,
- and if the error was `auth_revoked`, the account is marked `reconnect_required`.

Targets are retried independently, so one slow platform never holds back the others.

### Duplicate protection

Each target has exactly one publish job, and a target that's already `published` or `failed` is never published again. If a job is lost (for example while infrastructure restarts), a sweeper re-queues targets still waiting a couple of minutes after their scheduled time, without creating duplicates.
