Source: https://developers.tokportal.com/webhooks/
Markdown: https://developers.tokportal.com/webhooks.md

# Webhooks

Webhooks let TokPortal notify your backend when supported bundle and item lifecycle events happen.

TokPortal currently emits these public webhook events:

- `webhook.test`
- `bundle.created`
- `bundle.published`
- `bundle.cancelled`
- `bundle.archived`
- `account.configured`
- `account.published`
- `account.in_review`
- `account.pending_corrections`
- `account.finalized`
- `account.remade`
- `account.banned`
- `account.ban_appeal.submitted`
- `account.ban_appeal.resolved`
- `account.ban_resolution.decided`
- `video.configured`
- `video.in_review`
- `video.published`
- `video.pending_corrections`
- `video.finalized`
- `video.not_recommended`
- `video.ai_labeled`
- `video.sound_restricted`
- `account.distribution_warning`
- `warming.session_started`
- `warming.term_verified`
- `warming.session_completed`
- `subscription.renewed`
- `subscription.lapsed`
- `subscription.cancelled`
- `subscription.reactivated`
- `subscription.ended`
- `account.revealed`
- `credits.restored`

> **TIP: Detect bans and cancellations without polling**
> If you automate posting, subscribe at minimum to **`account.banned`**, **`bundle.cancelled`**, and **`account.remade`**. They are the push signals that a managed account was banned, that a bundle will no longer accept publishes (`409 BUNDLE_INVALID_STATUS` with `current_status: "cancelled"`), and that an account was rebuilt. See the dedicated sections below.

