The CharGen API lets your product create the same content CharGen users make on char-gen.com: images, video, audio, 3D models, and structured D&D entities such as NPCs, settlements, dungeons, and full characters. It is a small REST API. One endpoint creates a generation, one polls it, and a handful more cover your account, the model catalog, and billing.
The base URL for every call is:
https://api.char-gen.comRequests and responses are JSON. Errors use application/problem+json (RFC 9457).
Authentication#
Every endpoint except the public pricing list requires an API key in the Authorization header:
Authorization: Bearer live_<key id>_<secret>Keys are minted in the developer portal. The full key is shown exactly once, when you mint it. After that, the portal only shows the key id and the last 4 characters, so copy the key into your secret manager at mint time.
A few rules for handling keys:
- Keys start with
live_ortest_. The prefix is a label for your own bookkeeping. Both kinds authenticate against the same API and bill the same wallet. - Keys are secrets. Keep them server-side. Never ship them in client code, mobile apps, or public repositories.
- Each key carries scopes:
generate:image,generate:video,generate:audio,generate:model3d,generate:entity,read, andbilling. A request without the required scope returns403with an empty body. - If a key leaks, revoke it in the portal and mint a new one. Revocation takes effect immediately.
A missing or invalid key returns 401 with an empty body.
Your first generation#
Renders (images, video, audio, 3D models) go to POST /partner/v1/generations; structured entities (NPCs, dungeons, pantheons and 15 more) go to POST /partner/v1/entities. Both take the same body shape, two fields: type and input. The Idempotency-Key header is required on both (more on it below).
export CHARGEN_API_KEY="live_..."
curl -s -X POST "https://api.char-gen.com/partner/v1/generations" \
-H "Authorization: Bearer $CHARGEN_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"type": "image",
"input": {
"model": "GPT Image 2",
"prompt": "a dwarf battle cook in a cluttered tavern kitchen, oil painting",
"size": "1024x1024"
}
}'The response is 202 Accepted with a Location header pointing at the status endpoint:
{ "id": "0b7a9f6e-3c2d-4e8a-9c1f-2d5b8a7e4f01", "status": "queued", "priceUsd": 0.12 }priceUsd is the amount this generation drew from your wallet. The value above is an example; the pricing table is the live source.
Then poll:
curl -s "https://api.char-gen.com/partner/v1/generations/0b7a9f6e-3c2d-4e8a-9c1f-2d5b8a7e4f01" \
-H "Authorization: Bearer $CHARGEN_API_KEY"{
"id": "0b7a9f6e-3c2d-4e8a-9c1f-2d5b8a7e4f01",
"status": "succeeded",
"assets": [
{
"url": "https://...",
"webpUrl": "https://...",
"thumbnailUrl": "https://...",
"videoUrl": null
}
]
}Idempotency#
The Idempotency-Key header is required on every generation POST, to /generations and /entities alike. Any string from 1 to 200 characters works; a UUID per logical generation is the easy choice.
The key is claimed BEFORE we dispatch or bill anything, which gives you these guarantees:
- Two concurrent requests with the same key can never double-bill. The second one returns
409(empty body) while the first is still in flight. - A retry with the same key within 24 hours returns the ORIGINAL generation (
202with the original id and price) instead of creating and billing a new one. Retrying a timed-out HTTP call with the same key is always safe. - If a request fails before work starts (for example
402insufficient funds), the key is released. You can retry with the same key after topping up. - After 24 hours a key is purged and may be reused for a fresh generation.
If a call fails with a 5xx, retry with the SAME key. You will either replay the original result or dispatch cleanly, never pay twice.
Lifecycle and statuses#
A generation moves through four public statuses, always lowercase:
| Status | Meaning |
|---|---|
queued | Accepted, waiting for a worker. |
processing | A worker is generating. |
succeeded | Done. Assets and/or result are ready. |
failed | Terminal failure. The charge is refunded to your wallet automatically. |
Details worth knowing:
- The create response always says
queued. - Renders (image, video, audio, model3d) report
processingfrom the first poll onward, thensucceededorfailed. The output appears inassets. - Entity generations (npc, settlement, and friends) report
queueduntil a worker picks them up. On success, the structured entity is inresult(a JSON object) and the automatically rendered portrait appears inassetsonce it finishes.assetscan be empty while the portrait is still rendering, so poll until it shows up if you want the image. resultis omitted from the JSON entirely for renders.
Poll every few seconds. Images usually finish in well under a minute; video can take several minutes; entities typically take under a minute for the text plus the portrait render time.
What you can generate#
Renders#
type is one of image, video, audio, model3d. The input object takes:
| Field | Applies to | Notes |
|---|---|---|
model | all | Required. Exact name from the catalog (see Models below). |
prompt | all | The generation prompt. |
referenceImageUrl | image, video, model3d | URL of a reference image. Required for models flagged "needs reference image". |
referenceVideoUrl | audio | URL of a reference video (for models that score to picture). |
size | image | Shortcut for requestFields.Size, e.g. "1024x1024". |
numImages | image | Shortcut for requestFields["Number of Images"]. |
requestFields | all | Per-model options, listed below. |
requestFields accepts ONLY these keys; anything else is silently dropped:
| Key | Validation | Typical use |
|---|---|---|
Size | <width>x<height>, e.g. 1024x1024 | Image dimensions |
Number of Images | integer 1-10 | Image batch size |
Quality | free text | Model-specific quality tier |
Premium Quality | Low, Medium, or High | GPT Image and Ideogram tiers |
Duration | number 1-600 | Audio length in seconds |
Video Duration | integer 1-60 | Video length in seconds |
Resolution | free text | Video resolution |
Aspect Ratio | free text | Output aspect ratio |
Generate Audio | true / false | Veo soundtrack toggle |
All values are strings, including numbers and booleans: "requestFields": {"Video Duration": "5", "Generate Audio": "true"}.
Entities#
Entities use their own endpoint, POST /partner/v1/entities. type is one of the 18 entity types:
npc, character, monster, faction, magicitem, hazard, settlement, region, tavern,
shop, building, dungeon, quest, pantheon, species, spellbook, poetry, puzzleEvery type accepts prompt, a freeform seed, plus typed fields specific to that generator. A monster takes crLevel, monsterType, role, difficulty and more; a character takes className, level, background; a dungeon takes numberOfLevels, trapDensity and a reproducible seed. The API reference lists every field per type. All fields are optional, and anything you leave out gets a sensible default.
curl -s -X POST "https://api.char-gen.com/partner/v1/entities" \
-H "Authorization: Bearer $CHARGEN_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"type": "monster",
"input": {
"prompt": "a swamp-dwelling horror that mimics drowned voices",
"monsterType": "Undead",
"crLevel": "9",
"partyLevel": "5",
"difficulty": "Deadly"
}
}'Polling is the same for both families: the returned id resolves at GET /partner/v1/generations/{id}.
Each entity generation returns the structured entity JSON in result and automatically renders a portrait image, which lands in assets. The portrait is billed separately as an image render (see Pricing).
Models#
The model catalog is the contract: model must match a catalog name EXACTLY, including spaces and capitalisation. An unknown name returns 400 before anything is billed, with a pointer to the list.
Two ways to read the catalog:
GET /partner/v1/models(requires thereadscope) returns every model with the price YOUR organisation pays, computed from your pinned price version.- The public pricing table on this page shows the current standard prices without authentication.
Each catalog row carries:
priceUsd: the price for a representative base configuration (image: 1024x1024, one image; video: default clip; per-1000-character pricing for some TTS models).pricingNote: present when request options change the price (longer durations, premium quality, generated audio, larger sizes). If a model has a note, read it before assuming the base price.requiresReferenceImage: whentrue, the model only works image-to-image and a request withoutreferenceImageUrlis rejected with400.
Four video models require an explicit duration. Calls without requestFields["Video Duration"] return 400 for: Kling 2.6 Pro, Kling O1, Kling O1 Standard, and Grok Imagine Video.
Pricing#
Your organisation has a prepaid wallet, held in US dollars. Every generation draws from it at dispatch time; the create response tells you what it drew.
- Renders are priced per model. The exact figure comes from your price version (below) and the model's cost; the pricing table on this page lists every model.
- Entities charge a flat per-type fee. Most types use the default fee; heavier multi-part generators (character, spellbook, pantheon, dungeon) cost slightly more. The live fees are in the table below.
- Entity portraits: the automatic image render attached to an entity generation is billed separately, as a normal image render at your image rate.
- Failures refund automatically. If a generation fails on our side, the amount drawn for it is credited back to your wallet. You never pay for failed work.
Price versions and grandfathering#
Prices are published as dated price versions. Your organisation is pinned to a version when you sign up, and you stay on it (you are grandfathered) until you opt in to a newer one or we migrate you with at least 30 days' email notice. Upstream cost changes never silently reprice your existing integration.
Funding the wallet#
Top up through Stripe Checkout, either in the portal billing tab or via the API:
curl -s -X POST "https://api.char-gen.com/partner/v1/billing/checkout-session" \
-H "Authorization: Bearer $CHARGEN_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "amountUsdCents": 5000 }'{ "checkoutUrl": "https://checkout.stripe.com/c/pay/...", "sessionId": "cs_..." }Open checkoutUrl in a browser to pay. Top-ups are between $10.00 (1000 cents) and $100,000.00 (10000000 cents). The wallet is credited when the payment clears, usually within a moment of checkout completing. Where we are required to collect tax, Stripe Tax adds it at checkout; listed prices exclude taxes.
When the wallet cannot cover a generation, the API returns 402 (see Errors) and nothing is dispatched or billed.
API credits are not Gold#
CharGen consumer accounts use Gold. The API does not. API usage draws from your organisation's prepaid USD wallet, which is entirely separate from any Gold balance on a personal CharGen account, including subscriptions. Gold cannot be spent through the API and API credits cannot be spent on char-gen.com.
Rate limits#
Each API key may make up to 120 requests per minute. The bucket refills continuously, so short bursts above 2 requests/second are fine until the bucket drains. Over the limit, the API returns 429 with a Retry-After: 60 header:
{
"type": "https://docs.chargen.example/errors/rate-limited",
"title": "Too Many Requests",
"status": 429,
"detail": "Per-key rate limit exceeded. Retry after 60 seconds.",
"code": "rate_limited"
}Back off and retry after the indicated delay. The limit is per key, so separate keys for separate workloads also separate their budgets.
Errors#
Errors are application/problem+json objects with a machine-readable code property. Example, a wallet that cannot cover the price:
{
"type": "https://docs.chargen.example/errors/insufficient-funds",
"title": "Payment Required",
"status": 402,
"detail": "Insufficient partner balance",
"code": "insufficient_funds",
"balanceUsd": 0.52,
"requiredUsd": 1.08
}The full set:
| Status | code | When |
|---|---|---|
| 400 | bad_request | Invalid input: unknown model, missing required field, out-of-range value. The detail says what to fix. |
| 401 | (empty body) | Missing, malformed, revoked, or expired API key. |
| 402 | insufficient_funds | Wallet balance below the price. Includes balanceUsd and requiredUsd. Top up and retry with the same Idempotency-Key. |
| 403 | (empty body) | The key lacks the required scope. |
| 404 | not_found | Unknown generation id, or a generation that belongs to a different organisation. |
| 409 | (empty body) | A request with the same Idempotency-Key is still in flight. Wait and poll, or retry shortly to replay its result. |
| 429 | rate_limited | Over 120 requests/min on this key. Honour Retry-After. |
| 500 | server_error, wallet_missing | Our fault. Safe to retry with the SAME Idempotency-Key. |
End-to-end examples#
Generate, poll until done, and surface a top-up link when funds run out.
JavaScript (fetch)#
const BASE = "https://api.char-gen.com";
const KEY = process.env.CHARGEN_API_KEY;
async function createGeneration() {
const res = await fetch(`${BASE}/partner/v1/generations`, {
method: "POST",
headers: {
Authorization: `Bearer ${KEY}`,
"Idempotency-Key": crypto.randomUUID(),
"Content-Type": "application/json",
},
body: JSON.stringify({
type: "image",
input: {
model: "GPT Image 2",
prompt: "a dwarf battle cook in a cluttered tavern kitchen, oil painting",
size: "1024x1024",
},
}),
});
if (res.status === 402) {
const problem = await res.json();
const topUpUrl = await createTopUp(2000);
throw new Error(
`Balance $${problem.balanceUsd} is below $${problem.requiredUsd}. Top up: ${topUpUrl}`
);
}
if (!res.ok) throw new Error(`create failed: HTTP ${res.status}`);
const created = await res.json();
return created.id;
}
async function waitForGeneration(id, timeoutMs = 300_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const res = await fetch(`${BASE}/partner/v1/generations/${id}`, {
headers: { Authorization: `Bearer ${KEY}` },
});
if (!res.ok) throw new Error(`poll failed: HTTP ${res.status}`);
const body = await res.json();
if (body.status === "succeeded" || body.status === "failed") return body;
await new Promise((r) => setTimeout(r, 3000));
}
throw new Error(`generation ${id} did not finish in ${timeoutMs}ms`);
}
async function createTopUp(amountUsdCents) {
const res = await fetch(`${BASE}/partner/v1/billing/checkout-session`, {
method: "POST",
headers: {
Authorization: `Bearer ${KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ amountUsdCents }),
});
if (!res.ok) throw new Error(`top-up failed: HTTP ${res.status}`);
const body = await res.json();
return body.checkoutUrl;
}
const id = await createGeneration();
const done = await waitForGeneration(id);
console.log(done.status, done.assets);Python (requests)#
import os
import time
import uuid
import requests
BASE = "https://api.char-gen.com"
KEY = os.environ["CHARGEN_API_KEY"]
AUTH = {"Authorization": f"Bearer {KEY}"}
def create_generation():
resp = requests.post(
f"{BASE}/partner/v1/generations",
headers={**AUTH, "Idempotency-Key": str(uuid.uuid4())},
json={
"type": "image",
"input": {
"model": "GPT Image 2",
"prompt": "a dwarf battle cook in a cluttered tavern kitchen, oil painting",
"size": "1024x1024",
},
},
timeout=30,
)
if resp.status_code == 402:
problem = resp.json()
top_up_url = create_top_up(2000)
raise RuntimeError(
f"Balance ${problem['balanceUsd']} is below ${problem['requiredUsd']}. "
f"Top up: {top_up_url}"
)
resp.raise_for_status()
return resp.json()["id"]
def wait_for_generation(generation_id, timeout_seconds=300):
deadline = time.time() + timeout_seconds
while time.time() < deadline:
resp = requests.get(
f"{BASE}/partner/v1/generations/{generation_id}", headers=AUTH, timeout=30
)
resp.raise_for_status()
body = resp.json()
if body["status"] in ("succeeded", "failed"):
return body
time.sleep(3)
raise TimeoutError(f"generation {generation_id} did not finish in {timeout_seconds}s")
def create_top_up(amount_usd_cents):
resp = requests.post(
f"{BASE}/partner/v1/billing/checkout-session",
headers=AUTH,
json={"amountUsdCents": amount_usd_cents},
timeout=30,
)
resp.raise_for_status()
return resp.json()["checkoutUrl"]
generation_id = create_generation()
result = wait_for_generation(generation_id)
print(result["status"], result.get("assets"))Terms#
Use of the API is governed by the CharGen API Terms of Service (v1.1). Highlights: the wallet is prepaid, failed generations are auto-refunded, you may embed Output in your own product but not re-expose the raw API, and price versions are grandfathered with 30 days' notice before any migration.