# Submissions over WebSocket

A submission is one video published to one account. Create it, then watch its `status` until
it settles.

All six funs need the `submissions` ability. See [Frames](/websocket/frames) for the request
and reply envelope, [Overview](/websocket/overview) for the status table, and
[Events](/websocket/events) for the `submission` push that saves you polling.

| Fun | Data fields | Reply |
|---|---|---|
| `/app/submissions/list` | `page`, `platform` | `200` with `{data, meta}` |
| `/app/submissions/get` | `submission` | `200` with one submission |
| `/app/submissions/create` | `platform`, `account`, one of `video_url` or `upload_id`, `caption`, `draft` | `201` with the created submission |
| `/app/submissions/cancel` | `submission` | `200` with the cancelled submission |
| `/app/submissions/delete` | `submission` | `204` with `data: null` |
| `/app/submissions/upload-url` | *(none)* | `201` with `{upload_id, upload_url, expires_at}` |

## The submission shape

```json
{
  "id": 4821,
  "platform": "tiktok",
  "account_id": "6f2c8a41-95c1-4a2e-8f64-0b8d5e1c7a93",
  "caption": "new drop",
  "draft": false,
  "status": "pending",
  "attempts": 0,
  "failure": null,
  "started_at": null,
  "finished_at": null,
  "created_at": "2026-02-11T09:20:00+00:00",
  "updated_at": "2026-02-11T09:20:00+00:00"
}
```

A submission id is an **integer**, not a UUID. `failure` is `null` unless `status` is
`failed`, in which case it is `{"step": "...", "message": "..."}` naming the stage that failed
and what to do next.

## List submissions

| Field | Type | | Description |
|---|---|---|---|
| `page` | integer | optional | Page number, 1 or more. Defaults to 1. |
| `platform` | string | optional | Narrow to `tiktok`, `instagram` or `youtube`. |

The reply is `{"data": [...], "meta": {"current_page", "last_page", "per_page", "total"}}`,
50 submissions a page, newest first.

## Get a submission

| Field | Type | | Description |
|---|---|---|---|
| `submission` | integer | **required** | Submission id. |

This is the polling fun. Read `status` against the
[status table](/websocket/overview).

## Create a submission

| Field | Type | | Description |
|---|---|---|---|
| `platform` | string | optional | `tiktok`, `instagram` or `youtube`. Defaults to `tiktok`. |
| `account` | string | **required** | Account UUID to post as. |
| `video_url` | string | *one of* | Public https URL of the video. The server downloads it. |
| `upload_id` | string | *one of* | Id from `/app/submissions/upload-url`. |
| `caption` | string | *see below* | The post's text. |
| `draft` | boolean | optional | Save as a draft on the phone instead of publishing. |

The field is `account`, not `account_id`.

Give **exactly one** of `video_url` or `upload_id`. There is no multipart over a socket, so a
local file goes through the upload URL flow below. Sending both is refused with `422`, and so
is sending neither.

`caption` is the caption on TikTok and Instagram, optional and up to 2200 characters. On
YouTube it is the Short's **title**: required, and capped at 100 characters. Leaving it out on
YouTube is refused with
`Give the Short a title. YouTube names an untitled Short after the date.`

The account is looked up on the requested platform only. An id from another platform reads the
same as one that does not exist, so a `platform` that does not match the account answers `404`.

The account must already have a phone assigned. If it does not, the reply is `422` on the
`account` field with `This account is not assigned to a phone yet, so it cannot post.`

The reply is `201` with the submission in `pending`. The phone is then driven through the
platform's app.

## Cancel a submission

| Field | Type | | Description |
|---|---|---|---|
| `submission` | integer | **required** | Submission id. |

This stops the phone between steps. A submission that has not started yet is simply marked
cancelled. One that already finished answers `409` with
`This submission has already finished.` One you cannot see answers `404` with
`Submission not found.`

## Delete a submission

| Field | Type | | Description |
|---|---|---|---|
| `submission` | integer | **required** | Submission id. |

Deletes a finished submission and its stored video. The reply is `204` with `data: null`.

A submission still in flight cannot be deleted. Cancel it first, or you get `409` with
`Submissions can only be deleted once they have finished processing.`

## Upload URL

`/app/submissions/upload-url` takes no fields. It mints a short-lived signed URL you can push
a video to.

```json
→ { "fun": "/app/submissions/upload-url", "msgid": "1", "data": {} }
← { "fun": "/app/submissions/upload-url", "msgid": "1", "status": 201,
    "data": { "upload_id": "9a1f7b2d-3c41-4e85-b0d7-6f2e9c4a1b58",
              "upload_url": "https://0bull.net/mcp/uploads/9a1f7b2d-.../41?...",
              "expires_at": "2026-02-11T09:35:00+00:00" } }
```

Send the video to `upload_url` **within 15 minutes**, then pass `upload_id` to
`/app/submissions/create`.

