Skip to content
Browse the docs

Docs / Developers

Using the API: a walk-through

From a key to a published episode in code: create a project, pick a niche, make a channel, start episodes, approve and publish.

This walk-through takes one channel from nothing to a published episode with the REST API, in curl, then shows the same calls in TypeScript and Python. Every call, field and answer below is the API's own; ids are shortened to …. Keep the REST API reference open beside it for the full list of fields.

 key ─▶ GET /me
         │
         ▼
 POST /projects ──▶ niche research job ──▶ GET /jobs/{id}   (the shortlist)
         │
         ▼
 draft the channel from a niche   (in the app, today)
         │
         ▼
 POST /jobs kit.create ─▶ POST /jobs brand.create          (GPU jobs)
         │
         ▼
 GET /quote ─▶ POST /episodes/generate (max_credits) ─▶ GET /jobs/{id}
         │
         ▼
 GET /episodes ─▶ POST /episodes/{id}/approve or /reject
         │
         ▼
 the channel's schedule posts it, or POST /jobs episode.publish
         │
         ▼
 POST /webhooks ─▶ your endpoint hears episode.ready, episode.published …

Before you start#

  • A workspace on Pro or above, with credits. The API is not part of smaller plans.
  • An API key from Settings → API & webhooks with the scopes read, write, jobs and publish (one key for the whole walk-through; a real integration should give each part its own key — see Keys and scopes).
  • Two shell variables, so the commands below can be pasted as they are:
bash
export FORGE_API=https://forge.storeswiki.com/api/v1
export FORGE_KEY=fk_live_…        # in real use, read it from a secret store, not your shell history

How long things take: niche research usually under two minutes; a kit and a brand suite are GPU jobs; an episode takes about one to two hours on a GPU, most of it the writer's rounds and the checks.

1. Check the key#

bash
curl -s $FORGE_API/me -H "Authorization: Bearer $FORGE_KEY"
json
{
  "workspace": { "id": "ws_…", "slug": "northwind", "name": "Northwind Media", "plan": "pro", "credits": 3160, "state": "active" },
  "key": { "id": "key_…", "name": "walk-through", "scopes": ["read", "write", "jobs", "publish"] }
}

401 unauthenticated means the key is wrong, revoked or expired; 403 plan means the workspace's plan has no API.

2. Create a project from a brief#

A project holds the channels made from one brief. Creating one starts its niche research at once (send "research": false to skip it).

bash
curl -s -X POST $FORGE_API/projects \
  -H "Authorization: Bearer $FORGE_KEY" -H "Content-Type: application/json" \
  -d '{
    "name": "How things stand up",
    "brief": "Short, original explainers of how bridges, towers and dams carry their loads, for curious adults. Calm, precise, no hype.",
    "language": "en"
  }'
FieldRules
name2–80 characters
brief30–4,000 characters: what you want, for whom, in what tone — it steers every proposal
languagea BCP 47 primary tag, en by default
researchtrue by default

The answer (201) carries the project, the research job and its price:

json
{
  "id": "prj_…",
  "job_id": "job_…",
  "price": {
    "reserved": 7, "typical": 6, "low": 5, "high": 7,
    "basis": "Most niche research runs on ChannelForge cost 5–7 credits; 7 is reserved and the rest returned.",
    "source": "platform", "samples": 41, "window_days": 60
  }
}

If the research cannot start (not enough credits, a spending cap), the project is still made: job_id is null and research says why, as {"started": false, "code": "credits", "message": "…"}. Start it later with POST /jobs and {"kind": "niches.suggest", "project_id": "prj_…"}.

3. Follow the research#

