# Publishing at volume

You have a pipeline producing videos and a set of accounts on 0bull phones. This page is
the shortest path from "file on disk" to "posted", for a script that runs unattended.

Everything here needs a token with the `submissions` ability. See the
[introduction](/introduction#the-rest-api) for creating one, and the
[API reference](/api) for every field.

## The loop

Per video, a pipeline makes at most three calls:

1. **Hand over the video**, by URL, signed upload, or multipart.
2. **Create the submission** (one account) or **the batch** (many accounts), optionally
   with `scheduled_at` and `webhook_url`.
3. **Learn the outcome**: wait for the [webhook](/rest/webhooks), or poll.

```bash
# 1 + 2 in one call: the file lives on object storage, so hand over a link.
curl https://0bull.net/api/v1/submissions/batches \
  -H "Authorization: Bearer <your-token>" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{
    "destinations": [
      {"platform": "tiktok",    "account_id": "019feb70-07f7-71a4-a84e-e33b56837a1d"},
      {"platform": "instagram", "account_id": "019feb70-0a1c-7c3e-9b2f-4d8e6a1b3c55"},
      {"platform": "youtube",   "account_id": "019feb70-0c44-7e9a-8d1b-2f7c9e4a6b88"}
    ],
    "video_url": "https://cdn.example.com/clips/2026-09-22/clip-0412.mp4?X-Amz-Signature=...",
    "caption": "new drop",
    "youtube_title": "New drop",
    "scheduled_at": "2026-09-23T18:00:00Z",
    "webhook_url": "https://pipeline.example.com/hooks/0bull"
  }'
```

That is one request for three posts on three platforms, held until 18:00 UTC tomorrow,
each of which calls you back when it finishes.

## Handing over the video

Both `POST /v1/submissions` and `POST /v1/submissions/batches` take the video in exactly
one of three ways. Sending two is a `422`.

| Field | When to use it | Limits |
|---|---|---|
| `video_url` | Files already on object storage or a CDN. Hand over a pre-signed link; 0bull downloads it. | https only. Redirects are not followed. Private and loopback addresses are refused. |
| `upload_id` | Files that are not reachable from the internet. `POST /v1/uploads` returns a signed `upload_url` valid for 15 minutes; `PUT` or `POST` the bytes there, then submit with the `upload_id`. | 60 uploads per minute. |
| `video` | Small clips from a script that has the file in hand. A multipart field. | Same size limit; the whole request is one upload. |

All three accept MP4 or MOV up to 200 MB.

:::tip{title="One upload, many accounts"}
An `upload_id` is consumed by the submission that uses it, so it cannot be reused for a
second account. To post the same clip to several accounts from one upload, use the batch
endpoint below.
:::

## One clip, many accounts

`POST /v1/submissions/batches` creates one submission per entry in `destinations`, from a
single video, across TikTok, Instagram and YouTube in the same call. Each destination is a
`{platform, account_id}` pair; an account may appear once.

```json
{
  "destinations": [
    {"platform": "tiktok", "account_id": "..."},
    {"platform": "tiktok", "account_id": "..."},
    {"platform": "youtube", "account_id": "..."}
  ],
  "upload_id": "8f0e3c2a-6d5b-4e1f-9a7c-1b2d3e4f5a6b",
  "caption": "posted on TikTok and Instagram",
  "youtube_title": "Posted as the Short's title",
  "draft": false
}
```

Text is per platform. `caption` (up to 2200 characters) goes to TikTok and Instagram.
`youtube_title` (up to 100) becomes the Short's title on YouTube, and is required whenever
a destination is on YouTube.

The response is `201` with the batch and its submissions:

```json
{
  "data": {
    "id": 731,
    "caption": "posted on TikTok and Instagram",
    "youtube_title": "Posted as the Short's title",
    "draft": false,
    "scheduled_at": null,
    "webhook_url": null,
    "running": true,
    "submissions": [
      {"id": 4821, "platform": "tiktok", "account_id": "...", "batch_id": 731, "status": "pending", "...": "..."},
      {"id": 4822, "platform": "tiktok", "account_id": "...", "batch_id": 731, "status": "pending", "...": "..."},
      {"id": 4823, "platform": "youtube", "account_id": "...", "batch_id": 731, "status": "pending", "...": "..."}
    ],
    "created_at": "2026-09-22T16:40:12+00:00",
    "updated_at": "2026-09-22T16:40:12+00:00"
  }
}
```

Every submission in it is an ordinary submission: it shows up in `GET /v1/submissions`,
can be polled at `GET /v1/submissions/{submission}`, and cancelled on its own. The batch
adds two things:

- `GET /v1/submissions/batches/{batch}` returns the whole drop, with `running` set while
  any post is still scheduled, queued or on a phone.
- `POST /v1/submissions/batches/{batch}/cancel` stops every post that has not finished and
  leaves the ones that already landed alone. It never fails on a partly finished drop.

Validation is all or nothing. If any destination is unknown, listed twice, or on an account
without a phone, the call is a `422` keyed on that entry (`destinations.1.account_id`) and
nothing is created.

Up to 50 destinations per call.

## Scheduling

Both endpoints take `scheduled_at`, an ISO 8601 timestamp in the future. Omit it to publish
immediately.

A scheduled submission is created with status `scheduled` and does nothing until that time.
It can be cancelled until it starts; once it moves to `pending` it is treated like any other
queued post. On a batch, every post shares the same `scheduled_at`.

0bull does not spread posts out for you. A pipeline that wants "one post per account every
two hours" computes the timestamps itself and sends one request per slot in the plan.

## Learning the outcome

Publishing happens on a real phone after the request returns, so the status you get back is
never final. There are two ways to learn how it went:

- **Webhooks.** Pass `webhook_url` and 0bull POSTs to it once per submission when that
  submission reaches `published`, `drafted`, `failed` or `cancelled`. See
  [Webhooks](/rest/webhooks) for the payload and how to verify the signature. This is the
  right choice for a pipeline: no polling loop, no missed transitions.
- **Polling.** `GET /v1/submissions/{submission}` until `status` is one of those four. A
  post typically takes one to three minutes on the phone; poll every 5 to 15 seconds, and
  give up after 15 minutes. The [status table](/mcp/overview#submission-status) lists every
  value.

When `status` is `failed`, `failure.step` names the stage that failed and `failure.message`
says what to do. Retrying is your call: create a new submission with the same video.

## Rate limits and pacing

The API allows 250 requests per minute per token, which is far more than a phone can post.
A phone posts one video at a time and each post takes a few minutes, so the practical
ceiling is set by how many phones you have, not by the API. Submissions to the same phone
queue up and run in order.

Batches count as one request no matter how many destinations they carry.
