Source: https://developers.tokportal.com/create-comments/
Markdown: https://developers.tokportal.com/create-comments.md

# Create Comments

```
POST /api/ext/comments
```

Create one comment task or up to **200 in a single call**. Each accepted task costs **1 credit**. Validation happens before billing; rejected rows are never debited. The accepted-task debit and all accepted task inserts commit atomically.

Send an `Idempotency-Key` header on every create call. Reuse the same key only for an exact retry with the same method, path and body.

## Target types

Each task targets one of two things, set with `target_type`:

| `target_type`     | What the manager does                           | Required URL field   | Platforms         |
| ----------------- | ----------------------------------------------- | -------------------- | ----------------- |
| `video` (default) | Posts a new comment under a video               | `target_video_url`   | TikTok, Instagram |
| `comment`         | Replies to a **specific comment** under a video | `target_comment_url` | TikTok only       |

If you omit `target_type`, it defaults to `video` (the legacy behaviour) — existing integrations keep working unchanged.

For a **comment target**, paste the comment deep-link copied from the TikTok app (**Share comment → Copy link**). Both the short form (`https://www.tiktok.com/t/XXXX/`) and the full form (`...?share_comment_id=...`) work — the link is resolved and validated server-side at **no extra cost** (no scraping).

> **NOTE: Short links are best-effort**
> TikTok occasionally bounces short comment-links (`t/…`) when resolved from a server, so we can't always extract the exact comment id up-front. When that happens the task is **still created** (we trust your explicit `comment` choice) and auto-verification runs best-effort. **Make sure your link actually opens a comment.** If it doesn't, the assigned manager flags it as "not a comment" → the task is cancelled and your credit is **refunded**. Passing the full `...?share_comment_id=...` URL always resolves cleanly and enables exact verification.

## Single task

Send a single object:

```bash
curl -X POST https://app.tokportal.com/api/ext/comments \
  -H "X-API-Key: sk_xxx" \
  -H "Idempotency-Key: comments-create-018f4a1a" \
  -H "Content-Type: application/json" \
  -d '{
    "saved_account_id": "9f3a7b2e-1c4d-4e8f-a5b6-7d9e0f1a2b3c",
    "target_video_url": "https://www.tiktok.com/@someone/video/7000000000000000000",
    "comment_text": "Love this technique ✨"
  }'
```

**Success (201):**

```json
{
  "data": {
    "created": [
      {
        "id": "0d8b5a3e-92c4-4111-9a7d-3e2f1a2b3c4d",
        "status": "pending",
        "platform": "tiktok",
        "saved_account_id": "9f3a7b2e-1c4d-4e8f-a5b6-7d9e0f1a2b3c",
        "target_type": "video",
        "target_video_url": "https://www.tiktok.com/@someone/video/7000000000000000000",
        "target_comment_url": null,
        "target_comment_id": null,
        "target_author_handle": "someone",
        "comment_text": "Love this technique ✨",
        "cm_payout_amount": 0.15,
        "submitted_at": null,
        "verified_at": null,
        "manually_confirmed_at": null,
        "client_dispute_deadline_at": null,
        "finalized_at": null,
        "correction_required": null,
        "deadline_at": "2026-04-30T17:21:00Z",
        "execution_blocked": false,
        "execution_blocked_at": null,
        "execution_block_reason": null,
        "created_at": "2026-04-27T17:21:00Z",
        "updated_at": "2026-04-27T17:21:00Z"
      }
    ],
    "rejected": []
  },
  "created_count": 1,
  "rejected_count": 0,
  "credits_charged": 1
}
```

