Overview
The OnBriefing API checks whether videos published by creators on TikTok, Instagram and YouTube follow a campaign brief. You create the campaign with the brief, submit the video URLs and get one report per video, with a verdict and one line per rule, each with evidence.
https://onbriefing.com/api/v1- Request and response bodies are JSON (
Content-Type: application/json), UTF-8. - Dates are ISO 8601, UTC, for example
2026-10-09T14:30:00.000Z. - IDs are opaque strings. Don't assume a format or length.
- New fields may be added to responses at any time; ignore the ones you don't know.
- Analysis is asynchronous: submission responds immediately, the report is ready within minutes.
- Error messages (
error,detail) and the report text written by the analysis (summary,rationale) are currently in Portuguese. Rely on HTTP status codes,fieldand enum values in your code.
Authentication
Every call requires an API key in the Authorization header, as a Bearer token. Keys start with obk_.
Authorization: Bearer obk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxGetting a key
- Create an account at onbriefing.com/cadastro (10 free videos, no card) or sign in to yours. Every account can generate API keys.
- In the dashboard, open Settings → API keys → Generate key and give it a name (for example,
production-integration). - Copy the value right away: it's shown only once. OnBriefing stores only a hash of the key and can't display it again.
Scope and security
- The key belongs to the organization and grants access to all its campaigns and verifications.
- Resources of another organization return
404, as if they didn't exist. - Keep the key as a server-side secret (environment variable or vault). Never use it in code that runs in the browser or a user's app.
- A key revoked in the dashboard stops working immediately. To rotate without downtime, generate the new one, deploy it, and only then revoke the old one.
Quickstart
From zero to your first report in three calls.
1. Create the campaign
curl -X POST https://onbriefing.com/api/v1/campaigns \
-H "Authorization: Bearer $ONBRIEFING_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Summer 2026: launch",
"brand": "Brand X",
"rulesText": "The video must be at least 30s long, show the product in use, say the promo code SUMMER26 out loud and use #BrandXSummer and #ad in the caption. Competitors must not be mentioned."
}'Save the id from the response.
2. Submit the videos
curl -X POST https://onbriefing.com/api/v1/campaigns/$CAMPAIGN_ID/verifications \
-H "Authorization: Bearer $ONBRIEFING_KEY" \
-H "Content-Type: application/json" \
-d '{ "urls": ["https://www.tiktok.com/@creator/video/7300000000000000001"] }'Each URL with status: "queued" comes with the verification id.
3. Fetch the report
curl https://onbriefing.com/api/v1/verifications/$VERIFICATION_ID \
-H "Authorization: Bearer $ONBRIEFING_KEY"When status is COMPLETED, the report is in verdict, summary and findings. In production, replace polling with the webhook.
Full example in Node.js
const BASE = "https://onbriefing.com/api/v1";
const headers = {
Authorization: `Bearer ${process.env.ONBRIEFING_KEY}`,
"Content-Type": "application/json",
};
async function api(path, init = {}) {
const res = await fetch(BASE + path, { ...init, headers });
const body = await res.json();
if (!res.ok) throw new Error(`${res.status}: ${body.error}`);
return body;
}
const campaign = await api("/campaigns", {
method: "POST",
body: JSON.stringify({ name: "Pilot", brand: "Brand X", rulesText: BRIEF }),
});
const { outcomes } = await api(`/campaigns/${campaign.id}/verifications`, {
method: "POST",
body: JSON.stringify({ urls: VIDEO_URLS }),
});
const ids = outcomes.filter((o) => o.status === "queued").map((o) => o.id);
async function waitFor(id) {
for (;;) {
const v = await api(`/verifications/${id}`);
if (v.status === "COMPLETED" || v.status === "FAILED") return v;
await new Promise((r) => setTimeout(r, 30_000));
}
}
const reports = await Promise.all(ids.map(waitFor));Python example
import os, time, requests
BASE = "https://onbriefing.com/api/v1"
s = requests.Session()
s.headers["Authorization"] = f"Bearer {os.environ['ONBRIEFING_KEY']}"
campaign = s.post(f"{BASE}/campaigns", json={
"name": "Pilot", "brand": "Brand X", "rulesText": BRIEF,
}).json()
outcomes = s.post(f"{BASE}/campaigns/{campaign['id']}/verifications",
json={"urls": VIDEO_URLS}).json()["outcomes"]
for o in outcomes:
if o["status"] != "queued":
continue
while True:
v = s.get(f"{BASE}/verifications/{o['id']}").json()
if v["status"] in ("COMPLETED", "FAILED"):
print(v["videoUrl"], v["status"], v["verdict"])
break
time.sleep(30)Concepts
| Term | Type | Description |
|---|---|---|
| Campaign | Campaign | Name, brand and the brief (rulesText). Groups the verifications. |
| Briefing | rulesText | The text you already send creators, in plain language. OnBriefing extracts the rules on its own and flags the ones that can't be checked on video. |
| Verification | Verification | The analysis of one video URL within a campaign. |
| Report | verdict + findings | The result: overall verdict, summary and one line per rule, with evidence. |
| Finding | Finding | The result for one rule: status, severity, rationale, confidence and evidence. |
The brief is frozen at submission
Each verification keeps the brief that was in effect when the URL was submitted. Editing the campaign later only affects new submissions; reports already issued remain explainable against the original rules.
Verification lifecycle
QUEUED → DOWNLOADING → TRANSCRIBING → ANALYZING → COMPLETED
↘ FAILEDCOMPLETED and FAILED are final states. When the video platform temporarily limits access, the verification waits and retries on its own, and may stay a few extra minutes in QUEUED or DOWNLOADING.
Create campaign
Body
| Field | Type | Description |
|---|---|---|
| name | string | Required. Campaign name; can't be empty. |
| brand | string | null | Optional. Advertiser brand. |
| rulesText | string | Required. Brief in plain text, at least 20 characters. |
POST /api/v1/campaigns
{
"name": "Summer 2026: launch",
"brand": "Brand X",
"rulesText": "The video must be at least 30s long, show the product in use and mention the promo code SUMMER26."
}{
"id": "cm8x1a2b3c4d5e6f7g8h9i0j",
"orgId": "cm7o...",
"name": "Summer 2026: launch",
"brand": "Brand X",
"rulesText": "The video must be at least 30s long, show the product in use and mention the promo code SUMMER26.",
"active": true,
"archivedAt": null,
"hasWebhook": false,
"webhookUrl": null,
"createdAt": "2026-10-09T14:00:00.000Z",
"updatedAt": "2026-10-09T14:00:00.000Z"
}Errors: 400 if the body isn't JSON or a field is missing; 422 if name is empty or rulesText has fewer than 20 characters. The offending field is in field.
Get campaign
Returns the same object as creation, plus verificationCount , the total number of videos submitted to the campaign. The webhookSecret never appears here.
{
"id": "cm8x1a2b3c4d5e6f7g8h9i0j",
"name": "Summer 2026: launch",
"hasWebhook": true,
"webhookUrl": "https://your-app.com/webhooks/onbriefing",
"verificationCount": 128,
...
}Update campaign
Send only the fields you want to change.
| Field | Type | Description |
|---|---|---|
| name | string | New name; can't be empty. |
| brand | string | null | New brand; null removes it. |
| rulesText | string | New brief, at least 20 characters. Applies to new submissions. |
| webhookUrl | string | null | URL that receives the reports. null removes the webhook and its secret. |
The webhook secret is shown once
When you set webhookUrl on a campaign that didn't have a webhook yet, the response includes a webhookSecret field. Store it right away : it signs every webhook and never comes back in any read.
PATCH /api/v1/campaigns/cm8x1a2b3c4d5e6f7g8h9i0j
{ "webhookUrl": "https://your-app.com/webhooks/onbriefing" }{
"id": "cm8x1a2b3c4d5e6f7g8h9i0j",
"hasWebhook": true,
"webhookUrl": "https://your-app.com/webhooks/onbriefing",
"webhookSecret": "9f2c6b1e...64 hex characters"
}- Changing the URL of an existing webhook keeps the same secret.
- To generate a new secret, send
{ "webhookUrl": null }and then set the URL again.
Submit videos
| Field | Type | Description |
|---|---|---|
| urls | string[] | 1 to 500 URLs of videos published on TikTok, Instagram or YouTube. |
POST /api/v1/campaigns/cm8x1a2b3c4d5e6f7g8h9i0j/verifications
{
"urls": [
"https://www.tiktok.com/@creator1/video/7300000000000000001",
"https://www.instagram.com/reel/ABC123/",
"https://www.youtube.com/shorts/dQw4w9WgXcQ",
"https://example.com/video"
]
}The response is line by line, in submission order. A problem with one URL doesn't affect the others.
{
"outcomes": [
{ "url": "https://www.tiktok.com/@creator1/video/7300000000000000001", "status": "queued", "id": "cm8x9z..." },
{ "url": "https://www.instagram.com/reel/ABC123/", "status": "duplicate" },
{ "url": "https://www.youtube.com/shorts/dQw4w9WgXcQ", "status": "insufficient_credits", "detail": "..." },
{ "url": "https://example.com/video", "status": "invalid", "detail": "Host não permitido: example.com. Aceitos: TikTok, Instagram e YouTube." }
]
}| Status | Kind | Meaning |
|---|---|---|
| queued | status | Queued. Save the verification id. |
| duplicate | status | This URL was already submitted to this campaign. Nothing is charged; use the existing verification. |
| invalid | status | Malformed URL or unsupported platform. detail explains why. |
| insufficient_credits | status | The organization doesn't have credits for a single minute of video. detail shows how many credits are left. |
Accepted URLs
- Only
https, with no port and no user/password in the URL. - Hosts:
tiktok.com,www.tiktok.com,m.tiktok.com,vm.tiktok.com,vt.tiktok.com,instagram.com,www.instagram.com,youtube.com,www.youtube.com,m.youtube.comandyoutu.be. - The URL is normalized: the query string and
#are dropped (except YouTube'sv). The URL returned inoutcomes[].urlis the normalized one, and it's what identifies the video in the campaign. - Repeated URLs within the same submission count once.
- The video must be public. A private video or one that requires login fails with
AUTH_REQUIRED.
List verifications
| Parameter | Type | Description |
|---|---|---|
| status | query, optional | Filter by state: QUEUED, DOWNLOADING, TRANSCRIBING, ANALYZING, COMPLETED or FAILED. |
| cursor | query, optional | The nextCursor from the previous page. |
Pages of 50 items, from the most recent verifications to the oldest. nextCursor is null on the last page. Items carry the summary; for findings, use Get verification.
{
"items": [
{
"id": "cm8x9z...",
"videoUrl": "https://www.tiktok.com/@creator1/video/7300000000000000001",
"platform": "TIKTOK",
"status": "COMPLETED",
"verdict": "APPROVED",
"finalVerdict": null,
"errorCode": null,
"createdAt": "2026-10-09T14:01:00.000Z",
"completedAt": "2026-10-09T14:04:12.000Z"
}
],
"nextCursor": "cm8x9y..."
}let cursor = null;
do {
const qs = new URLSearchParams({ status: "COMPLETED", ...(cursor ? { cursor } : {}) });
const page = await api(`/campaigns/${campaignId}/verifications?${qs}`);
for (const item of page.items) handle(item);
cursor = page.nextCursor;
} while (cursor);Get verification
Returns the full verification, with the report and the evidence for each rule.
{
"id": "cm8x9z...",
"campaignId": "cm8x1a2b3c4d5e6f7g8h9i0j",
"videoUrl": "https://www.tiktok.com/@creator1/video/7300000000000000001",
"platform": "TIKTOK",
"status": "COMPLETED",
"verdict": "REJECTED",
"finalVerdict": null,
"summary": "Shows the product and uses the hashtag, but doesn't mention the code and runs 22s.",
"contentCategories": ["REVIEW", "DEPOIMENTO"],
"errorCode": null,
"error": null,
"createdAt": "2026-10-09T14:01:00.000Z",
"startedAt": "2026-10-09T14:01:05.000Z",
"completedAt": "2026-10-09T14:04:12.000Z",
"findings": [
{
"ruleText": "Say the promo code SUMMER26 out loud",
"scope": "VERIFIABLE",
"status": "FAIL",
"severity": "MAJOR",
"rationale": "The code doesn't appear in the transcript or on-screen text.",
"confidence": 0.86,
"evidence": [
{ "type": "ABSENT", "note": "Full transcript and on-screen text; no mention of SUMMER26.",
"frameIndex": null, "timestampMs": null, "quote": null, "field": null, "value": null }
]
},
{
"ruleText": "Use #BrandXSummer in the caption",
"scope": "VERIFIABLE",
"status": "PASS",
"severity": "MAJOR",
"rationale": "The hashtag is in the caption.",
"confidence": 0.97,
"evidence": [
{ "type": "METADATA", "field": "hashtags", "value": "brandxsummer, ad",
"frameIndex": null, "timestampMs": null, "quote": null, "note": null }
]
}
]
}While the analysis is running, verdict, summary and completedAt are null and findings is empty.
The report
Verification
| Field | Type | Description |
|---|---|---|
| status | enum | QUEUED · DOWNLOADING · TRANSCRIBING · ANALYZING · COMPLETED · FAILED |
| platform | enum | TIKTOK · INSTAGRAM · YOUTUBE |
| verdict | enum | null | Automatic verdict: APPROVED (on brief), REJECTED (off brief) or INCONCLUSIVE (the evidence isn't enough and needs human review). |
| finalVerdict | enum | null | Verdict after human review in the dashboard. null until someone reviews it. When set, it's the final word. |
| summary | string | null | Report summary (currently written in Portuguese). |
| contentCategories | string[] | Up to 3 video format categories, most relevant first. Empty when not categorized. |
| errorCode | string | null | Only on FAILED. See Errors. |
| error | string | null | Only on FAILED. Human-readable message about the failure. |
Finding
| Field | Type | Description |
|---|---|---|
| ruleText | string | The rule, as OnBriefing extracted it from the brief. |
| scope | enum | VERIFIABLE: can be checked on the video. OUT_OF_SCOPE: depends on external data (paid boosting, publishing date, post staying up) and always comes with status: UNVERIFIABLE. |
| status | enum | PASS · FAIL · UNVERIFIABLE |
| severity | enum | BLOCKER (prohibition or legal requirement), MAJOR (core deliverable) or MINOR (detail). It's the rule's weight and applies even when the rule passed. |
| rationale | string | Why the rule passed, failed or couldn't be verified. |
| confidence | number | From 0 to 1. |
| evidence | Evidence[] | One or more pieces of evidence backing the result. |
Evidence
Every evidence object has the same fields; those that don't apply to the type are null.
| Field | Type | Description |
|---|---|---|
| FRAME | type | Something seen in a frame. Uses frameIndex, timestampMs and note (what is seen). |
| TRANSCRIPT | type | Something said. Uses timestampMs and quote (verbatim excerpt of the speech). |
| SCREEN_TEXT | type | Text overlaid on the video. Uses timestampMs and quote. |
| METADATA | type | A post field. Uses field (caption, hashtags, mentions, durationMs, author, dimensions) and value. |
| SOURCE_MATCH | type | Excerpt matching the campaign's official footage. Uses note. |
| ABSENT | type | The result relies on an absence. note says where it looked. |
Content categories
Possible values of contentCategories. The list may grow; treat an unknown code as OUTRO (other).
| Code | Kind | Label |
|---|---|---|
| MEME | category | Meme / humor |
| CASAL | category | Couple |
| ESQUETE | category | Skit / role-play |
| TREND | category | Trend / dance |
| REVIEW | category | Review / unboxing |
| TUTORIAL | category | Tutorial / tips |
| RECEITA | category | Recipe / cooking |
| BELEZA_MODA | category | Beauty / fashion |
| VLOG | category | Vlog / routine |
| DEPOIMENTO | category | Testimonial / opinion |
| REACT | category | Reaction / commentary |
| CORTE | category | Live / podcast clip |
| GAMEPLAY | category | Gameplay |
| OUTRO | category | Other |
Using the verdict
- Use
verdictfor automatic triage:APPROVEDmoves on,REJECTEDgoes back to the creator with theFAILfindings,INCONCLUSIVEgoes to human review. - If
finalVerdictis set, it overridesverdict. - For creator feedback, show
ruleTextandrationaleof findings withstatus: FAIL, sorted byseverity.
Webhooks
With webhookUrl set on the campaign, OnBriefing sends a POST to that URL whenever a verification reaches a final state.
| Event | Kind | When |
|---|---|---|
| verification.completed | event | The analysis finished and the report is ready. |
| verification.failed | event | The analysis failed. See errorCode. The credit has already been refunded. |
Request
POST https://your-app.com/webhooks/onbriefing
Content-Type: application/json
X-OnBriefing-Event: verification.completed
X-OnBriefing-Signature: sha256=5d41402abc4b2a76b9719d911017c592...
{
"event": "verification.completed",
"verification": {
"id": "cm8x9z...",
"campaignId": "cm8x1a2b3c4d5e6f7g8h9i0j",
"videoUrl": "https://www.tiktok.com/@creator1/video/7300000000000000001",
"platform": "TIKTOK",
"status": "COMPLETED",
"verdict": "REJECTED",
"summary": "Shows the product and uses the hashtag, but doesn't mention the code and runs 22s.",
"contentCategories": ["REVIEW"],
"errorCode": null,
"completedAt": "2026-10-09T14:04:12.000Z",
"findings": [
{ "ruleText": "Say the promo code SUMMER26 out loud", "scope": "VERIFIABLE",
"status": "FAIL", "severity": "MAJOR",
"rationale": "The code doesn't appear in the transcript.", "confidence": 0.86 }
]
}
}The webhook payload is lean: findings come without evidence. For the evidence (frames, speech, metadata), call GET /verifications/:id.
Verifying the signature
X-OnBriefing-Signature is the hex-encoded HMAC-SHA256 of the raw body of the request, signed with the campaign's webhookSecret. Compute it over the bytes received (not over re-serialized JSON) and compare in constant time. Reject with 401 if it doesn't match.
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
const app = express();
app.post(
"/webhooks/onbriefing",
express.raw({ type: "application/json" }), // raw body, not parsed
(req, res) => {
const expected = createHmac("sha256", process.env.ONBRIEFING_WEBHOOK_SECRET)
.update(req.body)
.digest("hex");
const received = String(req.get("X-OnBriefing-Signature") ?? "").replace("sha256=", "");
const valid =
expected.length === received.length &&
timingSafeEqual(Buffer.from(expected), Buffer.from(received));
if (!valid) return res.sendStatus(401);
const { event, verification } = JSON.parse(req.body);
// Respond fast; process in the background.
queue.add({ event, verification });
res.sendStatus(200);
},
);import { createHmac, timingSafeEqual } from "node:crypto";
export async function POST(request: Request) {
const raw = await request.text();
const expected = createHmac("sha256", process.env.ONBRIEFING_WEBHOOK_SECRET!).update(raw).digest("hex");
const received = (request.headers.get("x-onbriefing-signature") ?? "").replace("sha256=", "");
if (expected.length !== received.length ||
!timingSafeEqual(Buffer.from(expected), Buffer.from(received))) {
return new Response("invalid signature", { status: 401 });
}
const { event, verification } = JSON.parse(raw);
// ...
return new Response(null, { status: 200 });
}import hmac, hashlib, os
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["ONBRIEFING_WEBHOOK_SECRET"].encode()
@app.post("/webhooks/onbriefing")
def onbriefing_webhook():
raw = request.get_data() # raw body
expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
received = request.headers.get("X-OnBriefing-Signature", "").removeprefix("sha256=")
if not hmac.compare_digest(expected, received):
abort(401)
payload = request.get_json()
# ...
return "", 200Delivery and retries
- Respond with any
2xxstatus within 10 seconds to acknowledge receipt. - A response outside
2xx, a network error or a timeout triggers retries: up to 5, with increasing intervals starting at 30 seconds. - The same verification may arrive more than once. Make your handler idempotent, using
verification.idas the key. - Delivery order isn't guaranteed across different verifications.
- Keep a periodic safety check (for example, hourly, listing by
status) to recover any missed webhook.
Errors
API errors come with an HTTP status and a JSON body with error (message, in Portuguese) and, on field validation errors, field.
{ "error": "Cole o briefing da campanha. É ele que define o que será verificado.", "field": "rulesText" }HTTP status codes
| Status | Kind | When |
|---|---|---|
| 200 / 201 | success | Request accepted. 201 when creating a campaign. |
| 400 | request | Body isn't JSON, a field has the wrong type, a batch has more than 500 URLs or the filter status is invalid. |
| 401 | authentication | Missing, invalid or revoked key. |
| 404 | not found | Campaign or verification doesn't exist or belongs to another organization. |
| 422 | validation | A field has an invalid value (name empty, rulesText too short). See field. On PATCH, these cases may come back as 400. |
| 5xx | server | OnBriefing error. Retry with exponential backoff; resubmitting URLs is safe. |
Verification failures
A verification in FAILED carries errorCode and error. The analysis credits are refunded.
| Code | Kind | Meaning |
|---|---|---|
| AUTH_REQUIRED | errorCode | The video is private or requires login to view. Ask the creator to make it public. |
| VIDEO_UNAVAILABLE | errorCode | The video was removed or doesn't exist. |
| GEO_BLOCKED | errorCode | The video exists, but the platform doesn't serve it to the region the verification downloads from. |
| VIDEO_TOO_LONG | errorCode | The video is longer than 10 minutes, the current limit. |
| RATE_LIMITED | errorCode | The video platform blocked access even after automatic retries. |
| NETWORK_ERROR | errorCode | The video platform couldn't be reached. |
| DOWNLOAD_FAILED | errorCode | The download failed for another reason. |
| PIPELINE_FAILED | errorCode | Internal analysis failure. |
A verification in FAILED still holds its URL in the campaign: resubmitting the same URL returns duplicate. To reprocess it, contact us.
Limits and credits
- Up to 500 URLs per submission call. For more, split into batches.
- Videos up to 10 minutes long.
- Listings are paginated at 50 items per page.
- There's no published per-minute request limit today. What's queued is processing: submitting 500 videos at once is normal, they'll be ready over the following minutes.
- Billing is in credits: 1 credit per minute of video, and a partial minute counts as a full one. Credits come from the account's monthly plan or from extra credits. Submission checks that there is at least one credit per video; the exact debit happens when processing starts, once the duration is known. Without credits, the URL comes back as
insufficient_creditsand isn't queued.duplicateandinvalidURLs cost nothing, and failed analyses are refunded. - Plans and extra credits are managed in the dashboard, under Credits, by card (dollar accounts pay with an international card). High volume or contracts: Enterprise plan, through the contact form.
Best practices
- Set up the webhook before submitting videos, so you don't miss any result.
- Store the verification
idnext to the deliverable record in your system. It's what links the webhook to the right deliverable. - Use the normalized URL from
outcomes[].urlto match records, not the URL you sent. - Without a webhook, poll every 30–60 seconds and only the verifications that haven't finished.
- If a submission times out, resend the same batch: what already went in comes back as
duplicate, with no double charge. - Write the brief as you would for the creator, with concrete rules ("say the promo code SUMMER26") rather than generic ones ("speak well of the product").
- One campaign per brief: if the rules change per group of creators, create separate campaigns.
Versioning
The current version is v1, at /api/v1. Compatible changes, such as new response fields and new category values, ship without changing the version. Breaking changes go to a new version, with advance notice to API customers.
Questions?
Talk directly to the people who build the API. We'll help you design the integration.
Talk to the team