OnBriefing API documentation

Everything you need to add influencer video verification to your product: from the first curl to the webhook in production.

API v1 · updated 2026-10-09

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.

Base URL
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, field and 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_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Getting 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
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
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
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

node (18+)
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

python 3 + requests
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

TermTypeDescription
CampaignCampaignName, brand and the brief (rulesText). Groups the verifications.
BriefingrulesTextThe 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.
VerificationVerificationThe analysis of one video URL within a campaign.
Reportverdict + findingsThe result: overall verdict, summary and one line per rule, with evidence.
FindingFindingThe 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
                                                     ↘ FAILED

COMPLETED 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

POST/campaigns

Body

FieldTypeDescription
namestringRequired. Campaign name; can't be empty.
brandstring | nullOptional. Advertiser brand.
rulesTextstringRequired. Brief in plain text, at least 20 characters.
Request
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."
}
Response 201
{
  "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

GET/campaigns/:id

Returns the same object as creation, plus verificationCount , the total number of videos submitted to the campaign. The webhookSecret never appears here.

Response 200 (excerpt)
{
  "id": "cm8x1a2b3c4d5e6f7g8h9i0j",
  "name": "Summer 2026: launch",
  "hasWebhook": true,
  "webhookUrl": "https://your-app.com/webhooks/onbriefing",
  "verificationCount": 128,
  ...
}

Update campaign

PATCH/campaigns/:id

Send only the fields you want to change.

FieldTypeDescription
namestringNew name; can't be empty.
brandstring | nullNew brand; null removes it.
rulesTextstringNew brief, at least 20 characters. Applies to new submissions.
webhookUrlstring | nullURL 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.

Request
PATCH /api/v1/campaigns/cm8x1a2b3c4d5e6f7g8h9i0j
{ "webhookUrl": "https://your-app.com/webhooks/onbriefing" }
Response 200 (excerpt)
{
  "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

POST/campaigns/:id/verifications
FieldTypeDescription
urlsstring[]1 to 500 URLs of videos published on TikTok, Instagram or YouTube.
Request
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.

Response 200
{
  "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." }
  ]
}
StatusKindMeaning
queuedstatusQueued. Save the verification id.
duplicatestatusThis URL was already submitted to this campaign. Nothing is charged; use the existing verification.
invalidstatusMalformed URL or unsupported platform. detail explains why.
insufficient_creditsstatusThe 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.com and youtu.be.
  • The URL is normalized: the query string and # are dropped (except YouTube's v). The URL returned in outcomes[].url is 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

GET/campaigns/:id/verifications?status=&cursor=
ParameterTypeDescription
statusquery, optionalFilter by state: QUEUED, DOWNLOADING, TRANSCRIBING, ANALYZING, COMPLETED or FAILED.
cursorquery, optionalThe 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.

Response 200
{
  "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..."
}
Iterate over all pages (Node)
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

GET/verifications/:id

Returns the full verification, with the report and the evidence for each rule.

