# Retries and duplicate posts: retry safely

> Index of all OmniSocials docs pages: https://docs.omnisocials.com/llms.txt

A social post that goes out twice is worse than one that fails. This page says what OmniSocials does when a request or a publish goes wrong, and how to retry without posting twice.

## The short version

- The API has no idempotency keys. Every `POST /posts/create` makes a new post, even with the same body.
- After a timeout or a `5xx` on create, check `GET /posts` before you send the request again.
- Retry failed channels with `POST /posts/:id/retry`, never by creating the post again.
- `429` and `503 storage_unavailable` did nothing, so they are safe to resend after `Retry-After`.
- One post can send several webhook events. Track state per `data.post_id`.

## No idempotency keys

The API does not read an `Idempotency-Key` header or anything like it, and `POST /posts/create` does not compare a new post with earlier ones. Two identical requests create two posts, and when both publish, both go out (unless the platform refuses the second, see [Duplicate content](#duplicate-content)).

Media uploads are the exception. An image or video up to 100 MB is matched by its content: uploading the same bytes to the same workspace again returns the existing media item instead of a new one. This covers `POST /media/upload`, `upload-from-base64`, presigned uploads, `upload-from-url` and `media_urls` on a post. A file over 100 MB, ingested in the background through `upload-from-url`, creates a new item on every call.

## Timeouts and 5xx errors

`POST /posts/create` saves the post before it builds the response. A `500 internal_error` or a dropped connection can therefore arrive after the post was saved, and a saved post with `publish_now: true` publishes anyway. Treat a timeout or a `5xx` on create as "unknown", not as "failed".

To make the check easy, give each create a unique `source` value. `source` is a free-form label (it defaults to `"api"`, is shown in the dashboard and comes back on every post):

```bash
curl -X POST https://api.omnisocials.com/v1/posts/create \
  -H "Authorization: Bearer $OMNISOCIALS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "content": { "default": "Spring sale starts today" },
    "accounts": ["your-account-id"],
    "publish_now": true,
    "source": "spring-sale-job-8f3a"
  }'
```

If that call times out or answers `5xx`:

1. Wait a few seconds, then call `GET /posts?limit=20`. Posts come back newest first.
2. Look for a post whose `source` is `spring-sale-job-8f3a`.
3. If it is there, do not create again. Poll `GET /posts/:id` for its outcome.
4. If it is not there, send the create again with the same `source`.

The other write endpoints are safer to repeat:

| Endpoint | What a second identical call does |
|----------|-----------------------------------|
| `POST /posts/:id/publish` | Only `draft` and `scheduled` posts can be published. A second call answers `400 invalid_status` (the post is `posting` or `warning`), `400 already_published` or `400 post_failed`, and publishes nothing |
| `POST /posts/:id/retry` | While the retry runs the post is `posting`, so a second call answers `400 invalid_status` |
| `DELETE /posts/:id` | The second call answers `404 not_found` |
| Media uploads up to 100 MB | Returns the same media item |
| `POST /inbox/conversations/:id/reply` | The same text and attachment to the same conversation within 10 minutes answers `409 duplicate_reply`. Nothing is sent again, and `data` carries the earlier message. Treat it as sent |
| Any request answered `429` | Rejected before it ran. Resend after `Retry-After` |
| Any upload answered `503 storage_unavailable` | Nothing was stored. Resend after `Retry-After` |

## How OmniSocials retries a publish

Publishing runs in the background after your request returns. Several guards keep a channel from getting the same post twice:

- **One success per channel.** A channel's success is recorded the moment the platform confirms it. Every later run of the same post (a retry, or recovery after a crash) skips the channels that already succeeded.
- **Lookup before resend.** When Instagram's publish call or Facebook's feed post call times out or answers `5xx`, OmniSocials first looks on the account for a post created after the attempt with the same caption, and keeps it when it finds one. Instagram sometimes answers "Action blocked" for a post that is already live; the same lookup runs then. A post without a caption, such as a story, cannot be matched this way.
- **Unknown outcomes are reported, not retried.** When X accepts a post but returns no post id, the channel fails with "X accepted the request but did not return a post id, so we can't confirm the post went live. Check your X profile before retrying, to avoid a duplicate."
- **Threads and stories resume.** A Threads thread that fails halfway resumes at the first unpublished part, and a multi-slide story publishes only the slides that did not go out.

### Automatic retries

A failure that OmniSocials classifies as temporary gets an automatic retry about 15 minutes later, up to 3 automatic attempts per channel. While one is planned, the channel's message in `errors` ends with "We'll retry this platform automatically in about 15 minutes."

The post's `status` is `failed` or `warning` in the meantime. Calling `POST /posts/:id/retry` yourself replaces the planned automatic retry. Automatic attempts are counted apart from the 3 manual retries per channel.

## warning and failed

| Status | Meaning | Webhook |
|--------|---------|---------|
| `warning` | Published on some channels, failed on others. `published_urls` has the live links, `errors` the failed channels | `post.published` with `data.status: "warning"` |
| `failed` | Failed on every channel | `post.failed` |

An `errors` entry disappears once a retry of that channel succeeds. `GET /posts?status=failed` does not return `warning` posts, so query both when you track failures. See [Post statuses](https://docs.omnisocials.com/creating-posts.md#post-statuses).

## Retrying failed channels

`POST /posts/:id/retry` publishes the failed channels of a `failed` or `warning` post again, on the same post. Channels that already published are never posted again.

```bash
curl -X POST https://api.omnisocials.com/v1/posts/59546574/retry \
  -H "Authorization: Bearer $OMNISOCIALS_API_KEY"
```

```json
{
  "data": {
    "id": "59546574",
    "status": "posting",
    "platforms": ["linkedin"],
    "message": "Retry queued for: linkedin. Platforms that already succeeded are never re-published. Poll GET /posts/{id} for the outcome."
  }
}
```

The retry runs in the background. Poll `GET /posts/:id` until `status` is no longer `posting`.

| Response | Meaning |
|----------|---------|
| `400 invalid_status` | The post is not `failed` or `warning` |
| `400 nothing_to_retry` | No failed channel is recorded, for example on posts from before per-channel tracking. Create a new post instead |
| `400 max_retries_reached` | A failed channel was already retried 3 times. All failed channels retry together, so one exhausted channel blocks the whole request. Create a new post |

Channels dismissed with "ignore" in the dashboard are not retried. `POST /posts/:id/publish` refuses `failed` posts (`400 post_failed`) and `warning` posts (`400 invalid_status`); retry is the endpoint for them.

**Fix the cause first.** A retry sends the same post again, so it fails again when the cause is the content or the account: a duplicate, a missing permission, an expired connection. Reconnect the account, top up credits, or change the post first. [Known issues](https://docs.omnisocials.com/known-issues.md) lists the common causes per channel. A `warning` post can still be edited with `PATCH /posts/:id` before you retry; a `failed` post cannot (`400 invalid_status`), so create a new post when its content has to change.

Retries made in the dashboard can create a separate retry post, linked with `retry_of` and `retries`. See [Retry linkage](https://docs.omnisocials.com/creating-posts.md#retry-linkage).

## Duplicate content

OmniSocials does not refuse a post for being the same as an earlier one. Some platforms do:

| Channel | Message |
|---------|---------|
| Facebook | "Facebook rejected this as a duplicate" |
| LinkedIn | "LinkedIn rejected this post as a duplicate: identical content already exists on your profile" (or page) |
| X | "This is a duplicate of a tweet you already posted." |

These refusals also cover content that was posted outside OmniSocials. A retry with the same text fails the same way: change the text first.

## Webhooks for one post can arrive more than once

Every delivery carries `X-OmniSocials-Delivery`, a unique id per event that stays the same when a delivery is retried. Deduplicate deliveries on it.

One post can still produce several events, each with its own delivery id:

- `post.failed` after the first attempt, then `post.published` when a retry (manual or automatic) succeeds.
- `post.published` with `data.status: "warning"` after the first attempt, then `post.published` again after each retry.
- A planned automatic retry sends nothing until it ends.

Keep your state per `data.post_id`, read `data.status` and `data.targets` on every event, and order events by `created_at`. See [Webhooks](https://docs.omnisocials.com/webhooks.md#event-catalog).

## Checklist

| Situation | Resend? | What to do |
|-----------|---------|------------|
| `429 rate_limit_exceeded` | Yes | Wait `Retry-After` seconds |
| `503 storage_unavailable` | Yes | Wait `Retry-After` seconds, send the same request |
| `400`, `401`, `402`, `403`, `404` | No | Fix the request, key, scope or credits first |
| Timeout or `5xx` on `POST /posts/create` | Only after a check | Look for the post's `source` in `GET /posts`; create again only when it is missing |
| Timeout or `5xx` on publish or retry | Yes | A refusal with `invalid_status`, `already_published` or `post_failed` means the first call went through. Poll the post |
| A channel failed (`warning` or `failed`) | Use retry | Fix the cause, then `POST /posts/:id/retry` |
| `409 duplicate_reply` | No | The reply was already sent |

For the full status code table and the error body, see [Rate limits and errors](https://docs.omnisocials.com/rate-limits-and-errors.md).