On a single-task call, **validation errors return a typed 4xx** instead of a `rejected` array, so you can branch on `error.code` without parsing the body twice. See [error codes](https://developers.tokportal.com/comments-overview#error-handling).

## Reply to a comment (comment target)

Set `target_type: "comment"` and pass `target_comment_url` instead of `target_video_url`:

```bash
curl -X POST https://app.tokportal.com/api/ext/comments \
  -H "X-API-Key: sk_xxx" \
  -H "Idempotency-Key: comments-reply-018f4a1b" \
  -H "Content-Type: application/json" \
  -d '{
    "saved_account_id": "9f3a7b2e-1c4d-4e8f-a5b6-7d9e0f1a2b3c",
    "target_type": "comment",
    "target_comment_url": "https://www.tiktok.com/t/ZT9j5AXVsyuRS/",
    "comment_text": "100% agree with this!"
  }'
```

The resolved task echoes both the underlying video URL and the comment it targets:

```json
{
  "data": {
    "created": [
      {
        "id": "7b1c...",
        "status": "pending",
        "platform": "tiktok",
        "target_type": "comment",
        "target_video_url": "https://www.tiktok.com/@someone/video/7650858712323656974",
        "target_comment_url": "https://www.tiktok.com/@someone/video/7650858712323656974?share_comment_id=7651510351893316372",
        "target_comment_id": "7651510351893316372",
        "target_author_handle": "someone",
        "comment_text": "100% agree with this!",
        "...": "..."
      }
    ],
    "rejected": []
  },
  "created_count": 1,
  "rejected_count": 0,
  "credits_charged": 1
}
```

## Batch (up to 200)

Wrap in `{ "tasks": [...] }`:

```bash
curl -X POST https://app.tokportal.com/api/ext/comments \
  -H "X-API-Key: sk_xxx" \
  -H "Idempotency-Key: comments-batch-018f4a1c" \
  -H "Content-Type: application/json" \
  -d '{
    "tasks": [
      {
        "saved_account_id": "9f3a7b2e-1c4d-4e8f-a5b6-7d9e0f1a2b3c",
        "target_video_url": "https://www.tiktok.com/@someone/video/7000000000000000000",
        "comment_text": "Brilliant idea"
      },
      {
        "saved_account_id": "b2c3d4e5-6f7a-8b9c-0d1e-2f3a4b5c6d7e",
        "target_video_url": "https://www.instagram.com/reel/AbCdEf12345/",
        "comment_text": "Stunning shot 🌟"
      },
      {
        "saved_account_id": "9f3a7b2e-1c4d-4e8f-a5b6-7d9e0f1a2b3c",
        "target_video_url": "ftp://broken.example",
        "comment_text": "Cool"
      }
    ]
  }'
```

**Partial-success response (201):**

```json
{
  "data": {
    "created": [
      { "id": "...", "status": "pending", "...": "..." },
      { "id": "...", "status": "pending", "...": "..." }
    ],
    "rejected": [
      {
        "reason": "invalid_video_url",
        "raw": {
          "saved_account_id": "9f3a7b2e-1c4d-4e8f-a5b6-7d9e0f1a2b3c",
          "target_video_url": "ftp://broken.example"
        }
      }
    ]
  },
  "created_count": 2,
  "rejected_count": 1,
  "credits_charged": 2
}
```

The batch path **always returns 201**, even when every row fails. Read `created_count`, `rejected_count`, and the authoritative `credits_charged` value. Retry only recoverable rejected rows as a new logical request with a new idempotency key.

## Request body

| Field                | Type          | Required    | Description                                                                                                                                                                                                        |
| -------------------- | ------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `saved_account_id`   | string (UUID) | yes         | A delivered account you own. Find IDs via `GET /api/ext/accounts`.                                                                                                                                                 |
| `target_type`        | string        | no          | `video` (default) or `comment`. See [Target types](https://developers.tokportal.com/create-comments/#target-types).                                                                                                |
| `target_video_url`   | string        | conditional | Required when `target_type` is `video` (or omitted). TikTok / Instagram video URL. The parser is lenient — protocol, `vm.tiktok.com/...`, `tiktok.com/t/...`, IG `/share/...`, `instagram.com/reel/...`, all work. |
| `target_comment_url` | string        | conditional | Required when `target_type` is `comment`. TikTok comment deep-link (**Share comment → Copy link**); short `t/…` or full `?share_comment_id=…` links both work. TikTok only.                                        |
| `comment_text`       | string        | yes         | The exact text the manager will post. Length capped per platform (TikTok 150, IG 2200).                                                                                                                            |
| `brief_id`           | string (UUID) | no          | Optional internal grouping ID — see your dashboard.                                                                                                                                                                |

For the batch form, wrap in `{ "tasks": [ ... ] }` (1–200 entries). You can mix `video` and `comment` targets in the same batch.

### Validation rules

The same rules apply to single and batch:

1. `saved_account_id` must belong to your user (`SAVED_ACCOUNT_NOT_OWNED` otherwise).
2. The account must have active TokPortal Coverage or be permanently grandfathered. Paused, lapsed, detached, banned, missing or unrecoverable Coverage states cannot create executable work.
3. The account must have an **active manager** (`current_cm_id IS NOT NULL`). If not, you'll see `COMMENT_ACCOUNT_NOT_MANAGED` (single) or `account_not_managed` (batch). Wait until an order is in progress on that account.
4. For `video` targets, `target_video_url` must parse to one of the supported platforms. For `comment` targets, `target_comment_url` must be a TikTok link; it's rejected only if it clearly resolves to a video (`not_a_comment_url`). Otherwise it's accepted (best-effort, see note above).
5. The target's platform must match the account's platform. Otherwise the API returns `COMMENT_PLATFORM_MISMATCH` (single) or `platform_mismatch` (batch). Comment targets require a TikTok account.
6. `comment_text` length must be 1–150 (TikTok), 1–2200 (IG).

### Per-row rejection reasons (batch path)

| `rejected[*].reason`                   | Meaning                                                                                                                              |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `account_not_found`                    | `saved_account_id` doesn't exist.                                                                                                    |
| `account_not_owned`                    | Account belongs to a different user.                                                                                                 |
| `account_not_managed`                  | Account has no `current_cm_id` — no manager can take this task.                                                                      |
| `managed_subscription_lapsed`          | Coverage period ended. Fetch a fresh Coverage quote and reactivate before retrying.                                                  |
| `managed_subscription_cancelled`       | Coverage was manually paused. Fetch the current period and reactivation quote.                                                       |
| `managed_subscription_missing`         | A post-cutoff eligible account has no usable Coverage record. Do not treat it as grandfathered; contact support with the request ID. |
| `account_revealed`                     | Credentials were revealed and the account is permanently detached. Do not retry.                                                     |
| `account_banned`                       | The account is banned. Do not create new work.                                                                                       |
| `managed_account_unrecoverable`        | Coverage cannot be reactivated for this account. Do not retry.                                                                       |
| `managed_account_access_lookup_failed` | Coverage could not be verified. No task or debit was created; retry with the same idempotency key.                                   |
| `invalid_video_url`                    | `target_video_url` didn't parse.                                                                                                     |
| `invalid_comment_url`                  | `target_comment_url` is not a recognizable TikTok comment link.                                                                      |
| `not_a_comment_url`                    | The link resolved to a video, not a specific comment — use a video target.                                                           |
| `comment_target_platform_unsupported`  | Comment targets are TikTok-only.                                                                                                     |
| `platform_mismatch`                    | Target platform ≠ account platform.                                                                                                  |
| `comment_text_too_long`                | Exceeded the platform's max chars.                                                                                                   |
| `comment_text_length_0_exceeds_X`      | Text is empty (legacy literal).                                                                                                      |
| `insufficient_credits`                 | The accepted subset could not be paid. No accepted task or debit was committed; top up and retry as a new logical request.           |

## Tips

- **Idempotency:** send an `Idempotency-Key` and reuse the same key, method, path, and body for an exact transport retry. TokPortal replays a durably completed response for 24 hours. If `IDEMPOTENCY_KEY_IN_PROGRESS` persists, stop instead of changing keys and ask support to reconcile the uncertain claim; it never auto-expires into another execution. For a genuinely new logical call or a changed body, use a new key and still dedupe locally on `(saved_account_id, target_video_url || target_comment_url, comment_text)`. Different keys for identical comments represent different tasks and can bill twice.
- **Don't post under-72h windows where you can't act.** A `pending` task you don't follow up on auto-cancels at 72h and refunds, but you also burn a manager's mindshare.
- **Test on a single task first.** The single-shot path returns precise typed errors, easier to debug than a partial batch.
- **CSV import?** The dashboard's CSV importer hits the same engine — every rule above applies there too.