bash
curl -s $FORGE_API/jobs/job_… -H "Authorization: Bearer $FORGE_KEY"
json
{
  "id": "job_…", "kind": "niches.suggest", "state": "succeeded", "phase": "…", "progress": 1,
  "error": null, "error_kind": null,
  "result": { "n": 10, "shortlist": ["load-paths-explained", "dams-under-pressure", "towers-in-the-wind"] },
  "attempts": 1, "credits_reserved": 7, "credits_charged": 6,
  "queued_at": "2026-11-02T09:00:01.120Z", "started_at": "2026-11-02T09:00:02.004Z", "finished_at": "2026-11-02T09:01:31.877Z"
}

A job's state goes queued → running → succeeded, failed or cancelled. result.n is how many niches were proposed and scored; result.shortlist names the ones that work best together. credits_charged is what it really cost; the rest of credits_reserved came back when it ended.

4. Choose a niche and draft the channel#

  1. In the app, open Projects and then your project. Its Niche proposals show each niche with its score, what is on screen, why it works, its competition, difficulty and risk.
  2. Press Draft this channel on the one you want, or Draft the N shortlisted to draft the whole shortlist. Each draft is a short job of a few credits, its price shown beside the button.
  3. Back in code, find the channel:
bash
curl -s $FORGE_API/channels -H "Authorization: Bearer $FORGE_KEY"
json
{
  "data": [
    {
      "id": "ch_…", "project_id": "prj_…", "key": "loadpath", "name": "Load Path", "handle": "@loadpath",
      "niche": "How bridges and towers carry their loads", "state": "draft", "kit_state": null, "brand_state": null,
      "schedule_enabled": false, "post_times": ["09:00", "15:00", "21:00"], "timezone": "Europe/Rome",
      "publish_platforms": ["youtube", "facebook", "instagram"], "episodes_made": 0, "last_episode_at": null
    }
  ]
}

A channel's state moves from draft to brand once its kit is built (it reads kit while a build started in the app runs), to ready once its brand suite is built, and to live when its schedule is switched on; a paused channel makes and posts nothing. GET /channels/{id} gives one channel in full: its settings, schedule and next slots, whether it asks for approval, which platforms are connected, and its episodes ready, waiting for approval and held.

5. Build the kit and the brand suite#

A channel's kit is its own renderer, built for its niche on a GPU; every episode is drawn with it. The brand suite is its logo, banners, covers and copy for every platform, rendered with the kit. Both are one-time setup jobs, priced closer to cost than episodes.

bash
curl -s -X POST $FORGE_API/jobs \
  -H "Authorization: Bearer $FORGE_KEY" -H "Content-Type: application/json" \
  -d '{"kind": "kit.create", "channel_id": "ch_…", "idempotency_key": "loadpath-kit-1"}'
json
{
  "id": "job_…", "state": "queued", "credits_reserved": 275,
  "price": {
    "reserved": 275, "typical": 205, "low": 160, "high": 248,
    "basis": "Most kit builds on ChannelForge cost 160–248 credits; 275 is reserved and the rest returned.",
    "source": "platform", "samples": 9, "window_days": 60
  }
}

Follow it with GET /jobs/{id} as in step 3. When it succeeds, the channel's kit_state is built, and the channel's first topics are planned by themselves. Then the brand suite, the same way:

bash
curl -s -X POST $FORGE_API/jobs \
  -H "Authorization: Bearer $FORGE_KEY" -H "Content-Type: application/json" \
  -d '{"kind": "brand.create", "channel_id": "ch_…", "idempotency_key": "loadpath-brand-1"}'

The brand suite is not needed to make episodes, only the kit is. Wait for the kit to finish before you start the brand suite: two setup jobs of one channel never run at the same time, and the suite is drawn with the kit.

KindNeedsWhat it does
kit.createchannel_idbuilds the channel's renderer kit on a GPU; params.repair and params.notes improve an existing one
brand.createchannel_idlogos, banners, covers and the brand copy, rendered with the kit
topics.seedchannel_idplans more topics; params.n from 4 to 30

6. Price and generate episodes#

Ask the price first. GET /quote with a channel and a count prices generating episodes, before anything is held:

