# Bulk upload, recurring posts and RSS

> A month of posts from one CSV, checked row by row and scheduled whole or not at all; posts that repeat; RSS feeds that become posts.

Source: https://postwire.io/docs/automation/ · Base URL: https://postwire.io · OpenAPI: https://postwire.io/openapi.json

**Bulk upload.** Send a CSV (or rows as JSON) to `POST /api/bulk`. It is a **dry run by default**: every row comes back with its time in its timezone, each network's length as that network counts it (on X a link counts 23 and an emoji 2), the X credits it will use, whether it waits for approval or is held by the plan, and its errors and warnings — with the line number. Send it again with `dry_run: false` and **all rows are scheduled, or none**: one row with an error answers 422 `bulk_rows_invalid` and nothing is stored. The same file twice answers 409 `bulk_already_scheduled` (a double click never doubles your posts), and `DELETE /api/bulk/{batch_id}` undoes a whole upload. Columns: `date` (2026-11-02 09:00 in the row's or the upload's timezone, an ISO time with its offset, or `next_slot`), `timezone`, `networks`, `text`, `text_<network>`, `media_urls`, `media_urls_<network>`, `alt_text`, `first_comment` (LinkedIn), `x_reply`, `x_thread`, `title`, `brand`, `label`, `privacy`. Comma, semicolon or tab; UTF-8 with or without a BOM. [Download the template](/api/bulk/template.csv). Rows per upload: Free 20, Starter 500, Pro 1,000, Agency 2,000, Scale 5,000; on Free, posts past the 3 scheduled at a time are saved as held.

**Recurring posts** (every paid plan). Daily, weekly on some weekdays, or monthly on some days (31 falls on the last day of shorter months; -1 is always the last day), every N days, weeks or months, at 1-4 times a day in a timezone, until a date or for a number of times. Daylight saving is handled the way calendars do it. `variants` rotate in order. Occurrences are put in the queue up to 7 days ahead as ordinary scheduled posts: each can be edited or canceled alone, and editing the recurring post replaces every waiting occurrence except the ones edited by hand. The **duplicate guard** (`guard_days`, default 3) skips a network where the same text went out in the last N days when an occurrence is published (code `duplicate_recent`): networks flag repeated posts, and X refuses them.

**RSS feeds.** RSS 2.0, RSS 1.0 and Atom, read as often as every 30 minutes. What is already in the feed when you add it is recorded, not posted. Each new item — new by its guid **and** its link — becomes a post from your template (`{title}`, `{link}`, `{summary}`), fitted to each network's limit without ever cutting the link; or written per network with AI, from the plan's AI drafts a day, never an extra charge. New items go out right away or in the brand's next free slot, or wait for approval. Feeds are fetched safely: private, loopback and cloud-metadata addresses are refused, also after a redirect or when a name resolves to one; 3 redirects, 8 seconds and 2 MB at most.

Everything here goes through the brand's [approval rules](/docs/approvals/), the key's limits, [X credits](/docs/x/) and the plan's monthly posts, like any post, and is written to the activity log.