Response 200
{
  "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

FieldTypeDescription
statusenumQUEUED · DOWNLOADING · TRANSCRIBING · ANALYZING · COMPLETED · FAILED
platformenumTIKTOK · INSTAGRAM · YOUTUBE
verdictenum | nullAutomatic verdict: APPROVED (on brief), REJECTED (off brief) or INCONCLUSIVE (the evidence isn't enough and needs human review).
finalVerdictenum | nullVerdict after human review in the dashboard. null until someone reviews it. When set, it's the final word.
summarystring | nullReport summary (currently written in Portuguese).
contentCategoriesstring[]Up to 3 video format categories, most relevant first. Empty when not categorized.
errorCodestring | nullOnly on FAILED. See Errors.
errorstring | nullOnly on FAILED. Human-readable message about the failure.

Finding

FieldTypeDescription
ruleTextstringThe rule, as OnBriefing extracted it from the brief.
scopeenumVERIFIABLE: 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.
statusenumPASS · FAIL · UNVERIFIABLE
severityenumBLOCKER (prohibition or legal requirement), MAJOR (core deliverable) or MINOR (detail). It's the rule's weight and applies even when the rule passed.
rationalestringWhy the rule passed, failed or couldn't be verified.
confidencenumberFrom 0 to 1.
evidenceEvidence[]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.

FieldTypeDescription
FRAMEtypeSomething seen in a frame. Uses frameIndex, timestampMs and note (what is seen).
TRANSCRIPTtypeSomething said. Uses timestampMs and quote (verbatim excerpt of the speech).
SCREEN_TEXTtypeText overlaid on the video. Uses timestampMs and quote.
METADATAtypeA post field. Uses field (caption, hashtags, mentions, durationMs, author, dimensions) and value.
SOURCE_MATCHtypeExcerpt matching the campaign's official footage. Uses note.
ABSENTtypeThe 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).

CodeKindLabel
MEMEcategoryMeme / humor
CASALcategoryCouple
ESQUETEcategorySkit / role-play
TRENDcategoryTrend / dance
REVIEWcategoryReview / unboxing
TUTORIALcategoryTutorial / tips
RECEITAcategoryRecipe / cooking
BELEZA_MODAcategoryBeauty / fashion
VLOGcategoryVlog / routine
DEPOIMENTOcategoryTestimonial / opinion
REACTcategoryReaction / commentary
CORTEcategoryLive / podcast clip
GAMEPLAYcategoryGameplay
OUTROcategoryOther

Using the verdict

  • Use verdict for automatic triage: APPROVED moves on, REJECTED goes back to the creator with the FAIL findings, INCONCLUSIVE goes to human review.
  • If finalVerdict is set, it overrides verdict.
  • For creator feedback, show ruleText and rationale of findings with status: FAIL, sorted by severity.

Webhooks

With webhookUrl set on the campaign, OnBriefing sends a POST to that URL whenever a verification reaches a final state.

EventKindWhen
verification.completedeventThe analysis finished and the report is ready.
verification.failedeventThe 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.

Node.js (Express)
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);
  },
);
Next.js (route handler)
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 });
}
Python (Flask)
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 "", 200

Delivery and retries

  • Respond with any 2xx status 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.id as 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

StatusKindWhen
200 / 201successRequest accepted. 201 when creating a campaign.
400requestBody isn't JSON, a field has the wrong type, a batch has more than 500 URLs or the filter status is invalid.
401authenticationMissing, invalid or revoked key.
404not foundCampaign or verification doesn't exist or belongs to another organization.
422validationA field has an invalid value (name empty, rulesText too short). See field. On PATCH, these cases may come back as 400.
5xxserverOnBriefing error. Retry with exponential backoff; resubmitting URLs is safe.

Verification failures

A verification in FAILED carries errorCode and error. The analysis credits are refunded.

CodeKindMeaning
AUTH_REQUIREDerrorCodeThe video is private or requires login to view. Ask the creator to make it public.
VIDEO_UNAVAILABLEerrorCodeThe video was removed or doesn't exist.
GEO_BLOCKEDerrorCodeThe video exists, but the platform doesn't serve it to the region the verification downloads from.
VIDEO_TOO_LONGerrorCodeThe video is longer than 10 minutes, the current limit.
RATE_LIMITEDerrorCodeThe video platform blocked access even after automatic retries.
NETWORK_ERRORerrorCodeThe video platform couldn't be reached.
DOWNLOAD_FAILEDerrorCodeThe download failed for another reason.
PIPELINE_FAILEDerrorCodeInternal 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_credits and isn't queued. duplicate and invalid URLs 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 id next to the deliverable record in your system. It's what links the webhook to the right deliverable.
  • Use the normalized URL from outcomes[].url to 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

Talk to us

Tell us about your campaign. We reply by email.

We only use this to reply to you.