bash
curl -s "$FORGE_API/quote?channel_id=ch_…&count=2" -H "Authorization: Bearer $FORGE_KEY"
json
{
  "action": "generate_episodes",
  "channel": { "id": "ch_…", "name": "Load Path" },
  "quality": "standard",
  "count": 2,
  "credits_per_episode": 231,
  "credits_total": 462,
  "credits_available": 2810,
  "enough": true,
  "max_credits_to_pass": 462,
  "basis": "hold",
  "how_it_is_charged": "Each episode holds this many credits when it is queued — about what a typical episode costs. When it is ready it is charged what it really cost to make (model and GPU time at your plan’s rate), which can be somewhat more or less, and the hold is released. An episode the critic holds back, and a failure on our side, cost nothing.",
  "typical_credits_per_episode": 180,
  "typical_range": [165, 199],
  "typical_from": "platform",
  "price_in_words": "Most episodes on ChannelForge cost 165–199 credits; 231 is reserved and the rest returned.",
  "typical_measured_credits": 177,
  "measured_on": "2026-11-01"
}

credits_per_episode is what each episode holds while it is made; typical_credits_per_episode is what episodes like it were really charged lately (on this channel, else this workspace, else across ChannelForge); typical_measured_credits is ChannelForge's own measured median at your plan's rate. Add quality=premium to price premium episodes (Pro and above).

Then generate, passing the quote's max_credits_to_pass as max_credits:

bash
curl -s -X POST $FORGE_API/episodes/generate \
  -H "Authorization: Bearer $FORGE_KEY" -H "Content-Type: application/json" \
  -d '{"channel_id": "ch_…", "count": 2, "max_credits": 462, "idempotency_key": "loadpath-2026-11-02"}'
json
{
  "channel": { "id": "ch_…", "name": "Load Path" },
  "quality": "standard",
  "queued": [ { "job_id": "job_…", "credits_held": 231 }, { "job_id": "job_…", "credits_held": 231 } ],
  "credits_held": 462,
  "stopped_because": null,
  "how_it_is_charged": "Each episode holds this many credits when it is queued — …",
  "follow_with": "get_job"
}
FieldRules
channel_idrequired; the channel's kit must be built
max_creditsrequired: the most you accept to have held. Below the quote's total the request is refused with 409 quote and nothing is queued or spent
count1–5, default 1
qualitystandard or premium; the channel's own by default
idempotency_key8–80 of A–Z a–z 0–9 _ . : -: the same key again returns the same jobs instead of queuing more

stopped_because says why fewer than you asked were queued (a plan limit on jobs in the queue, the credits). Follow each job with GET /jobs/{id} — follow_with names the matching MCP tool — or, better, let a webhook tell you (step 10). When an episode job succeeds, its result.episodes lists what it made:

json
{ "episodes": [ { "episode_id": "ep_…", "title": "Why the Golden Gate sways", "critic": 8, "state": "ready", "reason": null } ], "quality": "standard" }

When the critic does not pass an episode after its fix rounds, the job ends failed with error_kind quality: the episode is in the library as held, for you to look at, and it is not charged. POST /jobs with {"kind": "episode.generate", "channel_id": "ch_…"} also starts an episode; it answers with the price but takes no max_credits ceiling, so prefer /episodes/generate in code that runs unattended.

7. Read what was made#

bash
curl -s "$FORGE_API/episodes?channel_id=ch_…&state=ready&limit=10" -H "Authorization: Bearer $FORGE_KEY"
json
{
  "data": [
    { "id": "ep_…", "channel_id": "ch_…", "title": "Why the Golden Gate sways", "topic_key": "golden-gate-sway",
      "state": "ready", "duration_s": 148, "score": 8, "created_at": "2026-11-02T11:42:10.000Z" }
  ],
  "next_cursor": null
}