- [`POST /api/bulk`](#post-api-bulk): Bulk upload: check, then schedule many posts at once (CSV or rows)
- [`GET /api/bulk/template.csv`](#get-api-bulk-template-csv): The bulk upload template
- [`DELETE /api/bulk/{id}`](#delete-api-bulk-id): Undo a bulk upload
- [`POST /api/recurring`](#post-api-recurring): Set up a recurring post
- [`POST /api/recurring/preview`](#post-api-recurring-preview): Preview a rule
- [`GET /api/recurring`](#get-api-recurring): List recurring posts
- [`GET /api/recurring/{id}`](#get-api-recurring-id): A recurring post
- [`PATCH /api/recurring/{id}`](#patch-api-recurring-id): Edit all occurrences, pause or resume
- [`DELETE /api/recurring/{id}`](#delete-api-recurring-id): Delete a recurring post
- [`POST /api/rss/preview`](#post-api-rss-preview): Preview a feed
- [`POST /api/rss`](#post-api-rss): Add an RSS feed
- [`GET /api/rss`](#get-api-rss): List RSS feeds
- [`PATCH /api/rss/{id}`](#patch-api-rss-id): Change, pause or turn on a feed
- [`DELETE /api/rss/{id}`](#delete-api-rss-id): Remove a feed
- [`POST /api/rss/{id}/check`](#post-api-rss-id-check): Read a feed now
- [`GET /api/rss/{id}/items`](#get-api-rss-id-items): What happened to a feed's items

## Bulk upload: check, then schedule many posts at once (CSV or rows)

`POST /api/bulk`

A CSV (field csv, or the request body itself with Content-Type: text/csv) or a list of rows. Columns: date ("2026-11-02 09:00" in the row's timezone or the upload's, an ISO time with its offset, or next_slot), time (optional), timezone, networks, text, text_<network> (each network's own text), title, title_<network>, media_urls (https; prefix video: or document:), media_urls_<network>, image_urls, video_url, alt_text (| between images), first_comment (LinkedIn), x_reply, x_thread (||| between posts), brand (name or id), label, privacy. Comma, semicolon or tab separated; UTF-8 with or without a BOM; quoted fields may hold commas and line breaks. dry_run is true by default: nothing is stored, and every row comes back with its time in its timezone, its networks, each network's length as that network counts it (X: a link 23, an emoji 2), the X credits it would use, whether it waits for approval (the brand's rules, a key's limits, a Contributor) or is held by the plan, and its errors and warnings. With dry_run false, ALL rows are scheduled or NONE: any row with an error answers 422 bulk_rows_invalid and nothing is stored; the rows that go to the queue are inserted in one statement; if the rows that wait for approval cannot be stored, the inserted ones are canceled again (503). The same upload twice answers 409 bulk_already_scheduled unless allow_duplicate is true. Rows per upload: Free 20, Starter 500, Pro 1,000, Agency 2,000, Scale 5,000 (403 bulk_rows_limit past it). On Free, rows past the 3 scheduled posts at a time are saved as held (never published on Free).

Needs an API key: `Authorization: Bearer pw_live_…`.

**Body (JSON)**

| Field | Type | Description |
|---|---|---|
| `csv` | string | The CSV text, first line = column names. Up to 3 MB. |
| `rows` | array of object | Instead of csv: objects with the same column names. |
| `dry_run` | boolean | Default true. |
| `timezone` | string | IANA timezone for dates without one. |
| `brand_id` | string | Default brand (id or name) for rows without a brand column. |
| `fit_long_text` | boolean | A text over a network's limit is cut at a sentence or word when published (a warning), instead of an error. Default false. |
| `date_format` | string | How to read 12/10/2026. One of: ymd, dmy, mdy. Default "ymd". |
| `allow_duplicate` | boolean | Default false. |
| `check_media` | boolean | Check that each media link answers (each link once, up to 20 seconds in all). Default true. |
| `batch_id` | string | Your own id for the upload (6-64 letters, digits, - or _). Default: a hash of the file. |

**Responses**

| Status | Means |
|---|---|
| `200` | { ok, dry_run, batch_id, summary: { rows, ok, with_errors, with_warnings, posts, x_credits, wait_for_approval, held, will_queue, plan }, rows: [{ line, ok, run_at, local, timezone, networks, brand, preview, lengths, x_credits?, approval?, held_for?, errors: [{ field, network?, message, code }], warnings, schedule_id?, status? }], x_credits?, ignored_columns?, undo?, message } |
| `400` | bad_csv (with line), bad_timezone, bad_request |
| `403` | bulk_rows_limit (with the plan that takes the upload), email not verified |
| `409` | bulk_already_scheduled |
| `422` | bulk_rows_invalid: nothing was scheduled; each row says what to fix |
| `503` | bulk_store_failed / governance_unavailable: nothing was scheduled |

```bash
curl -X POST "https://postwire.io/api/bulk" \
  -H "Authorization: Bearer $POSTWIRE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "csv": "date,networks,text,first_comment\n2026-11-02 09:00,\"linkedin, bluesky\",Our autumn menu is here,Full menu: https://bakery.example/menu\n2026-11-03 12:30,instagram,Fresh out of the oven",
  "timezone": "America/Lima",
  "dry_run": true
}'
```

```js
const res = await fetch("https://postwire.io/api/bulk", {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.POSTWIRE_API_KEY}`, "Content-Type": "application/json" },
  body: JSON.stringify({
    "csv": "date,networks,text,first_comment\n2026-11-02 09:00,\"linkedin, bluesky\",Our autumn menu is here,Full menu: https://bakery.example/menu\n2026-11-03 12:30,instagram,Fresh out of the oven",
    "timezone": "America/Lima",
    "dry_run": true
  }),
});
console.log(res.status, await res.json());
```

```python
import os, requests

r = requests.post(
    "https://postwire.io/api/bulk",
    headers={"Authorization": f"Bearer {os.environ['POSTWIRE_API_KEY']}"},
    json={
        "csv": "date,networks,text,first_comment\n2026-11-02 09:00,\"linkedin, bluesky\",Our autumn menu is here,Full menu: https://bakery.example/menu\n2026-11-03 12:30,instagram,Fresh out of the oven",
        "timezone": "America/Lima",
        "dry_run": True,
    },
)
print(r.status_code, r.json())
```

**Example response**

```json
{
  "ok": false,
  "dry_run": true,
  "batch_id": "5681dfcaa8a49e2bd2a6",
  "summary": {
    "rows": 2,
    "ok": 1,
    "with_errors": 1,
    "posts": 2,
    "x_credits": 0,
    "wait_for_approval": 0,
    "held": 0,
    "will_queue": 1
  },
  "rows": [
    {
      "line": 2,
      "ok": true,
      "run_at": "2026-11-02T14:00:00.000Z",
      "local": "2026-11-02 09:00",
      "timezone": "America/Lima",
      "networks": [
        "linkedin",
        "bluesky"
      ],
      "lengths": {
        "linkedin": 23,
        "bluesky": 23
      },
      "errors": [],
      "warnings": []
    },
    {
      "line": 3,
      "ok": false,
      "networks": [
        "instagram"
      ],
      "errors": [
        {
          "field": "media",
          "network": "instagram",
          "message": "instagram needs a photo or a video",
          "code": "media_required"
        }
      ],
      "warnings": []
    }
  ],
  "message": "1 of 2 rows have problems: fix them and check again. Nothing is scheduled until every row is right."
}
```

Send the same body with dry_run: false to schedule every row, or none. Or send the file itself: curl --data-binary @posts.csv -H "Content-Type: text/csv" "https://postwire.io/api/bulk?timezone=America/Lima".

## The bulk upload template

`GET /api/bulk/template.csv`

A CSV with every column and three example rows (dates a week from today). No key needed.

No API key needed.

**Responses**

| Status | Means |
|---|---|
| `200` | text/csv |

```bash
curl "https://postwire.io/api/bulk/template.csv"
```

```js
const res = await fetch("https://postwire.io/api/bulk/template.csv", {
  method: "GET",
});
console.log(res.status, await res.json());
```

```python
import os, requests

r = requests.get(
    "https://postwire.io/api/bulk/template.csv",
)
print(r.status_code, r.json())
```

## Undo a bulk upload

`DELETE /api/bulk/{id}`

Cancels every post of the upload (its batch_id) that has not gone out, and withdraws the ones waiting for approval. Posts already published stay.

Needs an API key: `Authorization: Bearer pw_live_…`.

**Parameters**

| Field | Type | Description |
|---|---|---|
| `id` (required) | path, string |  |

**Responses**

| Status | Means |
|---|---|
| `200` | { ok, batch_id, canceled, already_out, message } |
| `404` | not_found |

```bash
curl -X DELETE "https://postwire.io/api/bulk/5681dfcaa8a49e2bd2a6" \
  -H "Authorization: Bearer $POSTWIRE_API_KEY"
```

```js
const res = await fetch("https://postwire.io/api/bulk/5681dfcaa8a49e2bd2a6", {
  method: "DELETE",
  headers: { Authorization: `Bearer ${process.env.POSTWIRE_API_KEY}` },
});
console.log(res.status, await res.json());
```

```python
import os, requests

r = requests.delete(
    "https://postwire.io/api/bulk/5681dfcaa8a49e2bd2a6",
    headers={"Authorization": f"Bearer {os.environ['POSTWIRE_API_KEY']}"},
)
print(r.status_code, r.json())
```

**Example response**

```json
{
  "ok": true,
  "batch_id": "5681dfcaa8a49e2bd2a6",
  "canceled": 12,
  "already_out": 0
}
```

## Set up a recurring post

`POST /api/recurring`

Every paid plan (Starter 25, Pro 100, Agency 500, Scale 2,000 at a time; 403 plan_feature on Free). Occurrences are put in the queue up to 7 days ahead (at most 14 at once) as ordinary scheduled posts with payload._recurring: each can be edited (PATCH /api/schedule/{id}) or canceled alone, and goes through the brand's approval rules, the key's limits, X credits and the plan's monthly posts like any post. An occurrence whose time passed while the publisher was not running is never posted late. Answers with warnings when the duplicate guard would skip posts (one text every day with a 3-day guard).

Needs an API key: `Authorization: Bearer pw_live_…`.

**Body (JSON)**

| Field | Type | Description |
|---|---|---|
| `platforms` (required) | array of string | One of: tiktok, instagram, youtube, linkedin, facebook, bluesky, mastodon, telegram, discord, x. |
| `text` | string | The post, when there is one text. |
| `variants` | array of any | Texts used in turn: occurrence n uses variant ((n-1) mod count) + 1. Up to 50 items. |
| `rule` (required) | object | When it repeats. Daylight saving: a time that does not exist on the day clocks go forward runs that much later (02:30 -> 03:30); a time that happens twice runs the first time. |
| `rule.freq` (required) | string | One of: daily, weekly, monthly. |
| `rule.interval` | integer | Every N days / weeks / months, counted from start. Default 1. 1 to 52. |
| `rule.days` | array of string | weekly: the weekdays (default: the start date's). One of: mon, tue, wed, thu, fri, sat, sun. |
| `rule.month_days` | array of integer | monthly: days of the month; -1 = the last day. 31 falls on the last day of shorter months. |
| `rule.times` | array of string | 24-hour HH:MM, 1-4 a day. Default 09:00. Up to 4 items. |
| `rule.timezone` | string | IANA name. Default: the brand's queue timezone, else UTC. |
| `rule.start` | string (date) | First day (default today); also the anchor of every N. |
| `rule.until` | string (date) | Last day, inclusive. |
| `rule.count` | integer | Stop after N occurrences. 1 to 1000. |
| `guard_days` | integer | When an occurrence is published, a network where the same text went out in the last N days (3-hour margin) is skipped, with code duplicate_recent in its result. 0 = off. Default 3. 0 to 90. |
| `per_platform` | object |  |
| `media` | array of object |  |
| `photo_url` | string |  |
| `video_url` | string |  |
| `options` | object |  |
| `title` | string |  |
| `label` | string | Up to 120 characters. |
| `brand_id` | string (uuid) |  |

**Responses**

| Status | Means |
|---|---|
| `201` | { ok, recurring, queued_now, warnings, message } |
| `400` | bad_rule, not_connected, text_required, too_long, media_required |
| `403` | plan_feature, recurring_limit, x_paid_plan_required |
| `503` | automation_unavailable |

```bash
curl -X POST "https://postwire.io/api/recurring" \
  -H "Authorization: Bearer $POSTWIRE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "platforms": [
    "linkedin",
    "bluesky"
  ],
  "variants": [
    "Monday tip: grind right before you brew.",
    "Monday tip: weigh your coffee, 1:16 is a good start.",
    "Monday tip: water just off the boil."
  ],
  "rule": {
    "freq": "weekly",
    "days": [
      "mon"
    ],
    "times": [
      "09:00"
    ],
    "timezone": "America/Lima"
  },
  "guard_days": 7,
  "label": "Monday tip"
}'
```

```js
const res = await fetch("https://postwire.io/api/recurring", {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.POSTWIRE_API_KEY}`, "Content-Type": "application/json" },
  body: JSON.stringify({
    "platforms": [
      "linkedin",
      "bluesky"
    ],
    "variants": [
      "Monday tip: grind right before you brew.",
      "Monday tip: weigh your coffee, 1:16 is a good start.",
      "Monday tip: water just off the boil."
    ],
    "rule": {
      "freq": "weekly",
      "days": [
        "mon"
      ],
      "times": [
        "09:00"
      ],
      "timezone": "America/Lima"
    },
    "guard_days": 7,
    "label": "Monday tip"
  }),
});
console.log(res.status, await res.json());
```

```python
import os, requests

r = requests.post(
    "https://postwire.io/api/recurring",
    headers={"Authorization": f"Bearer {os.environ['POSTWIRE_API_KEY']}"},
    json={
        "platforms": ["linkedin", "bluesky"],
        "variants": [
            "Monday tip: grind right before you brew.",
            "Monday tip: weigh your coffee, 1:16 is a good start.",
            "Monday tip: water just off the boil.",
        ],
        "rule": {
            "freq": "weekly",
            "days": ["mon"],
            "times": ["09:00"],
            "timezone": "America/Lima",
        },
        "guard_days": 7,
        "label": "Monday tip",
    },
)
print(r.status_code, r.json())
```

**Example response**

```json
{
  "ok": true,
  "recurring": {
    "id": "7c2a6f0e-5b1d-4c8a-9e3f-2d4b6a8c0e1f",
    "status": "active",
    "describe": "Every week on Mon at 09:00 (America/Lima), from 2026-10-05",
    "next": [
      {
        "at": "2026-10-05T14:00:00.000Z",
        "local": "2026-10-05 09:00",
        "n": 1,
        "variant": 1,
        "schedule_id": "c1a…",
        "status": "queued"
      }
    ]
  },
  "queued_now": 1,
  "warnings": []
}
```

## Preview a rule

`POST /api/recurring/preview`

The next occurrences of a rule (and which text each would use) and the warnings, without saving anything.

Needs an API key: `Authorization: Bearer pw_live_…`.

**Body (JSON)**

| Field | Type | Description |
|---|---|---|
| `rule` | object | When it repeats. Daylight saving: a time that does not exist on the day clocks go forward runs that much later (02:30 -> 03:30); a time that happens twice runs the first time. |
| `rule.freq` (required) | string | One of: daily, weekly, monthly. |
| `rule.interval` | integer | Every N days / weeks / months, counted from start. Default 1. 1 to 52. |
| `rule.days` | array of string | weekly: the weekdays (default: the start date's). One of: mon, tue, wed, thu, fri, sat, sun. |
| `rule.month_days` | array of integer | monthly: days of the month; -1 = the last day. 31 falls on the last day of shorter months. |
| `rule.times` | array of string | 24-hour HH:MM, 1-4 a day. Default 09:00. Up to 4 items. |
| `rule.timezone` | string | IANA name. Default: the brand's queue timezone, else UTC. |
| `rule.start` | string (date) | First day (default today); also the anchor of every N. |
| `rule.until` | string (date) | Last day, inclusive. |
| `rule.count` | integer | Stop after N occurrences. 1 to 1000. |
| `variants` | array of string |  |
| `guard_days` | integer |  |
| `brand_id` | string |  |
| `limit` | integer |  |

**Responses**

| Status | Means |
|---|---|
| `200` | { rule, describe, next: [{ at, local, n, variant }], warnings } |
| `400` | bad_rule |

```bash
curl -X POST "https://postwire.io/api/recurring/preview" \
  -H "Authorization: Bearer $POSTWIRE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "rule": {
    "freq": "monthly",
    "month_days": [
      -1
    ],
    "times": [
      "10:00"
    ],
    "timezone": "Europe/Madrid"
  },
  "variants": [
    "Order by tonight for next month'\''s box."
  ]
}'
```

```js
const res = await fetch("https://postwire.io/api/recurring/preview", {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.POSTWIRE_API_KEY}`, "Content-Type": "application/json" },
  body: JSON.stringify({
    "rule": {
      "freq": "monthly",
      "month_days": [
        -1
      ],
      "times": [
        "10:00"
      ],
      "timezone": "Europe/Madrid"
    },
    "variants": [
      "Order by tonight for next month's box."
    ]
  }),
});
console.log(res.status, await res.json());
```

```python
import os, requests

r = requests.post(
    "https://postwire.io/api/recurring/preview",
    headers={"Authorization": f"Bearer {os.environ['POSTWIRE_API_KEY']}"},
    json={
        "rule": {
            "freq": "monthly",
            "month_days": [-1],
            "times": ["10:00"],
            "timezone": "Europe/Madrid",
        },
        "variants": ["Order by tonight for next month's box."],
    },
)
print(r.status_code, r.json())
```

**Example response**

```json
{
  "describe": "Every month on the last day at 10:00 (Europe/Madrid), from 2026-10-05",
  "next": [
    {
      "local": "2026-10-31 10:00"
    },
    {
      "local": "2026-11-30 10:00"
    }
  ],
  "warnings": []
}
```

## List recurring posts

`GET /api/recurring`

Each with its rule in words (describe), its status (active, paused, ended), and its next occurrences: the ones in the queue with their schedule_id and status, then the later ones (not_yet_queued).

Needs an API key: `Authorization: Bearer pw_live_…`.

**Responses**

| Status | Means |
|---|---|
| `200` | { recurring: [{ id, label, platforms, variants, rule, describe, guard_days, status, paused_reason, occurrences, next_at, last_error, next: [...] }], plan: { name, limit, used }, setup_pending? } |

```bash
curl "https://postwire.io/api/recurring" \
  -H "Authorization: Bearer $POSTWIRE_API_KEY"
```

```js
const res = await fetch("https://postwire.io/api/recurring", {
  method: "GET",
  headers: { Authorization: `Bearer ${process.env.POSTWIRE_API_KEY}` },
});
console.log(res.status, await res.json());
```

```python
import os, requests

r = requests.get(
    "https://postwire.io/api/recurring",
    headers={"Authorization": f"Bearer {os.environ['POSTWIRE_API_KEY']}"},
)
print(r.status_code, r.json())
```

## A recurring post

`GET /api/recurring/{id}`

Needs an API key: `Authorization: Bearer pw_live_…`.

**Parameters**

| Field | Type | Description |
|---|---|---|
| `id` (required) | path, string (uuid) |  |

**Responses**

| Status | Means |
|---|---|
| `200` | { recurring (with next), history: [{ schedule_id, at, n, status, error }], warnings } |
| `404` | not_found |

```bash
curl "https://postwire.io/api/recurring/7c2a6f0e-5b1d-4c8a-9e3f-2d4b6a8c0e1f" \
  -H "Authorization: Bearer $POSTWIRE_API_KEY"
```

```js
const res = await fetch("https://postwire.io/api/recurring/7c2a6f0e-5b1d-4c8a-9e3f-2d4b6a8c0e1f", {
  method: "GET",
  headers: { Authorization: `Bearer ${process.env.POSTWIRE_API_KEY}` },
});
console.log(res.status, await res.json());
```

```python
import os, requests

r = requests.get(
    "https://postwire.io/api/recurring/7c2a6f0e-5b1d-4c8a-9e3f-2d4b6a8c0e1f",
    headers={"Authorization": f"Bearer {os.environ['POSTWIRE_API_KEY']}"},
)
print(r.status_code, r.json())
```

## Edit all occurrences, pause or resume

`PATCH /api/recurring/{id}`

Changing the content, the rule, the networks or the guard replaces every waiting occurrence that was not edited by hand; the edited ones stay. status paused cancels the waiting occurrences (not the edited ones); active puts them back.

Needs an API key: `Authorization: Bearer pw_live_…`.

**Parameters**

| Field | Type | Description |
|---|---|---|
| `id` (required) | path, string (uuid) |  |

**Body (JSON)**

| Field | Type | Description |
|---|---|---|
| `platforms` | array of string | One of: tiktok, instagram, youtube, linkedin, facebook, bluesky, mastodon, telegram, discord, x. |
| `text` | string | The post, when there is one text. |
| `variants` | array of any | Texts used in turn: occurrence n uses variant ((n-1) mod count) + 1. Up to 50 items. |
| `rule` | object | When it repeats. Daylight saving: a time that does not exist on the day clocks go forward runs that much later (02:30 -> 03:30); a time that happens twice runs the first time. |
| `rule.freq` (required) | string | One of: daily, weekly, monthly. |
| `rule.interval` | integer | Every N days / weeks / months, counted from start. Default 1. 1 to 52. |
| `rule.days` | array of string | weekly: the weekdays (default: the start date's). One of: mon, tue, wed, thu, fri, sat, sun. |
| `rule.month_days` | array of integer | monthly: days of the month; -1 = the last day. 31 falls on the last day of shorter months. |
| `rule.times` | array of string | 24-hour HH:MM, 1-4 a day. Default 09:00. Up to 4 items. |
| `rule.timezone` | string | IANA name. Default: the brand's queue timezone, else UTC. |
| `rule.start` | string (date) | First day (default today); also the anchor of every N. |
| `rule.until` | string (date) | Last day, inclusive. |
| `rule.count` | integer | Stop after N occurrences. 1 to 1000. |
| `guard_days` | integer | When an occurrence is published, a network where the same text went out in the last N days (3-hour margin) is skipped, with code duplicate_recent in its result. 0 = off. Default 3. 0 to 90. |
| `per_platform` | object |  |
| `media` | array of object |  |
| `photo_url` | string |  |
| `video_url` | string |  |
| `options` | object |  |
| `title` | string |  |
| `label` | string | Up to 120 characters. |
| `brand_id` | string (uuid) |  |
| `status` | string | One of: active, paused. |

**Responses**

| Status | Means |
|---|---|
| `200` | { ok, recurring, replaced, queued_now, warnings } |
| `404` | not_found |

```bash
curl -X PATCH "https://postwire.io/api/recurring/7c2a6f0e-5b1d-4c8a-9e3f-2d4b6a8c0e1f" \
  -H "Authorization: Bearer $POSTWIRE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "status": "paused"
}'
```

```js
const res = await fetch("https://postwire.io/api/recurring/7c2a6f0e-5b1d-4c8a-9e3f-2d4b6a8c0e1f", {
  method: "PATCH",
  headers: { Authorization: `Bearer ${process.env.POSTWIRE_API_KEY}`, "Content-Type": "application/json" },
  body: JSON.stringify({
    "status": "paused"
  }),
});
console.log(res.status, await res.json());
```

```python
import os, requests

r = requests.patch(
    "https://postwire.io/api/recurring/7c2a6f0e-5b1d-4c8a-9e3f-2d4b6a8c0e1f",
    headers={"Authorization": f"Bearer {os.environ['POSTWIRE_API_KEY']}"},
    json={
        "status": "paused",
    },
)
print(r.status_code, r.json())
```

## Delete a recurring post

`DELETE /api/recurring/{id}`

Its waiting occurrences are canceled (and ones waiting for approval withdrawn), unless ?keep_queued=1.

Needs an API key: `Authorization: Bearer pw_live_…`.

**Parameters**

| Field | Type | Description |
|---|---|---|
| `id` (required) | path, string (uuid) |  |
| `keep_queued` | query, string |  |

**Responses**

| Status | Means |
|---|---|
| `200` | { ok, canceled } |
| `404` | not_found |

```bash
curl -X DELETE "https://postwire.io/api/recurring/7c2a6f0e-5b1d-4c8a-9e3f-2d4b6a8c0e1f" \
  -H "Authorization: Bearer $POSTWIRE_API_KEY"
```

```js
const res = await fetch("https://postwire.io/api/recurring/7c2a6f0e-5b1d-4c8a-9e3f-2d4b6a8c0e1f", {
  method: "DELETE",
  headers: { Authorization: `Bearer ${process.env.POSTWIRE_API_KEY}` },
});
console.log(res.status, await res.json());
```

```python
import os, requests

r = requests.delete(
    "https://postwire.io/api/recurring/7c2a6f0e-5b1d-4c8a-9e3f-2d4b6a8c0e1f",
    headers={"Authorization": f"Bearer {os.environ['POSTWIRE_API_KEY']}"},
)
print(r.status_code, r.json())
```

## Preview a feed

`POST /api/rss/preview`

Reads the feed (with the same protections) and returns its title, its newest items and what each network's post of the newest would read. Nothing is saved. 30 an hour.

Needs an API key: `Authorization: Bearer pw_live_…`.

**Body (JSON)**

| Field | Type | Description |
|---|---|---|
| `url` (required) | string | RSS 2.0, RSS 1.0 or Atom. http or https on ports 80, 443, 8080 or 8443; private, loopback, link-local and metadata addresses are refused (400 feed_url_blocked), also when a name resolves to one or a redirect points at one; at most 3 redirects, 8 seconds, 2 MB. |
| `platforms` | array of string | One of: tiktok, instagram, youtube, linkedin, facebook, bluesky, mastodon, telegram, discord, x. |
| `template` | string | {title}, {link}, {summary}. Fitted to each network's limit as that network counts it: the summary is shortened first, then the title; the link is never cut. Default "{title}\n\n{link}". Up to 2000 characters. |

**Responses**

| Status | Means |
|---|---|
| `200` | { ok, url, kind, title, items_in_feed, items, posts: { <network>: { text, trimmed? } }, note } |
| `400` | feed_url_blocked, bad_feed_url |
| `422` | not_a_feed, feed_http_error, feed_timeout, feed_too_large, feed_too_many_redirects |

```bash
curl -X POST "https://postwire.io/api/rss/preview" \
  -H "Authorization: Bearer $POSTWIRE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "url": "https://bakery.example/feed.xml",
  "platforms": [
    "linkedin",
    "x"
  ],
  "template": "{title}\n\n{link}"
}'
```

```js
const res = await fetch("https://postwire.io/api/rss/preview", {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.POSTWIRE_API_KEY}`, "Content-Type": "application/json" },
  body: JSON.stringify({
    "url": "https://bakery.example/feed.xml",
    "platforms": [
      "linkedin",
      "x"
    ],
    "template": "{title}\n\n{link}"
  }),
});
console.log(res.status, await res.json());
```

```python
import os, requests

r = requests.post(
    "https://postwire.io/api/rss/preview",
    headers={"Authorization": f"Bearer {os.environ['POSTWIRE_API_KEY']}"},
    json={
        "url": "https://bakery.example/feed.xml",
        "platforms": ["linkedin", "x"],
        "template": "{title}\n\n{link}",
    },
)
print(r.status_code, r.json())
```

## Add an RSS feed

`POST /api/rss`

Feeds per account: Free 3, Starter 25, Pro 100, Agency 500, Scale 2,000. The first read happens now and only records what is already in the feed. Each new item (new by its guid AND its link) becomes a post, queued now or in the brand's next free slot, or sent to approvals; brand approval rules for automations apply too. A feed is turned off after 10 failed reads in a row.

Needs an API key: `Authorization: Bearer pw_live_…`.

**Body (JSON)**

| Field | Type | Description |
|---|---|---|
| `url` (required) | string | RSS 2.0, RSS 1.0 or Atom. http or https on ports 80, 443, 8080 or 8443; private, loopback, link-local and metadata addresses are refused (400 feed_url_blocked), also when a name resolves to one or a redirect points at one; at most 3 redirects, 8 seconds, 2 MB. |
| `platforms` (required) | array of string | Not YouTube or TikTok (video only). Instagram gets an item only when it has an image. One of: tiktok, instagram, youtube, linkedin, facebook, bluesky, mastodon, telegram, discord, x. |
| `template` | string | {title}, {link}, {summary}. Fitted to each network's limit as that network counts it: the summary is shortened first, then the title; the link is never cut. Default "{title}\n\n{link}". Up to 2000 characters. |
| `interval_minutes` | integer | Default 60. 30 to 10080. |
| `max_items` | integer | New items posted per read, oldest first; the rest wait for the next read. Default 3. 1 to 10. |
| `mode` | string | approval: every new item waits for approval (paid plans). One of: auto, approval. Default "auto". |
| `post_at` | string | One of: now, next_slot. Default "now". |
| `include_image` | boolean | Default true. |
| `ai_rewrite` | boolean | Write each network's post with AI: one of the plan's AI drafts a day per item, never an extra charge; the template when they are used up. Default false. |
| `post_latest` | boolean | Also post the newest item now. Otherwise the items already in the feed are recorded, not posted. Default false. |
| `enabled` | boolean |  |
| `brand_id` | string (uuid) |  |

**Responses**

| Status | Means |
|---|---|
| `201` | { ok, feed, first_read: { primed? \| posted? \| error? }, message } |
| `400` | feed_url_blocked, bad_feed_url, bad_template, media_required, not_connected |
| `403` | plan_feature, rss_feed_limit |
| `409` | rss_feed_exists |
| `503` | automation_unavailable |

```bash
curl -X POST "https://postwire.io/api/rss" \
  -H "Authorization: Bearer $POSTWIRE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "url": "https://bakery.example/feed.xml",
  "platforms": [
    "linkedin",
    "bluesky"
  ],
  "template": "{title}\n\n{summary}\n\n{link}",
  "interval_minutes": 60,
  "max_items": 3,
  "mode": "auto",
  "post_at": "next_slot"
}'
```

```js
const res = await fetch("https://postwire.io/api/rss", {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.POSTWIRE_API_KEY}`, "Content-Type": "application/json" },
  body: JSON.stringify({
    "url": "https://bakery.example/feed.xml",
    "platforms": [
      "linkedin",
      "bluesky"
    ],
    "template": "{title}\n\n{summary}\n\n{link}",
    "interval_minutes": 60,
    "max_items": 3,
    "mode": "auto",
    "post_at": "next_slot"
  }),
});
console.log(res.status, await res.json());
```

```python
import os, requests

r = requests.post(
    "https://postwire.io/api/rss",
    headers={"Authorization": f"Bearer {os.environ['POSTWIRE_API_KEY']}"},
    json={
        "url": "https://bakery.example/feed.xml",
        "platforms": ["linkedin", "bluesky"],
        "template": "{title}\n\n{summary}\n\n{link}",
        "interval_minutes": 60,
        "max_items": 3,
        "mode": "auto",
        "post_at": "next_slot",
    },
)
print(r.status_code, r.json())
```

**Example response**

```json
{
  "ok": true,
  "feed": {
    "id": "7c2a6f0e-5b1d-4c8a-9e3f-2d4b6a8c0e1f",
    "url": "https://bakery.example/feed.xml",
    "title": "Bakery journal",
    "interval_minutes": 60
  },
  "first_read": {
    "primed": 12
  },
  "message": "Saved. 12 items already in the feed were recorded and not posted. New items are posted as the feed publishes them (read every 60 minutes)."
}
```

## List RSS feeds

`GET /api/rss`

Needs an API key: `Authorization: Bearer pw_live_…`.

**Responses**

| Status | Means |
|---|---|
| `200` | { feeds: [{ id, url, title, platforms, template, mode, post_at, interval_minutes, max_items, enabled, last_checked_at, last_status, last_error, failures, items_posted, next_check_at }], plan: { limit, used, ai_drafts_a_day, min_interval_minutes, approvals } } |

```bash
curl "https://postwire.io/api/rss" \
  -H "Authorization: Bearer $POSTWIRE_API_KEY"
```

```js
const res = await fetch("https://postwire.io/api/rss", {
  method: "GET",
  headers: { Authorization: `Bearer ${process.env.POSTWIRE_API_KEY}` },
});
console.log(res.status, await res.json());
```

```python
import os, requests

r = requests.get(
    "https://postwire.io/api/rss",
    headers={"Authorization": f"Bearer {os.environ['POSTWIRE_API_KEY']}"},
)
print(r.status_code, r.json())
```

## Change, pause or turn on a feed

`PATCH /api/rss/{id}`

Needs an API key: `Authorization: Bearer pw_live_…`.

**Parameters**

| Field | Type | Description |
|---|---|---|
| `id` (required) | path, string (uuid) |  |

**Body (JSON)**

| Field | Type | Description |
|---|---|---|
| `url` | string | RSS 2.0, RSS 1.0 or Atom. http or https on ports 80, 443, 8080 or 8443; private, loopback, link-local and metadata addresses are refused (400 feed_url_blocked), also when a name resolves to one or a redirect points at one; at most 3 redirects, 8 seconds, 2 MB. |
| `platforms` | array of string | Not YouTube or TikTok (video only). Instagram gets an item only when it has an image. One of: tiktok, instagram, youtube, linkedin, facebook, bluesky, mastodon, telegram, discord, x. |
| `template` | string | {title}, {link}, {summary}. Fitted to each network's limit as that network counts it: the summary is shortened first, then the title; the link is never cut. Default "{title}\n\n{link}". Up to 2000 characters. |
| `interval_minutes` | integer | Default 60. 30 to 10080. |
| `max_items` | integer | New items posted per read, oldest first; the rest wait for the next read. Default 3. 1 to 10. |
| `mode` | string | approval: every new item waits for approval (paid plans). One of: auto, approval. Default "auto". |
| `post_at` | string | One of: now, next_slot. Default "now". |
| `include_image` | boolean | Default true. |
| `ai_rewrite` | boolean | Write each network's post with AI: one of the plan's AI drafts a day per item, never an extra charge; the template when they are used up. Default false. |
| `post_latest` | boolean | Also post the newest item now. Otherwise the items already in the feed are recorded, not posted. Default false. |
| `enabled` | boolean |  |
| `brand_id` | string (uuid) |  |

**Responses**

| Status | Means |
|---|---|
| `200` | { ok, feed } |
| `404` | not_found |

```bash
curl -X PATCH "https://postwire.io/api/rss/7c2a6f0e-5b1d-4c8a-9e3f-2d4b6a8c0e1f" \
  -H "Authorization: Bearer $POSTWIRE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "enabled": false
}'
```

```js
const res = await fetch("https://postwire.io/api/rss/7c2a6f0e-5b1d-4c8a-9e3f-2d4b6a8c0e1f", {
  method: "PATCH",
  headers: { Authorization: `Bearer ${process.env.POSTWIRE_API_KEY}`, "Content-Type": "application/json" },
  body: JSON.stringify({
    "enabled": false
  }),
});
console.log(res.status, await res.json());
```

```python
import os, requests

r = requests.patch(
    "https://postwire.io/api/rss/7c2a6f0e-5b1d-4c8a-9e3f-2d4b6a8c0e1f",
    headers={"Authorization": f"Bearer {os.environ['POSTWIRE_API_KEY']}"},
    json={
        "enabled": False,
    },
)
print(r.status_code, r.json())
```

## Remove a feed

`DELETE /api/rss/{id}`

Posts it already queued stay in the queue.

Needs an API key: `Authorization: Bearer pw_live_…`.

**Parameters**

| Field | Type | Description |
|---|---|---|
| `id` (required) | path, string (uuid) |  |

**Responses**

| Status | Means |
|---|---|
| `200` | { ok } |
| `404` | not_found |

```bash
curl -X DELETE "https://postwire.io/api/rss/7c2a6f0e-5b1d-4c8a-9e3f-2d4b6a8c0e1f" \
  -H "Authorization: Bearer $POSTWIRE_API_KEY"
```

```js
const res = await fetch("https://postwire.io/api/rss/7c2a6f0e-5b1d-4c8a-9e3f-2d4b6a8c0e1f", {
  method: "DELETE",
  headers: { Authorization: `Bearer ${process.env.POSTWIRE_API_KEY}` },
});
console.log(res.status, await res.json());
```

```python
import os, requests

r = requests.delete(
    "https://postwire.io/api/rss/7c2a6f0e-5b1d-4c8a-9e3f-2d4b6a8c0e1f",
    headers={"Authorization": f"Bearer {os.environ['POSTWIRE_API_KEY']}"},
)
print(r.status_code, r.json())
```

## Read a feed now

`POST /api/rss/{id}/check`

Needs an API key: `Authorization: Bearer pw_live_…`.

**Parameters**

| Field | Type | Description |
|---|---|---|
| `id` (required) | path, string (uuid) |  |

**Responses**

| Status | Means |
|---|---|
| `200` | { ok, result: { new, posted, results } \| { not_modified } \| { error }, feed } |
| `409` | feed_busy |
| `429` | 6 an hour per feed |

```bash
curl -X POST "https://postwire.io/api/rss/7c2a6f0e-5b1d-4c8a-9e3f-2d4b6a8c0e1f/check" \
  -H "Authorization: Bearer $POSTWIRE_API_KEY"
```

```js
const res = await fetch("https://postwire.io/api/rss/7c2a6f0e-5b1d-4c8a-9e3f-2d4b6a8c0e1f/check", {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.POSTWIRE_API_KEY}` },
});
console.log(res.status, await res.json());
```

```python
import os, requests

r = requests.post(
    "https://postwire.io/api/rss/7c2a6f0e-5b1d-4c8a-9e3f-2d4b6a8c0e1f/check",
    headers={"Authorization": f"Bearer {os.environ['POSTWIRE_API_KEY']}"},
)
print(r.status_code, r.json())
```

## What happened to a feed's items

`GET /api/rss/{id}/items`

Needs an API key: `Authorization: Bearer pw_live_…`.

**Parameters**

| Field | Type | Description |
|---|---|---|
| `id` (required) | path, string (uuid) |  |
| `limit` | query, integer |  |

**Responses**

| Status | Means |
|---|---|
| `200` | { feed, items: [{ item_id, title, link, published_at, status: scheduled \| pending_approval \| skipped \| failed, schedule_id, note, seen_at }] } |

```bash
curl "https://postwire.io/api/rss/7c2a6f0e-5b1d-4c8a-9e3f-2d4b6a8c0e1f/items" \
  -H "Authorization: Bearer $POSTWIRE_API_KEY"
```

```js
const res = await fetch("https://postwire.io/api/rss/7c2a6f0e-5b1d-4c8a-9e3f-2d4b6a8c0e1f/items", {
  method: "GET",
  headers: { Authorization: `Bearer ${process.env.POSTWIRE_API_KEY}` },
});
console.log(res.status, await res.json());
```

```python
import os, requests

r = requests.get(
    "https://postwire.io/api/rss/7c2a6f0e-5b1d-4c8a-9e3f-2d4b6a8c0e1f/items",
    headers={"Authorization": f"Bearer {os.environ['POSTWIRE_API_KEY']}"},
)
print(r.status_code, r.json())
```