The signature in the URL is the grant. The upload endpoint takes no bearer token, and the URL
carries the owning account so `/app/submissions/create` can check the upload is yours.

Either shape works, `POST` or `PUT`:

```bash
# raw bytes
curl -X PUT "$UPLOAD_URL" --data-binary @clip.mp4

# or a multipart field named video
curl -X POST "$UPLOAD_URL" -F video=@clip.mp4
```

The file must be MP4 or QuickTime. The endpoint answers `201` with `{"upload_id": "..."}`, or
`422` with `No video was uploaded.`, `Video exceeds the maximum allowed size.` or
`Video must be an MP4 or QuickTime file.`

Passing an `upload_id` that was never filled is refused at create time with `422` on the
`upload_id` field: `Upload not found. Upload the video to the signed URL first.`

## Worked example: upload a file and publish it

Both SDKs take `account_id` as the argument name and send it over the socket as `account`.
The raw frames are the create and the poll, after the file has been pushed to `upload_url`.

<CodeTabs syncKey="lang">

```python title="Python"
from zerobull import ZeroBull

with ZeroBull() as client, client.socket() as socket:
    account = socket.accounts.list(platform="tiktok")[0]

    upload = socket.uploads.create()
    print(upload.upload_id, upload.upload_url, upload.expires_at)

    queued = socket.submissions.create(
        account_id=account.id,
        upload_id=upload.upload_id,
        caption="new drop",
    )
    print(queued.id, queued.status)

    submission = socket.submissions.wait(queued, timeout=900, interval=5)
    if submission.status == "failed" and submission.failure is not None:
        print(submission.failure.step, submission.failure.message)
    else:
        print(submission.status)
```

```typescript title="TypeScript"
import { ZeroBull } from "@0bull/sdk";

const client = new ZeroBull();
await using socket = client.socket();
await socket.connect();

const accounts = await socket.accounts.list({ platform: "tiktok" });
const account = accounts.items[0]!;

const upload = await socket.uploads.create();
console.log(upload.upload_id, upload.upload_url, upload.expires_at);

const queued = await socket.submissions.create({
  account_id: account.id,
  upload_id: upload.upload_id,
  caption: "new drop",
});
console.log(queued.id, queued.status);

const submission = await socket.submissions.wait(queued, {
  timeout: 900_000,
  interval: 5_000,
});
if (submission.status === "failed" && submission.failure) {
  console.log(submission.failure.step, submission.failure.message);
} else {
  console.log(submission.status);
}
```

```json title="Raw frames"
→ { "fun": "/app/submissions/create", "msgid": "2",
    "data": { "account": "6f2c8a41-...", "upload_id": "9a1f7b2d-...",
              "caption": "new drop" } }
← { "fun": "/app/submissions/create", "msgid": "2", "status": 201,
    "data": { "id": 4821, "platform": "tiktok", "account_id": "6f2c8a41-...",
              "caption": "new drop", "draft": false, "status": "pending", "attempts": 0,
              "failure": null, "started_at": null, "finished_at": null,
              "created_at": "2026-02-11T09:20:00+00:00",
              "updated_at": "2026-02-11T09:20:00+00:00" } }

→ { "fun": "/app/submissions/get", "msgid": "3", "data": { "submission": 4821 } }
← { "fun": "/app/submissions/get", "msgid": "3", "status": 200,
    "data": { "id": 4821, "platform": "tiktok", "account_id": "6f2c8a41-...",
              "caption": "new drop", "draft": false, "status": "published", "attempts": 1,
              "failure": null, "started_at": "2026-02-11T09:20:04+00:00",
              "finished_at": "2026-02-11T09:21:26+00:00",
              "created_at": "2026-02-11T09:20:00+00:00",
              "updated_at": "2026-02-11T09:21:26+00:00" } }
```

</CodeTabs>

Both SDKs also accept a local file directly. Over a socket they mint the upload URL, push the
bytes over HTTPS and then send `upload_id` for you:

<CodeTabs syncKey="lang">

```python title="Python"
from zerobull import ZeroBull

with ZeroBull() as client, client.socket() as socket:
    account = socket.accounts.list(platform="tiktok")[0]

    submission = socket.submissions.create(
        account_id=account.id,
        video="clip.mp4",
        caption="new drop",
    )
    print(submission.id, submission.status)

    socket.submissions.cancel(submission.id)
    socket.submissions.delete(submission.id)
```

```typescript title="TypeScript"
import { ZeroBull } from "@0bull/sdk";

const client = new ZeroBull();
await using socket = client.socket();
await socket.connect();

const accounts = await socket.accounts.list({ platform: "tiktok" });
const account = accounts.items[0]!;

const submission = await socket.submissions.create({
  account_id: account.id,
  video: "clip.mp4",
  caption: "new drop",
});
console.log(submission.id, submission.status);

await socket.submissions.cancel(submission.id);
await socket.submissions.delete(submission.id);
```

</CodeTabs>