Pages are newest first; pass next_cursor as cursor for the next page, until it is null. limit is 1–100 (default 25). An episode's state is generating, ready, held, failed, published or archived; score is the critic's score out of 10.

One episode, with its posts and links to its files:

bash
curl -s $FORGE_API/episodes/ep_… -H "Authorization: Bearer $FORGE_KEY"
json
{
  "id": "ep_…", "channel_id": "ch_…", "title": "Why the Golden Gate sways", "state": "ready",
  "duration_s": 148, "score": 8, "created_at": "2026-11-02T11:42:10.000Z",
  "posts": [],
  "video_url": "https://…", "thumb_url": "https://…", "urls_expire_in": 900
}

video_url and thumb_url are signed links that work for 15 minutes (urls_expire_in seconds): download the file within that time, or ask again for fresh links.

8. Approve or hold#

A channel can ask for approval before each episode posts: in the app, open the channel and, under Schedule & generation, tick Ask for approval before each episode posts. Its schedule then posts only approved episodes, and GET /channels/{id} counts those waiting in episodes.waiting_approval.

Approve one (scope publish):

bash
curl -s -X POST $FORGE_API/episodes/ep_…/approve -H "Authorization: Bearer $FORGE_KEY"
json
{ "episode_id": "ep_…", "approved": true, "already": false, "posting": "Load Path’s schedule posts it in its turn." }

Approving posts nothing at once: the channel's schedule posts it in its next slots. While the schedule is off, posting says that it will post once someone turns the schedule on. Approving twice is harmless: the second answer has "already": true.

Hold one back instead (scope write), with a reason a person will read:

bash
curl -s -X POST $FORGE_API/episodes/ep_…/reject \
  -H "Authorization: Bearer $FORGE_KEY" -H "Content-Type: application/json" \
  -d '{"reason": "The opening claims a record we could not source."}'
json
{ "episode_id": "ep_…", "state": "held", "already": false, "reason": "The opening claims a record we could not source." }

A held episode never posts. A member releases it again from its page in the app. Holding sends the episode.held event. Only a ready episode can be approved or held: anything else answers 409 state.

9. Publish#

There are two ways an episode goes out.

On the channel's schedule. A person switches the schedule on in the app: the channel's Schedule & generation card, Post automatically on these slots, with its Times, Time zone and the platforms under Post to. From then on every slot posts the oldest ready (and, where asked, approved) episode the platform has not had yet. The API never switches a schedule on; see Schedules and autopilot.

Now, through the API (scope publish): a post to one platform connected to the episode's channel.

bash
curl -s -X POST $FORGE_API/jobs \
  -H "Authorization: Bearer $FORGE_KEY" -H "Content-Type: application/json" \
  -d '{"kind": "episode.publish", "episode_id": "ep_…", "params": {"platform": "linkedin"}, "idempotency_key": "ep-linkedin-1"}'
json
{
  "id": "job_…", "state": "queued", "credits_reserved": 0,
  "price": { "reserved": 0, "typical": 0, "low": 0, "high": 0, "basis": "Free — it uses no credits.", "source": "free", "samples": 0, "window_days": null }
}
  • params.platform is one of facebook, instagram, linkedin, x, threads or bluesky, connected to the channel and included in your plan. Posting is free, except on X, which charges ChannelForge for every post: an X post reserves its price (see GET /quote?kind=episode.publish&platform=x) and is charged only once it is live.
  • YouTube posts are made by ChannelForge in the app — on the schedule, or from the episode's page — never through the API: asking answers 400 youtube. TikTok and Pinterest posts are each made by a person on the episode's own TikTok or Pinterest screen.
  • A post never goes out twice: asking again for a platform the episode is already on returns "already": true in the job's result. A post whose answer was lost is marked as possibly live and is not retried blindly.
  • A direct post goes out whether or not the channel asks for approval: approval decides what the schedule posts.

When the job succeeds, its result is {"platform", "url", "id", "already"}, and the episode's posts show it:

