Skip to content
Dashboard

Async Jobs & Polling

Both generate endpoints run in the background. Here is how to start a job and wait for it correctly.

Asynchronous Operations

Content generation is asynchronous. POST /articles/generate and POST /social/generate both return 202 immediately, along with the id of the row that was created and a poll_url you call until the work finishes.

There is no webhook delivery yet. The API cannot call you back when a job completes. There is no endpoint to register a callback URL, no signed payload to verify, and no delivery retry. Polling the poll_url is the supported way to know when content is ready.

Article Generation Flow

Generating an article is a three-step pattern. Note that project_id is required: it supplies the business context and the output language.

1

Start Generation

POST /api/v1/articles/generate returns 202 with article_id, status: "queued", and a poll_url.

2

Poll Status

GET /api/v1/articles/:id/status until is_complete is true, or until the status is failed.

3

Retrieve Result

GET /api/v1/articles/:id for the full article content.

202Article generation started
{
  "success": true,
  "data": {
    "article_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "status": "queued",
    "job_id": "job_01hxyz",
    "slug": "content-marketing-trends-2026",
    "language": "en",
    "credits_deducted": 1,
    "credits_remaining": 32,
    "estimated_time": "10-15 minutes",
    "poll_url": "/api/v1/articles/a1b2c3d4-e5f6-7890-abcd-ef1234567890/status"
  },
  "meta": {
    "timestamp": "2026-02-01T00:00:00Z"
  }
}

Article Status Values

These are the only values articles.status ever holds. A finished article is cms. There is no published, no generating, and no scheduled status. A polling loop that waits for any of those will never exit.

StatusMeaningKeep polling?
queuedThe job is runningYes
draftSaved but not run through the pipelineYes
cmsFinished and live. This is the completed state.No
needs_editingFinished, but flagged for a human pass before it goes outNo
failedGeneration hit an errorNo
archivedRetired by the ownerNo

The status endpoint also returns an is_complete boolean. Use it and you do not have to hard-code the enum at all.

Polling Article Status

The complete generate-and-poll flow. The status response contains article_id, status, title, slug, progress, is_complete, created_at, and updated_at.

# Start generation. project_id is required.
curl -X POST https://www.brainpercent.app/api/v1/articles/generate \
  -H "Authorization: Bearer bp_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "topic": "Content marketing trends in 2026",
    "project_id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
    "tone": "professional",
    "word_count": 1200
  }'

# Poll status (repeat until is_complete is true)
curl https://www.brainpercent.app/api/v1/articles/ARTICLE_ID/status \
  -H "Authorization: Bearer bp_your_key"

Social Media Generation Flow

Social generation returns a single content_id, no matter how many platforms you asked for. One row holds every platform variant, so there is exactly one thing to poll.

202Social generation started
{
  "success": true,
  "data": {
    "content_id": "b1c2d3e4-f5a6-7890-abcd-ef1234567890",
    "status": "generating",
    "platforms": [
      "twitter",
      "linkedin"
    ],
    "credits_deducted": 1.2,
    "credits_remaining": 48.8,
    "poll_url": "/api/v1/social/content/b1c2d3e4-f5a6-7890-abcd-ef1234567890"
  },
  "meta": {
    "timestamp": "2026-02-01T00:00:00Z"
  }
}

Social Status Values

draft, queued, generating, ready, completed, scheduled, published, failed. Content is usable once the status is ready or completed.

Read captions from the platforms object, which is keyed by platform name once generation finishes. While the job is still running, platforms is a plain array of the requested names instead, so check the shape before you index into it. Read images from image_urls, which is also keyed by platform and is permanent.

async function generateSocialAndWait(apiKey, sourceUrl, platforms, projectId) {
  const BASE = 'https://www.brainpercent.app/api/v1';
  const headers = { 'Authorization': `Bearer ${apiKey}` };

  const genRes = await fetch(`${BASE}/social/generate`, {
    method: 'POST',
    headers: { ...headers, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      source_url: sourceUrl,
      platforms,
      project_id: projectId,
    }),
  });
  const { data: genData } = await genRes.json();
  const contentId = genData.content_id;

  // One row covers every platform, so there is one thing to poll.
  while (true) {
    const res = await fetch(`${BASE}/social/content/${contentId}`, { headers });
    const content = (await res.json()).data;

    if (content.status === 'failed') throw new Error('Social generation failed');
    if (content.status === 'ready' || content.status === 'completed') {
      // Captions are keyed by platform once generation finishes.
      return Object.entries(content.platforms).map(([platform, caption]) => ({
        platform,
        caption,
        imageUrl: content.image_urls?.[platform] ?? null,
      }));
    }

    await new Promise(resolve => setTimeout(resolve, 10000));
  }
}

Poll every 10 to 15 seconds. If you are running many jobs at once, back off exponentially on each check so a slow job does not eat your rate limit.

OperationPoll everyTypical durationSuggested client timeout
Article generation15 secondsRoughly 10 to 15 minutes30 minutes
Social media generation10 secondsScales with the number of platforms30 minutes

A tight loop is wasted work: the article pipeline runs for minutes, not seconds. Polling faster than every 10 seconds only burns rate limit.

Timeout Strategies

Always implement a timeout so a stuck job cannot hold your process open forever. If the deadline passes, stop polling and surface the id to the user. The content row still exists and you can check it later.

// Polls with exponential backoff, capped at 60 seconds between checks.
async function pollUntilDone(url, headers, isDone, timeoutMs = 1800000) {
  const deadline = Date.now() + timeoutMs;
  let delay = 10000;

  while (Date.now() < deadline) {
    const res = await fetch(url, { headers });
    const { data } = await res.json();

    if (data.status === 'failed') throw new Error('Job failed');
    if (isDone(data)) return data;

    await new Promise(resolve => setTimeout(resolve, delay));
    delay = Math.min(delay * 1.5, 60000);
  }

  throw new Error('Polling timed out');
}

// Articles: is_complete covers 'cms' and 'needs_editing'.
await pollUntilDone(articleStatusUrl, headers, d => d.is_complete);

// Social: done means 'ready' or 'completed'.
await pollUntilDone(socialContentUrl, headers,
  d => d.status === 'ready' || d.status === 'completed');

Webhook Callbacks Are Not Available

To be blunt about what does not exist yet, so you do not build against it:

  • There is no endpoint to register a callback URL.
  • Brainpercent never sends you an HTTP request, so there is no signature to verify and no event payload schema.
  • There is no delivery queue, no retry policy, and no event log.

Callbacks are on the roadmap. Until they ship, poll the poll_url returned by each 202. If you need push-style behavior inside an automation tool, run the polling step there: the n8n, Make.com, and Zapier guides all show a wait-then-check pattern that does this for you.