> **TIP: Show your users the full ban lifecycle**
> To surface ban status in your own UI, subscribe to the four ban lifecycle events — **`account.ban_appeal.submitted`** (a platform appeal was filed; the account is unavailable but not yet banned), **`account.ban_appeal.resolved`** (the platform answered), **`account.banned`** (the ban is validated), and **`account.ban_resolution.decided`** (staff decided refund / remake / no\_remake). The same lifecycle is pollable via [`GET /account-bans`](https://developers.tokportal.com/bans-and-appeals).

> **TIP: Stop tasks when TokPortal Coverage lapses or ends**
> API and MCP automations should subscribe to `subscription.lapsed`, `subscription.cancelled`, `subscription.ended`, and `account.revealed`. Stop creating tasks for that `saved_account_id` immediately after a lapse, cancellation, terminal end, or an `account.revealed` payload with `management_ended: true`. Resume only after `subscription.reactivated`. `account.revealed` is emitted once after the first successful credential or verification-code reveal; a prior-policy reveal reports `management_ended: false`. Ordinary metadata reads never emit it. A terminal `subscription.ended` or detached account can never return to TokPortal Coverage. `subscription.renewed` confirms a paid 30-day period, `bundle.archived` reports manager-workspace cleanup without changing Coverage, and `credits.restored` reports an eligible ban restoration.

## Event catalog

```
GET /webhooks/events
```

## TokPortal Coverage events

Coverage belongs to a saved account, not a bundle. These events always include `subscription_id` and `saved_account_id`.

| Event                      | Meaning                                                                                                                                                    | Required automation behavior                                                                                          |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `subscription.renewed`     | The automatic 25-credit debit succeeded and a new 30-day period began.                                                                                     | Continue normally.                                                                                                    |
| `subscription.lapsed`      | The renewal debit failed because the workspace lacked credits.                                                                                             | Stop every task for the account and show the reactivation requirement.                                                |
| `subscription.cancelled`   | The client manually paused coverage.                                                                                                                       | Stop every task. Do not create or resume bundles until reactivation.                                                  |
| `subscription.reactivated` | Coverage resumed atomically. `credits_charged` is 0 inside an already paid or included period; otherwise it equals the exact unpaid periods paid.          | Resume work. Read `resumed_tasks` and refresh schedules before posting.                                               |
| `subscription.ended`       | Staff completed the eligible refund or credit-restoration resolution for a confirmed banned account, permanently ending TokPortal Coverage as `ended_ban`. | Stop all work permanently. Do not retry renewal or offer reactivation. Read `status`, `ended_at`, and `ended_reason`. |

The exact reactivation price is always available from `GET /accounts/{id}/managed-subscription`. It is 0 credits while the original paid or included period is still current. After a boundary, it is the sum of the exact recorded period rates plus any newly projected periods at `period_rate_credits`. Reactivation keeps the existing billing anchor and never creates an overlapping period. Clients must submit the quoted amount, period end, and lock version; a changed quote is rejected without a debit. On `subscription.reactivated`, `reactivation_was_free` is `true` and `credit_transaction_id` is `null` when no credits were charged. On `subscription.cancelled`, `reactivation_may_be_free_until` identifies the current period end; the legacy `reactivation_requires_current_period` field is retained as deprecated and is always `false`.

`bundle.archived` is separate. It only removes a bundle from the manager's active workspace after 60 days without a newly scheduled or posted video. Its payload contains `account_coverage_changed: false`. The bundle reopens automatically when new work is attached, and Coverage continues throughout.

`credits.restored` reports an eligible confirmed-ban restoration. Its amount covers the initial account setup, warming and unused video-slot credits, never the Coverage period or used or published work. The payload expiration is 60 days after restoration.

The five Coverage lifecycle events in the table, plus `account.revealed`,
`credits.restored`, bundle cancellations produced by a confirmed-ban
resolution, and `bundle.archived`, use a transactional outbox. The state or
financial transition and its stable event ID commit together. Delivery happens
asynchronously after the API response and is retried with backoff. Delivery is
at least once, so store `TokPortal-Event-Id` with a unique constraint and make
your handler idempotent. The same business event keeps the same event ID on
every automatic or manual retry.

Use the event catalog to discover every supported event type, its delivery availability, example payload, payload schema, signature scheme, and required headers before creating an endpoint.

```bash
curl https://app.tokportal.com/api/ext/webhooks/events
```

The response includes `events`, `envelope`, `delivery`, and `signature`. Events marked `emitted` are actively delivered today.

## Signature model

When a webhook is delivered, TokPortal sends:

| Header                 | Description                                                    |
| ---------------------- | -------------------------------------------------------------- |
| `TokPortal-Event-Id`   | Stable event ID for idempotency.                               |
| `TokPortal-Event-Type` | Event type, for example `bundle.published`.                    |
| `TokPortal-Signature`  | HMAC SHA-256 signature in the format `t=<timestamp>,v1=<hex>`. |

Create endpoints with HTTPS URLs. Store the returned `signing_secret` immediately; it is only returned on creation.

Do not send `Idempotency-Key` when creating an endpoint. The successful response contains a one-time signing secret, so TokPortal rejects the header before execution with `400 IDEMPOTENCY_KEY_NOT_ALLOWED_FOR_SENSITIVE_RESPONSE`. If the response is lost after an uncertain request, list endpoints and reconcile by URL and description before trying again. If the endpoint exists but its one-time secret was not received, delete that specific endpoint and create a replacement so you can store a new secret. Never log or send the secret to support.

## Create an endpoint

```
POST /webhooks
```

### Node

```ts
const response = await fetch("https://app.tokportal.com/api/ext/webhooks", {
  method: "POST",
  headers: {
    "X-API-Key": process.env.TOKPORTAL_API_KEY!,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    url: "https://example.com/tokportal/webhook",
    events: ["bundle.created", "account.in_review", "video.finalized"],
    description: "Production ingestion",
  }),
});

if (!response.ok) {
  throw new Error(await response.text());
}

const endpoint = await response.json();
console.log(endpoint.data.signing_secret);
```

### Python

```python


response = requests.post(
    "https://app.tokportal.com/api/ext/webhooks",
    headers={
        "X-API-Key": os.environ["TOKPORTAL_API_KEY"],
        "Content-Type": "application/json",
    },
    json={
        "url": "https://example.com/tokportal/webhook",
        "events": ["bundle.created", "account.in_review", "video.finalized"],
        "description": "Production ingestion",
    },
    timeout=30,
)
response.raise_for_status()

endpoint = response.json()
print(endpoint["data"]["signing_secret"])
```

### curl

```bash
curl -X POST https://app.tokportal.com/api/ext/webhooks \
  -H "X-API-Key: sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/tokportal/webhook",
    "events": ["bundle.created", "account.in_review", "video.finalized"],
    "description": "Production ingestion"
  }'
```

### Response

```json
{
  "data": {
    "id": "0b88db42-1111-4222-9333-e681165e6f4a",
    "url": "https://example.com/tokportal/webhook",
    "description": "Production ingestion",
    "events": ["bundle.created", "account.in_review", "video.finalized"],
    "enabled": true,
    "created_at": "2026-05-25T18:00:00Z",
    "updated_at": "2026-05-25T18:00:00Z",
    "last_delivery_at": null,
    "last_delivery_status": null,
    "failure_count": 0,
    "signing_secret": "whsec_..."
  }
}
```

## List endpoints

```
GET /webhooks
```

```bash
curl "https://app.tokportal.com/api/ext/webhooks?event=bundle.published&enabled=true" \
  -H "X-API-Key: sk_your_key_here"
```

## Update an endpoint

```
PATCH /webhooks/{id}
```

```bash
curl -X PATCH https://app.tokportal.com/api/ext/webhooks/0b88db42-1111-4222-9333-e681165e6f4a \
  -H "X-API-Key: sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "enabled": false
  }'
```

## Delete an endpoint

```
DELETE /webhooks/{id}
```

```bash
curl -X DELETE https://app.tokportal.com/api/ext/webhooks/0b88db42-1111-4222-9333-e681165e6f4a \
  -H "X-API-Key: sk_your_key_here"
```

## Send a test event

```
POST /webhooks/{id}/test
```

This sends a signed `webhook.test` event to the endpoint and stores the delivery result.

### Node

```ts
const response = await fetch(
  "https://app.tokportal.com/api/ext/webhooks/0b88db42-1111-4222-9333-e681165e6f4a/test",
  {
    method: "POST",
    headers: {
      "X-API-Key": process.env.TOKPORTAL_API_KEY!,
    },
  },
);

if (!response.ok) {
  throw new Error(await response.text());
}

const delivery = await response.json();
console.log(delivery.data.success, delivery.data.status_code);
```

### Python

```python


response = requests.post(
    "https://app.tokportal.com/api/ext/webhooks/0b88db42-1111-4222-9333-e681165e6f4a/test",
    headers={"X-API-Key": os.environ["TOKPORTAL_API_KEY"]},
    timeout=30,
)
response.raise_for_status()

delivery = response.json()
print(delivery["data"]["success"], delivery["data"]["status_code"])
```

### curl

```bash
curl -X POST https://app.tokportal.com/api/ext/webhooks/0b88db42-1111-4222-9333-e681165e6f4a/test \
  -H "X-API-Key: sk_your_key_here"
```

## List delivery attempts

```
GET /webhooks/{id}/deliveries
```

```bash
curl "https://app.tokportal.com/api/ext/webhooks/0b88db42-1111-4222-9333-e681165e6f4a/deliveries?success=false" \
  -H "X-API-Key: sk_your_key_here"
```

Each delivery includes the event ID, event type, HTTP status code, success flag, duration, error message, payload, and creation timestamp.

## Retry a delivery

```
POST /webhooks/{id}/deliveries/{delivery_id}/retry
```

Retry a stored delivery when your endpoint was temporarily unavailable. TokPortal reuses the stored webhook payload, preserves the original event ID, signs the request again with a fresh `TokPortal-Signature`, and records the retry as a new delivery attempt.

```bash
curl -X POST https://app.tokportal.com/api/ext/webhooks/0b88db42-1111-4222-9333-e681165e6f4a/deliveries/7a1f3e5d-2222-4333-9444-abc123abc123/retry \
  -H "X-API-Key: sk_your_key_here"
```

Receivers should treat `TokPortal-Event-Id` as the idempotency key. A retry can have a different delivery row ID while preserving the same event ID.

## Verify signatures

The public Node SDK includes `verifyWebhookSignature`. The manual HMAC examples below are framework-agnostic and production-safe. In both cases, pass the exact raw request body bytes/string received by your HTTP framework, before JSON parsing or re-serialization.

### Node

```ts


function verifyTokPortalSignature(
  rawBody: Buffer | string,
  signatureHeader: string | null | undefined,
  secret: string,
  toleranceSeconds = 300,
) {
  if (!signatureHeader) {
    return false;
  }

  const parts = Object.fromEntries(
    signatureHeader.split(",").map((part) => {
      const [key, ...value] = part.trim().split("=");
      return [key, value.join("=")];
    }),
  );
  const timestamp = parts.t;
  const expectedHex = parts.v1;

  if (!timestamp || !expectedHex) {
    return false;
  }

  const timestampSeconds = Number(timestamp);
  if (
    !Number.isFinite(timestampSeconds) ||
    Math.abs(Date.now() / 1000 - timestampSeconds) > toleranceSeconds
  ) {
    return false;
  }

  const body = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody, "utf8");
  const signedPayload = Buffer.concat([Buffer.from(`${timestamp}.`, "utf8"), body]);
  const digest = createHmac("sha256", secret).update(signedPayload).digest();
  const expected = Buffer.from(expectedHex, "hex");

  return expected.length === digest.length && timingSafeEqual(expected, digest);
}

const valid = verifyTokPortalSignature(
  rawBody,
  request.headers["tokportal-signature"],
  process.env.TOKPORTAL_WEBHOOK_SECRET!,
);
```

### Python

```python


def verify_tokportal_signature(
    raw_body: bytes,
    signature_header: str | None,
    secret: str,
    tolerance_seconds: int = 300,
) -> bool:
    if not signature_header:
        return False

    parts = dict(
        item.strip().split("=", 1)
        for item in signature_header.split(",")
        if "=" in item
    )
    timestamp = parts.get("t")
    expected = parts.get("v1")

    if not timestamp or not expected:
        return False

    try:
        timestamp_seconds = int(timestamp)
    except ValueError:
        return False

    if abs(time.time() - timestamp_seconds) > tolerance_seconds:
        return False

    signed_payload = timestamp.encode() + b"." + raw_body
    digest = hmac.new(
        secret.encode(),
        signed_payload,
        hashlib.sha256,
    ).hexdigest()

    return hmac.compare_digest(digest, expected)

valid = verify_tokportal_signature(
    raw_body,
    request.headers["TokPortal-Signature"],
    os.environ["TOKPORTAL_WEBHOOK_SECRET"],
)
```

### Go

```go
package main


	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"io"
	"net/http"
	"os"
	"strconv"
	"strings"
	"time"
)

func verifyTokPortalSignature(rawBody []byte, signatureHeader string, secret string, tolerance time.Duration) bool {
	parts := map[string]string{}
	for _, part := range strings.Split(signatureHeader, ",") {
		keyValue := strings.SplitN(strings.TrimSpace(part), "=", 2)
		if len(keyValue) == 2 {
			parts[keyValue[0]] = keyValue[1]
		}
	}

	timestamp := parts["t"]
	expectedHex := parts["v1"]
	if timestamp == "" || expectedHex == "" {
		return false
	}

	timestampSeconds, err := strconv.ParseInt(timestamp, 10, 64)
	if err != nil {
		return false
	}

	signedAt := time.Unix(timestampSeconds, 0)
	if time.Since(signedAt) > tolerance || time.Until(signedAt) > tolerance {
		return false
	}

	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(timestamp + "."))
	mac.Write(rawBody)

	expected, err := hex.DecodeString(expectedHex)
	if err != nil {
		return false
	}

	return hmac.Equal(mac.Sum(nil), expected)
}

func handler(w http.ResponseWriter, r *http.Request) {
	rawBody, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "invalid body", http.StatusBadRequest)
		return
	}

	valid := verifyTokPortalSignature(
		rawBody,
		r.Header.Get("TokPortal-Signature"),
		os.Getenv("TOKPORTAL_WEBHOOK_SECRET"),
		5*time.Minute,
	)
	if !valid {
		http.Error(w, "invalid signature", http.StatusUnauthorized)
		return
	}
}
```

The signature is computed over:

```txt
<timestamp>.<raw_request_body>
```

using the endpoint `signing_secret`. Compare against the `v1` value in `TokPortal-Signature` with a constant-time comparison.

## Event payload

```json
{
  "id": "evt_...",
  "type": "webhook.test",
  "api_version": "2026-05-25",
  "created_at": "2026-05-25T18:00:00Z",
  "data": {
    "webhook_endpoint_id": "0b88db42-1111-4222-9333-e681165e6f4a",
    "message": "TokPortal webhook test event"
  }
}
```

## Bundle, account, and video event payloads

Every event wraps its data in the envelope above. The `data` object shape is shared per category. These schemas (and live example payloads) are also served programmatically from `GET /webhooks/events`.

**`bundle.*` events** (`bundle.created`, `bundle.published` — `bundle.cancelled` has its own payload, see below)

| Field          | Type           | Description                                             |
| -------------- | -------------- | ------------------------------------------------------- |
| `bundle_id`    | string (UUID)  | The bundle. Stable across remakes.                      |
| `external_ref` | string \| null | Your reference set on the bundle.                       |
| `status`       | string         | Bundle status.                                          |
| `platform`     | string         | `tiktok`, `instagram`, or `youtube`.                    |
| `bundle_type`  | string         | `account_only`, `account_and_videos`, or `videos_only`. |

**`account.*` events** (`account.configured`, `account.in_review`, `account.published`, `account.pending_corrections`, `account.finalized`)

| Field              | Type                  | Description                                                                                                                                                                                                                            |
| ------------------ | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `account_id`       | string (UUID)         | The bundle's **account listing** id — see the note below. **Stable across remakes.**                                                                                                                                                   |
| `saved_account_id` | string (UUID) \| null | The **saved account** id (the real created account; what `GET /accounts/{id}` uses). `null` until the account exists — it's populated from `account.in_review` onward (`configured`/`published` send `null`). Changes on every remake. |
| `bundle_id`        | string (UUID)         | The owning bundle. Stable across remakes.                                                                                                                                                                                              |
| `username`         | string \| null        | Current requested handle.                                                                                                                                                                                                              |
| `platform`         | string                | `tiktok`, `instagram`, or `youtube`.                                                                                                                                                                                                   |
| `status`           | string                | New account status.                                                                                                                                                                                                                    |
| `previous_status`  | string \| null        | Status before this transition.                                                                                                                                                                                                         |

**`video.*` events** (`video.configured`, `video.in_review`, `video.published`, `video.pending_corrections`, `video.finalized`)

| Field             | Type           | Description                    |
| ----------------- | -------------- | ------------------------------ |
| `video_id`        | string (UUID)  | The video listing.             |
| `bundle_id`       | string (UUID)  | The owning bundle.             |
| `position`        | integer        | 1-based slot position.         |
| `status`          | string         | New video status.              |
| `previous_status` | string \| null | Status before this transition. |
| `platform_url`    | string \| null | Posted URL once available.     |

> **CAUTION: Two different "account" ids — listing vs saved**
> TokPortal has two distinct concepts, both informally called "account":
>
> - **Account listing** — the account *slot/spec* on the bundle (what you order and configure). Its id is `account_id` in `account.*` events. **Stable across remakes** (the listing is rebuilt in place).
> - **Saved account** — the *real created social account* (credentials, the thing `GET /accounts/{id}` returns). Its id is `saved_account_id` (in `account.*` events from `in_review` onward, and on `GET /bundles/{id}`). **Replaced on every remake** — the old one 404s; `account.remade.old_account_id` is that old saved-account id.
>
> **How to get the new saved-account id after a remake:** wait for `account.in_review` or `account.finalized` on the bundle (they carry `saved_account_id`), or pull `GET /bundles/{bundle_id}` once the account exists (it returns `saved_account_id`). Before the account is created, `saved_account_id` is `null`.
>
> **Bottom line:** key your records on `bundle_id` (+ your `external_ref`) — it's in every event and never changes. Read `saved_account_id` from `in_review`/`finalized` (or `GET /bundles/{id}`) when you need to act on the real account.

## Platform signal events

Once a video is live, TokPortal checks what the platform did with it (about 48 hours after posting, again at 7 days, and on every later analytics scrape). Four events report the outcome; the same data is on every video as `platform_signals` ([Videos](https://developers.tokportal.com/videos#platform-signals)) and in [`GET /analytics/signals`](https://developers.tokportal.com/analytics#flagged-posts).

| Event                          | When                                                                                                                                                                                                                                            |
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `video.not_recommended`        | TikTok marked the post **Not For Feed / Not Recommended**: online, but excluded from For You and typically stuck at 0 views. Sent once when first observed (`value: "true"`); a second event with `value: "false"` follows if TikTok clears it. |
| `video.ai_labeled`             | An "AI-generated" label is shown on the post — `value` is `creator` (declared at posting) or `platform` (applied by TikTok moderation).                                                                                                         |
| `video.sound_restricted`       | The sound is restricted — `value` is `non_commercial` (cannot be promoted), `removed` (pulled for copyright, plays muted) or `muted` (Instagram). Sent again if the status changes, including back to `ok`.                                     |
| `account.distribution_warning` | At least 3 of the last 5 checked posts of an account are not recommended. Sent once when the account crosses the threshold.                                                                                                                     |

**`video.*` signal payload**

| Field              | Type               | Description                                                         |
| ------------------ | ------------------ | ------------------------------------------------------------------- |
| `video_id`         | string (UUID)      | The video listing.                                                  |
| `bundle_id`        | string (UUID)      | The owning bundle.                                                  |
| `position`         | integer            | 1-based slot position.                                              |
| `platform_url`     | string \| null     | The live post.                                                      |
| `tracked_post_id`  | string (UUID)      | Analytics v2 post id (use it with `GET /analytics/posts/{id}/raw`). |
| `saved_account_id` | string (UUID)      | The account that posted.                                            |
| `signal`           | string             | `not_recommended`, `ai_label` or `sound_status`.                    |
| `value`            | string \| null     | New value (see the table above).                                    |
| `previous_value`   | string \| null     | Value before this observation.                                      |
| `observed_at`      | string (date-time) | When the platform state was observed.                               |

**`account.distribution_warning` payload**: `saved_account_id`, `recent_posts_checked`, `recent_not_recommended`, `observed_at`.

> **CAUTION: Existing endpoints do not receive new event types automatically**
> An endpoint only receives the events listed in its `events` array, which is fixed when the endpoint is created. To receive the four platform-signal events on an endpoint created before 2026-09-22, `PATCH /webhooks/{id}` with the full desired `events` list (or create a new endpoint). `GET /webhooks/events` always returns the current catalog.

## The `account.remade` event

When an account is banned or lost, TokPortal **remakes** it: the bundle is rebuilt **in place under the same `bundle_id`**, the previous saved account is removed, and the manager re-creates the account (usually under a slightly different handle).

Subscribe to `account.remade` to track remakes without polling. This is the recommended way for resellers to keep downstream clients in sync when an account is replaced.

```json
{
  "id": "evt_...",
  "type": "account.remade",
  "api_version": "2026-05-25",
  "created_at": "2026-06-09T00:36:24Z",
  "data": {
    "bundle_id": "9f3a7b2e-1c4d-4e8f-a5b6-7d9e0f1a2b3c",
    "external_ref": "partner-order-123",
    "platform": "tiktok",
    "old_username": "launchprofile",
    "new_username": "launchprofile_",
    "old_account_id": "0d1e2f3a-4b5c-6789-8abc-def012345678",
    "reason": "account banned/not found",
    "mode": "republish",
    "remade_count": 1,
    "remade_at": "2026-06-09T00:36:24Z"
  }
}
```

| Field            | Type                      | Description                                                                                                                 |
| ---------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `bundle_id`      | string (UUID)             | The bundle that was remade. **Stable across remakes** — use it (or your `external_ref`) as the correlation anchor.          |
| `external_ref`   | string \| null            | Your own reference set when the bundle was created. Also stable across remakes.                                             |
| `platform`       | string                    | `tiktok`, `instagram`, or `youtube`.                                                                                        |
| `old_username`   | string \| null            | Handle before the remake.                                                                                                   |
| `new_username`   | string \| null            | Target handle the account is being rebuilt under.                                                                           |
| `old_account_id` | string (UUID) \| null     | The saved account that was removed. **Retire this id** — `GET /accounts/{old_account_id}` will return `404` after a remake. |
| `reason`         | string \| null            | Why the account was remade (e.g. `account banned/not found`).                                                               |
| `mode`           | string                    | `republish` or `publish_and_assign`.                                                                                        |
| `remade_count`   | integer                   | Total number of times this bundle has been remade.                                                                          |
| `remade_at`      | string (ISO 8601) \| null | When the remake happened.                                                                                                   |

> **NOTE: note**
> There is **no `new_account_id`** in this payload: the rebuilt saved account is created later by the manager. It surfaces through subsequent `account.*` events (e.g. `account.published`, `account.finalized`) carried on the **same `bundle_id`**, or via `GET /bundles/{bundle_id}` once the account exists. Always key your records on `bundle_id` / `external_ref`, not on the saved account id (which changes on every remake).

## The `account.banned` event

When a managed account is banned by the platform and the ban is **validated** (the manager reported it with no appeal available, the platform refused the ban appeal, or staff confirmed the ban), TokPortal marks the account banned and emits `account.banned` to the bundle owner.

A validated ban also **cancels every active bundle and order on the account** (a `bundle.cancelled` event fires alongside for each), stops the Account Owning Fee if one was active, and surfaces the ban on `GET /accounts/{id}` (`banned`, `ban.reason`, `ban.banned_at`, `ban.screenshot_url`).

```json
{
  "id": "evt_...",
  "type": "account.banned",
  "api_version": "2026-05-25",
  "created_at": "2026-07-06T19:13:43Z",
  "data": {
    "bundle_id": "9f3a7b2e-1c4d-4e8f-a5b6-7d9e0f1a2b3c",
    "saved_account_id": "7c9e0f1a-2b3c-4d5e-8f6a-1b2c3d4e5f60",
    "username": "launchprofile",
    "platform": "tiktok",
    "reason": "Community guidelines violation notice shown in-app",
    "appeal_status": "no_appeal_banned",
    "banned_at": "2026-07-06T19:13:43Z"
  }
}
```

| Field              | Type                  | Description                                                                                                  |
| ------------------ | --------------------- | ------------------------------------------------------------------------------------------------------------ |
| `bundle_id`        | string (UUID) \| null | The bundle the ban report is attached to. Stable across remakes.                                             |
| `saved_account_id` | string (UUID) \| null | The banned saved account (still readable via `GET /accounts/{id}` — banned accounts stay visible).           |
| `username`         | string \| null        | Handle of the banned account.                                                                                |
| `platform`         | string                | `tiktok`, `instagram`, or `youtube`.                                                                         |
| `reason`           | string \| null        | Ban reason as reported by the manager or staff.                                                              |
| `appeal_status`    | string                | `no_appeal_banned` (no platform appeal was available) or `appeal_refused` (the platform refused the appeal). |
| `banned_at`        | string (ISO 8601)     | When the ban was validated.                                                                                  |

> **NOTE: note**
> A ban does **not** automatically remake the account. If the account is later remade, you'll receive a separate `account.remade` event on the same `bundle_id`.

## The `account.ban_appeal.submitted` event

When the manager reports a ban **and files a platform appeal**, the account enters limbo: it is unavailable while the platform decides, but it is **not** marked banned — no `account.banned` fires and its bundles stay open. This event is your first signal that something happened to the account, typically days before the appeal resolves. Show it to your users instead of leaving them without feedback.

```json
{
  "id": "evt_...",
  "type": "account.ban_appeal.submitted",
  "api_version": "2026-05-25",
  "created_at": "2026-07-01T08:30:12Z",
  "data": {
    "appeal_id": "5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d",
    "bundle_id": "9f3a7b2e-1c4d-4e8f-a5b6-7d9e0f1a2b3c",
    "saved_account_id": "7c9e0f1a-2b3c-4d5e-8f6a-1b2c3d4e5f60",
    "username": "launchprofile",
    "platform": "tiktok",
    "appeal_status": "appeal_pending",
    "submitted_at": "2026-07-01T08:30:00Z"
  }
}
```

| Field              | Type                  | Description                                                  |
| ------------------ | --------------------- | ------------------------------------------------------------ |
| `appeal_id`        | string (UUID)         | The ban report — poll it via `GET /account-bans`.            |
| `bundle_id`        | string (UUID) \| null | The bundle the report is attached to. Stable across remakes. |
| `saved_account_id` | string (UUID) \| null | The account under appeal.                                    |
| `username`         | string \| null        | Handle of the account.                                       |
| `platform`         | string                | `tiktok`, `instagram`, or `youtube`.                         |
| `appeal_status`    | string                | Always `appeal_pending`.                                     |
| `submitted_at`     | string (ISO 8601)     | When the ban was first reported and the appeal filed.        |

> **NOTE: note**
> While an appeal is pending, TokPortal deliberately does **not** recreate the account: creating a duplicate while the platform reviews the appeal can trigger a ban-evasion strike. The next event on this `appeal_id` is `account.ban_appeal.resolved`.

## The `account.ban_appeal.resolved` event

Once the platform answers a pending appeal, this event reports the outcome:

- `resolution: "appeal_accepted"` — the account survived; nothing else changes.
- `resolution: "appeal_refused"` — the ban is validated; an `account.banned` event fires alongside, and staff then decides the commercial outcome (see `account.ban_resolution.decided`).

The payload carries `appeal_id`, `bundle_id`, `saved_account_id`, `username`, `platform`, `resolution`, `reason`, and `decided_at`.

## The `account.ban_resolution.decided` event

After a ban is confirmed (`appeal_refused` or `no_appeal_banned`), staff reviews the case in the Ban Appeals queue and decides the commercial outcome. This event announces that decision:

- `resolution: "refund"` — eligible credits are restored (`refund_credits` carries the amount; a `credits.restored` event can fire alongside for TokPortal Coverage accounts).
- `resolution: "remake"` — the mission is reset and relisted under the **same** `bundle_id`; an `account.remade` event fires alongside.
- `resolution: "no_remake"` — the case is closed without compensation. `reason_code: "tos_ban"` means the account was banned for a direct Terms of Service violation and is not refundable.

```json
{
  "id": "evt_...",
  "type": "account.ban_resolution.decided",
  "api_version": "2026-05-25",
  "created_at": "2026-07-04T16:45:10Z",
  "data": {
    "appeal_id": "5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d",
    "bundle_id": "9f3a7b2e-1c4d-4e8f-a5b6-7d9e0f1a2b3c",
    "saved_account_id": "7c9e0f1a-2b3c-4d5e-8f6a-1b2c3d4e5f60",
    "username": "launchprofile",
    "platform": "tiktok",
    "resolution": "refund",
    "reason_code": "eligible_refund",
    "refund_credits": 42,
    "resolved_at": "2026-07-04T16:45:00Z"
  }
}
```

| Field              | Type                  | Description                                                                                                                                                      |
| ------------------ | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `appeal_id`        | string (UUID)         | The ban report the decision belongs to.                                                                                                                          |
| `bundle_id`        | string (UUID) \| null | The bundle the report is attached to.                                                                                                                            |
| `saved_account_id` | string (UUID) \| null | The banned account. Null when a staff reset deleted the account row — key on `bundle_id`.                                                                        |
| `username`         | string \| null        | Handle of the banned account.                                                                                                                                    |
| `platform`         | string                | `tiktok`, `instagram`, or `youtube`.                                                                                                                             |
| `resolution`       | string                | `refund`, `remake`, or `no_remake`.                                                                                                                              |
| `reason_code`      | string \| null        | Machine-readable reason, e.g. `eligible_refund`, `eligible_credit_restoration`, `replacement_approved`, `tos_ban`, `tos_violation`, `content_related`, `custom`. |
| `refund_credits`   | integer               | Credits restored by the decision (0 unless `resolution` is `refund`).                                                                                            |
| `resolved_at`      | string (ISO 8601)     | When staff made the decision.                                                                                                                                    |

## The `bundle.cancelled` event

Emitted when a bundle is cancelled and will never accept publishes again — most commonly because the bundle's account was banned (`account_banned: true`), or after a ban-check was accepted with the cancel & refund resolution. After this event, `POST .../publish` calls on the bundle return `409 BUNDLE_INVALID_STATUS` with `current_status: "cancelled"` and a `cancelled_reason`.

```json
{
  "id": "evt_...",
  "type": "bundle.cancelled",
  "api_version": "2026-05-25",
  "created_at": "2026-07-06T19:13:43Z",
  "data": {
    "bundle_id": "9f3a7b2e-1c4d-4e8f-a5b6-7d9e0f1a2b3c",
    "external_ref": "partner-order-123",
    "platform": "tiktok",
    "reason": "Account banned: Community guidelines violation",
    "cancelled_at": "2026-07-06T19:13:43Z",
    "account_banned": true,
    "saved_account_id": "7c9e0f1a-2b3c-4d5e-8f6a-1b2c3d4e5f60",
    "username": "launchprofile"
  }
}
```

| Field              | Type                  | Description                                                                                                      |
| ------------------ | --------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `bundle_id`        | string (UUID)         | The cancelled bundle.                                                                                            |
| `external_ref`     | string \| null        | Your reference set on the bundle.                                                                                |
| `platform`         | string                | `tiktok`, `instagram`, or `youtube`.                                                                             |
| `reason`           | string                | Why the bundle was cancelled (also on `GET /bundles/{id}` as `cancelled_reason`).                                |
| `cancelled_at`     | string (ISO 8601)     | When the bundle was cancelled.                                                                                   |
| `account_banned`   | boolean               | `true` when the cancellation was caused by the account being banned — an `account.banned` event fires alongside. |
| `saved_account_id` | string (UUID) \| null | The account the bundle was attached to, when relevant.                                                           |
| `username`         | string \| null        | Handle of that account.                                                                                          |

## Warming events

`warming.session_started`, `warming.term_verified`, and `warming.session_completed` track Advanced Warming sessions. Their payload schemas and example payloads are served from `GET /webhooks/events`; see the [Advanced Warming](https://developers.tokportal.com/advanced-warming) page for the session lifecycle.

## Connection and inbox reads

[Account connection and inbox](https://developers.tokportal.com/account-connection-inbox) includes read-only GETs and explicit write operations for public comment replies, Instagram DM replies, private replies and opt-in comment-to-DM rules. Subscribe to `account.connection.connected`, `account.connection.disconnected`, `account.connection.removed`, `inbox.comment.received`, `inbox.message.received`, `inbox.comment.reply.updated`, `inbox.message.send.updated`, `inbox.comment.private_reply.updated`, `inbox.comment.automation.updated` and `inbox.comment.moderation.updated` as applicable. These are public TokPortal names, distinct from the provider's internal events.

Inbox events contain account/resource IDs and results, not private message bodies or credentials. Verify the normal TokPortal signature, deduplicate event IDs and refetch the current scoped inbox, operation or rule history. Delivery order is not state order. A successful send result means provider acceptance with a native ID, not recipient reading. Unknown delivery is not a reason to automatically resend. A `reason: instagram_checkpoint` disconnection directs the manager to complete Instagram's security check, then reconnect; it is not automatically an account ban. See the complete [Inbox guide](https://developers.tokportal.com/account-connection-inbox#messaging-webhooks) and generated [OpenAPI reference](https://developers.tokportal.com/api-reference) for request and payload schemas.

`inbox.comment.action.updated` reports a manual comment moderation result: delete,
hide, unhide, pin or unpin. Payload contains `saved_account_id`, `operation_id`,
`action`, `post_id`, `comment_id`, `state`, `error_code`, `retry_after_seconds` and
`credits_charged: 0`. It never contains comment text. Deduplicate the event ID and
fetch the [durable operation](https://developers.tokportal.com/account-connection-inbox) for current status; do not
blindly repeat an unknown action. Actions only apply when returned in Inbox capabilities.