json
{ "id": "post_…", "platform": "linkedin", "state": "posted", "url": "https://www.linkedin.com/feed/update/…", "posted_at": "2026-11-02T18:00:04.512Z", "views": null }

A YouTube post appears with its id, platform, state, link and time only — never with a views field.

10. Be told: a webhook#

Rather than polling, subscribe your endpoint to the events you care about (any scope):

bash
curl -s -X POST $FORGE_API/webhooks \
  -H "Authorization: Bearer $FORGE_KEY" -H "Content-Type: application/json" \
  -d '{"url": "https://hooks.example.com/channelforge", "events": ["episode.ready", "episode.held", "episode.published", "job.failed"], "label": "walk-through"}'
json
{
  "id": "whk_…", "url": "https://hooks.example.com/channelforge", "format": "json",
  "events": ["episode.ready", "episode.held", "episode.published", "job.failed"],
  "active": true, "created_via": "api", "label": "walk-through", "failures": 0,
  "last_status": null, "last_sent_at": null, "created_at": "2026-11-02T09:10:00.000Z",
  "secret": "whsec_…"
}

Keep the secret: it is shown only in this answer, and every delivery is signed with it. Each delivery is a POST of {"id", "type", "created", "data"}; answer with any 2xx within 10 seconds. A small receiver in Node.js that checks the signature and ignores repeats:

ts
import { createServer } from 'node:http';
import { createHmac, timingSafeEqual } from 'node:crypto';

const SECRET = process.env.FORGE_WEBHOOK_SECRET ?? ''; // the whsec_… of the subscription
const seen = new Set<string>(); // keep these in your database in production

function verify(header: string, raw: string): boolean {
  const parts = Object.fromEntries(header.split(',').map((kv) => kv.split('=')));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false; // older than 5 minutes: a replay
  const want = createHmac('sha256', SECRET).update(`${t}.${raw}`).digest('hex');
  return typeof parts.v1 === 'string' && parts.v1.length === want.length && timingSafeEqual(Buffer.from(parts.v1), Buffer.from(want));
}

createServer((req, res) => {
  const chunks: Buffer[] = [];
  req.on('data', (c) => chunks.push(c));
  req.on('end', () => {
    const raw = Buffer.concat(chunks).toString('utf8'); // the exact bytes, before JSON.parse
    if (!verify(String(req.headers['x-forge-signature'] ?? ''), raw)) return void res.writeHead(401).end();
    res.writeHead(200).end(); // answer first, work afterwards
    const event = JSON.parse(raw);
    if (seen.has(event.id)) return; // a retry of an event already handled
    seen.add(event.id);
    if (event.type === 'episode.ready') console.log('ready:', event.data.episode.title, event.data.app_url);
  });
}).listen(8080);

The events, payloads, retries and the Slack and Discord formats are in Webhooks and REST hooks. Remove the subscription with DELETE /webhooks/whk_… when you no longer need it.

The flow in TypeScript#

A small client for Node.js 22 (its built-in fetch), with the API's error shape and its rate limit handled, then tonight's episodes generated within their quote:

ts
const API = process.env.FORGE_API ?? 'https://forge.storeswiki.com/api/v1';
const KEY = process.env.FORGE_KEY ?? '';
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

async function call<T>(method: string, path: string, body?: unknown): Promise<T> {
  for (;;) {
    const r = await fetch(API + path, {
      method,
      headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
      body: body === undefined ? undefined : JSON.stringify(body),
    });
    if (r.status === 429) { await sleep(Number(r.headers.get('retry-after') ?? 5) * 1000); continue; }
    const data = await r.json();
    if (!r.ok) throw new Error(`${r.status} ${data.error.code}: ${data.error.message}`);
    return data as T;
  }
}

type Quote = { enough: boolean; max_credits_to_pass: number; credits_available: number };
type Job = { state: string; error: string | null; error_kind: string | null; result: { episodes?: { episode_id: string; title: string; state: string }[] } | null };

