Webhooks
Create and manage TokPortal webhook endpoints for emitted bundle and item status events.
Webhooks
Webhooks let TokPortal notify your backend when supported bundle and item lifecycle events happen.
TokPortal currently emits these public webhook events:
webhook.testbundle.createdbundle.publishedbundle.cancelledaccount.configuredaccount.publishedaccount.in_reviewaccount.pending_correctionsaccount.finalizedaccount.remadeaccount.bannedaccount.ban_appeal.resolvedvideo.configuredvideo.in_reviewvideo.publishedvideo.pending_correctionsvideo.finalizedwarming.session_startedwarming.term_verifiedwarming.session_completed
TIP: Detect bans and cancellations without polling If you automate posting, subscribe at minimum to
account.banned,bundle.cancelled, andaccount.remade. They are the push signals that a managed account was banned, that a bundle will no longer accept publishes (409 BUNDLE_INVALID_STATUSwithcurrent_status: "cancelled"), and that an account was rebuilt. See the dedicated sections below.
Event catalog
GET /webhooks/events
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.
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.
Create an endpoint
POST /webhooks
Node
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
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
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
{
"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
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}
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}
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
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
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
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
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.
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
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
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
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:
<timestamp>.<raw_request_body>
using the endpoint signing_secret. Compare against the v1 value in TokPortal-Signature with a constant-time comparison.
Event payload
{
"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_idinaccount.*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 issaved_account_id(inaccount.*events fromin_reviewonward, and onGET /bundles/{id}). Replaced on every remake — the old one 404s;account.remade.old_account_idis that old saved-account id.How to get the new saved-account id after a remake: wait for
account.in_revieworaccount.finalizedon the bundle (they carrysaved_account_id), or pullGET /bundles/{bundle_id}once the account exists (it returnssaved_account_id). Before the account is created,saved_account_idisnull.Bottom line: key your records on
bundle_id(+ yourexternal_ref) — it's in every event and never changes. Readsaved_account_idfromin_review/finalized(orGET /bundles/{id}) when you need to act on the real account.
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.
{
"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_idin this payload: the rebuilt saved account is created later by the manager. It surfaces through subsequentaccount.*events (e.g.account.published,account.finalized) carried on the samebundle_id, or viaGET /bundles/{bundle_id}once the account exists. Always key your records onbundle_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).
{
"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.remadeevent on the samebundle_id.
The account.ban_appeal.resolved event
When the manager files a platform ban appeal, no ban is applied while it's pending. Once the platform answers, this event reports the outcome:
resolution: "appeal_accepted"— the account survived; nothing else changes.resolution: "appeal_refused"— the ban is validated; anaccount.bannedevent fires alongside.
The payload carries appeal_id, bundle_id, saved_account_id, username, platform, resolution, reason, and decided_at.
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.
{
"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 page for the session lifecycle.