BrenzuriStart free
Automate with the REST API
Guides

Automate with the REST API

Create a key, ask for an article, follow the job, read what came back. A person approves.

On this page

This path takes about ten minutes the first time. It needs a Brenzuri workspace with one ready site, and a person who can create a key.

  1. Make a key

    In Brenzuri open Developer › API keys and choose New key. Give it a name, bind it to one site, tick the scopes you need, and set a daily cap. The token is shown once; copy it.

    For thisTick
    Create and estimate briefsbrief
    Start articlesgenerate
    Read articles and sites, follow jobsread
    Add sources, edit, steer jobsedit
    Export and deliverexport
    Shell
    export BRENZURI_URL="https://your-brenzuri-host"
    export BRENZURI_KEY="bz_live_…"
  2. Find your site

    A key bound to a site sees only that site, so the list has one row. You need its id.

    curl "$BRENZURI_URL/api/v1/sites" \
      -H "Authorization: Bearer $BRENZURI_KEY"
    JSON
    [
      {
        "id": "3b6f0c52-6a41-4d0e-9a53-71c8f2d5a1e0",
        "domain": "kolibri.example",
        "siteName": "Kolibri Courier",
        "status": "ready",
        "analysedAt": "2026-09-28",
        "offeredTopics": 7,
        "articleCount": 23,
        "needsReviewCount": 2
      }
    ]
    GET /sites, with `status: "ready"` meaning the site has been read
  3. Create a brief

    Only topic matters to start with; everything else has a default. A site-bound key gets its site without being told. Creating a brief costs nothing.

    curl -X POST "$BRENZURI_URL/api/v1/briefs" \
      -H "Authorization: Bearer $BRENZURI_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "topic": "Ferry operators agree on a single timetable for the northern route",
        "angle": "What changes for commuters from November",
        "purpose": "news",
        "length": "standard",
        "mustAppear": [
          "the November start date"
        ],
        "illustration": "none"
      }'
    JSON
    {
      "id": "5D2E8A14-7B3C-4F69-A0D1-9C46E3B7F208",
      "siteId": "3b6f0c52-6a41-4d0e-9a53-71c8f2d5a1e0",
      "topic": "Ferry operators agree on a single timetable for the northern route",
      "angle": "What changes for commuters from November",
      "useUrls": [],
      "excludeDomains": [],
      "purpose": "news",
      "audience": "",
      "region": "Global",
      "language": "en",
      "tone": "neutral",
      "length": "standard",
      "targetWords": 900,
      "structure": "ibc",
      "keywords": [],
      "mustAppear": [
        "the November start date"
      ],
      "preferDomains": [],
      "illustration": "none",
      "seo": true
    }
    The brief, as stored
  4. Estimate

    Ask whether this key may afford the run. You get a yes or no and the steps, never the balance.

    curl -X POST "$BRENZURI_URL/api/v1/briefs/$BRIEF_ID/estimate" \
      -H "Authorization: Bearer $BRENZURI_KEY"
    JSON
    {
      "credits": 1,
      "parts": [
        {
          "label": "Article",
          "credits": 1,
          "kind": "article"
        }
      ],
      "steps": [
        {
          "id": "find",
          "name": "Searching your domains",
          "estMs": 14200
        },
        {
          "id": "group",
          "name": "Grouping by origin",
          "estMs": 6100
        },
        {
          "id": "read",
          "name": "Reading one page per origin",
          "estMs": 39400
        },
        {
          "id": "check",
          "name": "Checking claims across origins",
          "estMs": 30800
        },
        {
          "id": "write",
          "name": "Writing",
          "estMs": 48000
        },
        {
          "id": "seo",
          "name": "Writing the SEO fields",
          "estMs": 4000
        },
        {
          "id": "qa",
          "name": "Reading the finished text back",
          "estMs": 9000
        }
      ],
      "enough": true
    }

    If enough is false, stop. The cause is one of: the trial has ended, the workspace balance is below credits, or the key’s daily cap would be passed.

  5. Start the article

    This is the one call that spends: the credits are held now and captured when the job finishes. If it fails they are released.

    curl -X POST "$BRENZURI_URL/api/v1/articles" \
      -H "Authorization: Bearer $BRENZURI_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "briefId": "{briefId}"
      }'
    JSON
    {
      "articleId": "9F3B1C2A-6D4E-4B7A-8C15-2E0A7D91B3F4",
      "jobId": "C8A41E07-52B9-4D36-8F1A-0B7E9D3C6A25",
      "creditsHeld": 1,
      "briefId": "5D2E8A14-7B3C-4F69-A0D1-9C46E3B7F208",
      "steps": [
        {
          "id": "find",
          "name": "Searching your domains",
          "estMs": 14200
        },
        {
          "id": "group",
          "name": "Grouping by origin",
          "estMs": 6100
        },
        {
          "id": "read",
          "name": "Reading one page per origin",
          "estMs": 39400
        },
        {
          "id": "check",
          "name": "Checking claims across origins",
          "estMs": 30800
        },
        {
          "id": "write",
          "name": "Writing",
          "estMs": 48000
        },
        {
          "id": "seo",
          "name": "Writing the SEO fields",
          "estMs": 4000
        },
        {
          "id": "qa",
          "name": "Reading the finished text back",
          "estMs": 9000
        }
      ]
    }
  6. Follow the job

    The simplest way is to read the job until its status is done, failed or cancelled. paused means a step failed and the job waits for someone to retry, skip or cancel it. A job takes a few minutes, so poll every 3 to 5 seconds.

    curl "$BRENZURI_URL/api/v1/jobs/$JOB_ID" \
      -H "Authorization: Bearer $BRENZURI_KEY"
    JSON
    {
      "id": "C8A41E07-52B9-4D36-8F1A-0B7E9D3C6A25",
      "kind": "article",
      "status": "running",
      "step": "check",
      "stepIndex": 3,
      "startedAt": "2026-10-06T12:00:03Z",
      "articleId": "9F3B1C2A-6D4E-4B7A-8C15-2E0A7D91B3F4"
    }
    GET /jobs/{id}, without its steps list, which names every step and its status

    Reading the article until its status is no longer generating works too, and run.jobId on it names the job.

    To watch the steps as they happen, follow the event stream. It is server-sent events: each frame has an id and a data line holding a job event. Reconnect with Last-Event-ID and you resume after the last frame you saw. The stream closes after done or refunded; a failed step leaves it open, because the job is paused and can be steered.

    curl -N "$BRENZURI_URL/api/v1/jobs/$JOB_ID/events" \
      -H "Authorization: Bearer $BRENZURI_KEY" \
      -H "Last-Event-ID: 4"

    Better than polling either way: register a webhook for job.done and job.failed and let Brenzuri call you.

  7. Read the article

    When the status is needsReview the article is written and waiting for a person. Look at the label and at the flags.

    JSON
    {
      "id": "9F3B1C2A-6D4E-4B7A-8C15-2E0A7D91B3F4",
      "status": "needsReview",
      "title": "Ferry operators agree on a single timetable for the northern route",
      "claims": [
        {
          "id": "E1A7C3B9-0D42-4F58-96AE-3B1C8D7F2045",
          "text": "The shared timetable starts on 3 November.",
          "state": "corroborated",
          "originCount": 2
        },
        {
          "id": "4B90D6E2-A317-4C8B-B5F0-7E2A19C3D864",
          "text": "The route will have 14 daily crossings instead of 9.",
          "state": "singleSource",
          "originCount": 1
        },
        {
          "id": "A63F2E58-9C10-47DB-8E3A-D5B07C41F192",
          "text": "A single fare table applies on all crossings.",
          "state": "corroborated",
          "originCount": 2
        }
      ],
      "label": {
        "reviewedByHuman": false
      }
    }
    GET /articles/{id}, trimmed to the parts that decide what happens next
    • label.reviewedByHuman is false. It stays false until a person approves; nothing you send can change it.
    • Claims whose state is not corroborated are flagged. Add a source for one to clear it, or leave it for the editor.
  8. A person approves

    Approval happens in the Brenzuri interface, by an Owner or Editor. The article moves to approved and the article.approved webhook fires. There is no API route for this step, by design.

  9. Export or deliver

    Render a file, then download it with the same key. Or deliver to the site’s connection as a draft.

    curl -X POST "$BRENZURI_URL/api/v1/articles/$ARTICLE_ID/export" \
      -H "Authorization: Bearer $BRENZURI_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "target": "markdown"
      }'
    JSON
    {
      "url": "/api/v1/exports/6E2B0D94-7C1A-4F35-98D2-A1B3C4D5E6F7"
    }
    Shell
    curl "$BRENZURI_URL$EXPORT_URL" \
      -H "Authorization: Bearer $BRENZURI_KEY" \
      -o article.md

    If the workspace requires approval before export, an unapproved article is refused with 409 approval_required.