export async function nightly(channelId: string, count = 2) {
  const q = await call<Quote>('GET', `/quote?channel_id=${channelId}&count=${count}`);
  if (!q.enough) throw new Error(`Not enough credits (${q.credits_available} available)`);
  const day = new Date().toISOString().slice(0, 10);
  const g = await call<{ queued: { job_id: string }[]; stopped_because: string | null }>('POST', '/episodes/generate', {
    channel_id: channelId, count, max_credits: q.max_credits_to_pass, idempotency_key: `nightly-${channelId}-${day}`,
  });
  for (const { job_id } of g.queued) {
    let job: Job;
    do { await sleep(60_000); job = await call<Job>('GET', `/jobs/${job_id}`); } while (job.state === 'queued' || job.state === 'running');
    console.log(job_id, job.state, job.result?.episodes ?? `${job.error_kind}: ${job.error}`);
  }
}

Run it twice on the same day and the idempotency key returns the same jobs: nothing is generated or held twice.

The flow in Python#

The same with the requests package:

python
import os, time
import requests

API = os.environ.get("FORGE_API", "https://forge.storeswiki.com/api/v1")
HEADERS = {"Authorization": f"Bearer {os.environ['FORGE_KEY']}"}

def call(method, path, **kw):
    while True:
        r = requests.request(method, API + path, headers=HEADERS, timeout=30, **kw)
        if r.status_code == 429:
            time.sleep(int(r.headers.get("Retry-After", "5")))
            continue
        data = r.json() if r.content else {}
        if r.status_code >= 400:
            raise RuntimeError(f"{r.status_code} {data['error']['code']}: {data['error']['message']}")
        return data

project = call("POST", "/projects", json={
    "name": "How things stand up",
    "brief": "Short, original explainers of how bridges, towers and dams carry their loads, for curious adults.",
})
job = call("GET", f"/jobs/{project['job_id']}")
while job["state"] in ("queued", "running"):
    time.sleep(15)
    job = call("GET", f"/jobs/{project['job_id']}")
print("shortlist:", job["result"]["shortlist"])

# … draft the channel from a niche in the app, then:
channel = next(c for c in call("GET", "/channels")["data"] if c["project_id"] == project["id"])
quote = call("GET", "/quote", params={"channel_id": channel["id"], "count": 1})
queued = call("POST", "/episodes/generate", json={
    "channel_id": channel["id"], "count": 1, "max_credits": quote["max_credits_to_pass"],
    "idempotency_key": f"first-{channel['id']}",
})
print("queued:", [q["job_id"] for q in queued["queued"]], "held:", queued["credits_held"])

When something goes wrong#

You getIt meansWhat to do
402 creditsthe job needs more credits than are availableadd credits or change plan in Settings → Billing
409 quotemax_credits is below what the request would holdask GET /quote again and pass its max_credits_to_pass
409 capa monthly spending cap of the workspace or the channel would be passederror.cap says which; raise the cap, or wait for next month
409 statee.g. the channel has no kit yet, or the episode is not readybuild the kit; look at the episode's state
409 limitthe plan's limit on jobs in the queue at oncewait for some to finish
403 scope / 403 planthe key lacks the scope; the plan lacks the feature (premium quality, the API)a key with the scope; a larger plan
400 youtubea YouTube post asked of the APIlet the schedule post it, or post from the episode's page
job failed, error_kind qualitythe critic held the episode after its fix roundswatch it in the app, then release it or let it go — it was not charged
job failed, error_kind usersomething to fix on your side, e.g. "connect linkedin for Load Path first (Settings → Connections)"fix it and start the job again
429 rate_limitedmore than 120 requests a minute for this keywait the Retry-After seconds

Failures on ChannelForge's side are retried by themselves and, if they still fail, not charged.

Something missing or unclear? Tell us and we will improve this page.