The whole run in one script#

Everything up to the point where a person takes over. It stops with an error that names the code if any call fails.

const base = `${process.env.BRENZURI_URL}/api/v1`;
const headers = {
  Authorization: `Bearer ${process.env.BRENZURI_KEY}`,
  'Content-Type': 'application/json',
};

async function call(method, path, body) {
  const res = await fetch(base + path, { method, headers, body: body ? JSON.stringify(body) : undefined });
  if (res.status === 204) return null;
  const data = await res.json();
  if (!res.ok) throw new Error(`${res.status} ${data.error.code}: ${data.error.message}`);
  return data;
}

const [site] = await call('GET', '/sites');
const brief = await call('POST', '/briefs', {
  siteId: site.id,
  topic: 'Ferry operators agree on a single timetable for the northern route',
  purpose: 'news',
});
const estimate = await call('POST', `/briefs/${brief.id}/estimate`);
if (!estimate.enough) throw new Error('This run does not fit the balance or the key’s daily cap');
const started = await call('POST', '/articles', { briefId: brief.id });

let article;
do {
  await new Promise((r) => setTimeout(r, 5000));
  article = await call('GET', `/articles/${started.articleId}`);
} while (article.status === 'generating');

const flagged = article.claims.filter((c) => c.state !== 'corroborated');
console.log(`${article.title}: ${flagged.length} flagged, status ${article.status}`);

Handling failures#

  • Branch on error.code, not on the status or the message. See Errors.
  • A 429 carries Retry-After. Wait that long. See Rate limits and caps.
  • A 402 means the workspace cannot cover the run. A 429 budget_exhausted means this key spent its daily cap; the person who made it can raise it.
  • Starting twice for the same subject is refused with 409 article_exists unless you send alsoNew: true. There are no idempotency keys, so check before retrying a start that timed out: list articles with GET /articles?status=generating.