This is the public reference for clients integrating against Abni’s image-generation API. Internal-only endpoints aren’t covered here.
Get started fast: n8n starter workflow (import it into n8n, or paste its link into n8n’s Import from URL: it lists your folders, makes an image and waits until it’s ready) · OpenAPI file (YAML) — import into Postman, Insomnia, Make or an AI agent.
Conventions #
Auth #
Every request needs x-api-key. The key is bearer-equivalent — it must live server-side. Don’t expose it in browser code, mobile apps, or public repositories.
Getting a key: workspace admins create keys in Settings › API & automation (paid workspaces: a subscription or any credit pack). Each key is shown once, when it’s made; give it a name like “n8n”. To rotate, create a new key and revoke the old one. The same section sets up your webhook and has a test button.
What a key can do: each key has one of three levels, chosen when it’s made (new keys default to Create). A call above the key’s level is refused with 403 INSUFFICIENT_SCOPE and details: { required, keyScope } before anything runs. Keys made before levels existed are Full access.
| Level | Can call |
|---|---|
| Read only | Every GET: folders, library, images, jobs, credits, pricing, usage. Changes nothing. |
| Create | Read, plus making things: images (create, brandify, revise, resize, improve, retry, recreate, analyse), text (generate, proofread), library adds and tags, new folders, extraction jobs and colour sampling. Not deletes, brand rules, style or voice edits, golden references or trial invites. |
| Full access | Everything a key can reach, including deletes, purge and brand settings. |
Hierarchy #
Level 1 folder → Level 2 folder → Level 3 folder → Level 4, 5 and 6 folders. Most-specific level wins for context resolution (style, world, golden refs, brand rules). All endpoints accept optional level2 and level3 slugs alongside level1; the older names brand, subBrand and campaign keep working. Omitting them targets the parent level.
Deeper folders (Level 4–6). Name a folder at any depth with level1…level6, or with one path of slugs separated by / (?path=studio-north/linkedin/voices/oliver; in a JSON body send folderPath, a string or an array). Folders go 6 levels deep at most (400 FOLDER_TOO_DEEP). Endpoints that don’t take Level 4+ yet answer 400 FOLDER_DEPTH_UNSUPPORTED — they never quietly use the Level 3 folder instead. Level 4+ responses add path; Level 1–3 responses are unchanged.
Async results #
Image generation returns 202 Accepted immediately with { id, jobId, status: "processing" }. Two ways to receive the final result:
- Polling:
GET /v1/images/{id}untilstatusiscompletedorfailed. Recommended cadence: 3–5s for the first 60s, back off to 10s. Most images deliver in 20–45s. - Webhooks (recommended for chat bots and async UIs): we POST to a URL you configure at provisioning time when the result is ready. See Webhooks.
Both delivery modes are always active when configured — you can poll AND receive webhooks.
clientReferenceId round-trip #
Every endpoint that creates work accepts an optional clientReferenceId: string (max 200 chars). We echo it back on GET /v1/images/{id} and in webhook payloads, so you can correlate to your own database without a second lookup.
Errors #
Every error response — across every endpoint — uses this shape:
{
"error": {
"code": "INSUFFICIENT_CREDITS",
"message": "Your client balance (4 credits) is below the required 12 for this operation.",
"details": { "balance": 4, "required": 12 },
"requestId": "abni-req-xyz123"
}
}
Branch on code for behaviour. Include requestId in support tickets — we trace it to the exact CloudWatch entry in seconds.
| HTTP | code | When |
|---|---|---|
| 400 | MISSING_BODY | Request body required but absent |
| 400 | VALIDATION_ERROR | Body failed schema validation; details lists per-field failures |
| 400 | UNSUPPORTED_BRAND | brand slug isn’t yours |
| 400 | UNSUPPORTED_HIERARCHY | subBrand doesn’t belong to that brand, or campaign doesn’t belong to that sub-brand |
| 400 | INVALID_SOURCE_URL | sourceUrl for brandify isn’t reachable, isn’t HTTPS, or content isn’t an image |
| 401 | UNAUTHENTICATED | No x-api-key was sent |
| 403 | INVALID_API_KEY | Key not recognised or revoked |
| 403 | INSUFFICIENT_SCOPE | The key’s level is below what this call needs; details: { required, keyScope } |
| 403 | CLIENT_PAUSED | The workspace is paused; the message says why |
| 403 | FORBIDDEN | Authorized but the operation isn’t allowed for this client |
| 404 | NOT_FOUND | imageId or jobId doesn’t exist for this client |
| 404 | FOLDER_NOT_FOUND | A folder (or one of its parents) doesn’t exist in your workspace. details: { level, level1, level2?, level3? }, where level (1, 2 or 3) is the missing folder’s level. Replaces BRAND_NOT_FOUND, SUB_BRAND_NOT_FOUND and CAMPAIGN_NOT_FOUND; for now the old code is also in details.legacyCode |
| 400 | FOLDER_ALIAS_CONFLICT | A folder name was sent under both its new name (level1…) and its old name (brand…) with different values |
| 402 | INSUFFICIENT_CREDITS | Balance can’t cover the operation; top up |
| 409 | ALREADY_PROCESSING | A pipeline is already in flight for that image; wait |
| 413 | PAYLOAD_TOO_LARGE | Upload exceeds 20MB |
| 415 | UNSUPPORTED_MEDIA_TYPE | Brandify upload isn’t an image |
| 429 | RATE_LIMITED | Future — when we add request-rate caps |
| 500 | INTERNAL_ERROR | Unexpected server error; include requestId in your support ticket |
| 502 | UPSTREAM_ERROR | Image-provider error we couldn’t recover from |
| 503 | SERVICE_BUSY | Temporary overload; retry with backoff |
| 504 | UPSTREAM_TIMEOUT | Image provider timed out |
Endpoints #
#
GET/v1/brands/{brandSlug}/sub-brands?withHero=true
Single round-trip that returns the brand’s sub-brands and campaigns plus their hero images. Designed to feed a UI selector.
curl https://api.abni.ai/v1/brands/cor2ed/sub-brands?withHero=true \
-H "x-api-key: $ABNI_KEY"
Response (200)
{
"brand": { "slug": "cor2ed", "name": "Cor2ED" },
"subBrands": [
{
"slug": "expert-how-to-video",
"name": "Expert How To Video",
"hero": {
"url": "https://cdn.abni.ai/...",
"width": 2048,
"height": 2048,
"imageId": "lib_..."
},
"campaigns": [
{
"slug": "accepted-style",
"name": "Accepted Style",
"hero": { "url": "...", "width": ..., "height": ..., "imageId": "..." }
}
]
}
]
}
hero bubbles up: a campaign without its own hero inherits its sub-brand’s; a sub-brand without one inherits the brand’s. Returns null only when no level in the chain has a hero tagged. Omit ?withHero=true to skip the lookups (faster, but hero will not be populated).
Errors: 404 NOT_FOUND if the brand isn’t yours.
#
POST/v1/images — generate
curl https://api.abni.ai/v1/images \
-X POST \
-H "x-api-key: $ABNI_KEY" \
-H "content-type: application/json" \
-d '{
"brand": "cor2ed",
"subBrand": "expert-how-to-video",
"campaign": "accepted-style",
"prompt": "a doctor holding a brochure on a clinical background",
"aspectRatio": "1:1",
"resolution": "2K",
"outputFormat": "png",
"clientReferenceId": "your-internal-id-123"
}'
Field reference
| Field | Type | Default | Notes |
|---|---|---|---|
brand | string (slug) | required | |
subBrand | string (slug) | optional | Must belong to brand |
campaign | string (slug) | optional | Must belong to subBrand |
prompt | string | required | Max 4000 chars |
aspectRatio | enum | "1:1" | 1:1 4:5 9:16 16:9 2:3 5:4 |
resolution | enum | "2K" | 1K 2K 4K |
outputFormat | enum | "png" | png jpg |
clientReferenceId | string | optional | Echoed back in GET and webhook payloads |
Response (202)
{ "id": "img_a1b2c3d4...", "jobId": "job_...", "status": "processing" }
Then poll GET /v1/images/{id} or wait for the webhook.
Errors: UNSUPPORTED_HIERARCHY, INSUFFICIENT_CREDITS, VALIDATION_ERROR.
#
GET/v1/images/{imageId}
Every /v1/images/{imageId} call (this one, revise and resize) needs ?brand=, the image's folder.
curl "https://api.abni.ai/v1/images/img_a1b2c3d4?brand=studio-north" \
-H "x-api-key: $ABNI_KEY"
Response (200)
{
"id": "img_a1b2c3d4...",
"status": "completed",
"imageUrl": "https://cdn.abni.ai/...",
"currentVersion": 1,
"brand": "cor2ed",
"subBrand": "expert-how-to-video",
"campaign": "accepted-style",
"clientReferenceId": "your-internal-id-123",
"versions": [
{
"version": 1,
"url": "https://cdn.abni.ai/...",
"createdAt": "2026-05-05T10:30:00.000Z",
"prompt": "a doctor holding a brochure on a clinical background",
"aspectRatio": "1:1",
"resolution": "2K",
"outputFormat": "png",
"score": 84
}
],
"error": null
}
status is processing (poll again), completed (imageUrl is the current version; each entry in versions has its own url), or failed (error populated). Image links are signed and expire after an hour: download the image, or call this endpoint again for a fresh link, rather than storing the link.
#
POST/v1/images/{imageId}/revise — edit
Asks for a content-level change to an existing image. Produces a new version of the same imageId.
curl "https://api.abni.ai/v1/images/img_a1b2c3d4/revise?brand=studio-north" \
-X POST \
-H "x-api-key: $ABNI_KEY" \
-H "content-type: application/json" \
-d '{
"instructions": "make the brochure red instead of blue",
"clientReferenceId": "your-internal-id-123"
}'
| Field | Type | Default |
|---|---|---|
instructions | string | required, max 4000 |
fromVersion | number | optional; the version to edit. Defaults to the newest finished version |
clientReferenceId | string | optional |
Response: 202 with { id, version, basedOn, status: "processing" }. The new version is added as version: N+1 of the same image, and basedOn is the version it was edited from. Poll GET /v1/images/{id} to see it appear in versions.
Redo from an earlier version. If an edit came out wrong, send the same (or a new) instruction with fromVersion set to the version before it. Abni edits that version, not the bad one, and the bad one stays in the history. For example, an image with v1 (upload), v2 (Brandify) and v3 (an edit you don't like): {"instructions": "…", "fromVersion": 2} makes v4 from v2. fromVersion must be a finished version of this image; a version that doesn't exist, failed or is still running returns 400 VALIDATION_ERROR. The price is the same as any edit.
Uploaded images can be edited too. Pass the library imageId you got back from POST /v1/library (or any library image id) as {imageId}. Your upload stays untouched as version: 1 and the edit becomes version: 2 of the same image; poll GET /v1/images/{id} with the id from the 202 response. The price is the same as editing a generated image. Resize works the same way.
#
POST/v1/images/{imageId}/set-current-version — go back to a version
Makes an earlier (or later) finished version the current one. Nothing is deleted: every version stays in versions. The current version is what the library tile shows and what is used when the image is a named asset. Free.
curl "https://api.abni.ai/v1/images/img_a1b2c3d4/set-current-version" \
-X POST \
-H "x-api-key: $ABNI_KEY" \
-H "content-type: application/json" \
-d '{ "brand": "studio-north", "version": 2 }'
| Field | Type | Default |
|---|---|---|
brand | string | required, the image's folder |
version | number | required; must be a finished version |
Response (200): { ok, currentVersion, totalVersions, url, title, description, tags, systemTags }. url is the new current picture. A version that doesn't exist or isn't finished returns 400 VALIDATION_ERROR.
#
POST/v1/images/{imageId}/resize
Surgical — preserves the image content, changes only frame and resolution. Brand context, refs, and rules are NOT re-applied.
curl "https://api.abni.ai/v1/images/img_a1b2c3d4/resize?brand=studio-north" \
-X POST \
-H "x-api-key: $ABNI_KEY" \
-H "content-type: application/json" \
-d '{
"aspectRatio": "9:16",
"resolution": "2K",
"outputFormat": "png"
}'
| Field | Type | Default |
|---|---|---|
aspectRatio | enum | required |
resolution | enum | required |
outputFormat | enum | "png" |
fromVersion | number | optional; the version to resize. Defaults to currentVersion (version is the old name and still works) |
clientReferenceId | string | optional |
Response: 202 with { id, jobId, version, basedOn, status: "processing" }. New version of the same imageId; basedOn is the version it was resized from. fromVersion must be a finished version of this image; one that doesn’t exist, failed or is still running returns 400 VALIDATION_ERROR and costs nothing. Works on uploaded library images the same way as revise (the upload is kept as version 1).
#
POST/v1/images/analyse
Look at one finished image and learn what it is: format (social post, packaging, event activation, poster…), subject, setting, the client's industry, audience, medium and a short style note. You also get briefs for new images in the same project family, the next pieces that client would commission: same format and world, a different subject, never a re-take of the image.
curl https://api.abni.ai/v1/images/analyse \
-X POST \
-H "x-api-key: $ABNI_KEY" \
-H "content-type: application/json" \
-d '{ "imageUrl": "https://example.com/work/launch-event.jpg", "context": "Made by a social-first agency", "briefs": 4 }'
Pass imageUrl (a public https image, looked at once and never stored) or imageId + brand (a library image). context (optional, up to 1500 characters) helps the reading; briefs is 1–8 (default 5). Response: 200 { format, subject, setting, industry, audience, medium, style, briefs: [{ title, brief, why }] }. One image_analyse operation is charged (see GET /v1/pricing), refunded if the analysis fails; a link that can't be read costs nothing. Takes about 10–15 seconds.
#
POST/v1/images/brandify
Take an external image and re-style it to match a brand. Two input modes; pick one.
Mode A — JSON with source URL
curl https://api.abni.ai/v1/images/brandify \
-X POST \
-H "x-api-key: $ABNI_KEY" \
-H "content-type: application/json" \
-d '{
"brand": "cor2ed",
"subBrand": "expert-how-to-video",
"sourceUrl": "https://your-cdn.example.com/source.jpg",
"instructions": "make this look like a Cor2ED clinical photo",
"aspectRatio": "1:1",
"resolution": "2K",
"outputFormat": "png",
"clientReferenceId": "your-internal-id-123"
}'
sourceUrl constraints: HTTPS, reachable from AWS eu-north-1, max 20MB, image content-type.
Mode B — multipart upload
curl https://api.abni.ai/v1/images/brandify \
-X POST \
-H "x-api-key: $ABNI_KEY" \
-F "brand=cor2ed" \
-F "subBrand=expert-how-to-video" \
-F "instructions=make this look like a Cor2ED clinical photo" \
-F "aspectRatio=1:1" \
-F "resolution=2K" \
-F "clientReferenceId=your-internal-id-123" \
-F "source=@./photo.jpg;type=image/jpeg"
Form fields mirror the JSON body. The file must be in a source field, max 20MB.
Response (both modes): 202 { id, jobId, status: "processing" }. The original is preserved as version 1; the brandified version writes as version 2 of the same imageId.
Errors: INVALID_SOURCE_URL, PAYLOAD_TOO_LARGE, UNSUPPORTED_MEDIA_TYPE, UNSUPPORTED_HIERARCHY, INSUFFICIENT_CREDITS.
#
POST/v1/folders
Create the folders your work lives in. Folders nest up to six deep: a Level 1 folder holds Level 2 folders, a Level 2 folder holds Level 3 folders, and so on down to Level 6. Each level inherits the style, rules and references of the folders above it, and the most specific folder wins. Every folder belongs to the workspace your key belongs to. If you omit slug it’s derived from name; a clash gets a -2, -3… suffix rather than an error.
curl https://api.abni.ai/v1/folders \
-X POST \
-H "x-api-key: $ABNI_KEY" \
-H "content-type: application/json" \
-d '{ "name": "Summer", "level1": "studio-north", "level2": "stills" }'
| Method | Path | Body | Response |
|---|---|---|---|
GET | /v1/folders | — | 200 { level1Folders: [{ level: 1, slug, name, … }] } |
GET | /v1/folders/{level1}/tree | — | 200 { level1Folder, level2Folders: [{ level: 2, …, level3Folders: […] }] } |
POST | /v1/folders | { name, slug?, level1?, level2?, … } or { name, folderPath } (the parent) | 201 { folder: { level, slug, name, level1, level2?, level3?, createdAt } }. No parent makes a Level 1 folder; level1 makes a Level 2 folder inside it; level1 + level2 makes a Level 3 folder |
PATCH | /v1/folders/{level1} (add ?level2=…?level6= or ?path= for a deeper folder) | { name } | 200 { folder }; the slug never changes |
DELETE | /v1/folders/{level1} (?level2=…?level6= or ?path=) | — | 200 { folder, deleted: { slug, name } } |
GET | /v1/folders/{level1}/children (?level2=… or ?path=) | — | 200 { folder, children: [{ slug, name, level, path?, childFolderCount }], maxDepth: 6 } — one folder’s child folders, for loading a deep tree a branch at a time |
POST | /v1/folders/{level1}/duplicate (?level2=… or ?path=) | — | 201 { folder, imagesCopied, itemsSkipped, subFoldersCopied, errors } — copies the folder and everything in it as “{name} (copy)”. Free; needs a Full access key |
Folder names in every call. level1, level2 and level3 name a folder anywhere the API asks for one: in the path, the query string or a JSON body. Every Level 1 folder resource also answers under /v1/folders/{level1}/… (style, golden references, extract jobs, rules and so on). The older names keep working everywhere: /v1/brands routes, and brand / subBrand / campaign (or brandSlug / subBrandSlug / campaignSlug) for the three levels. Responses carry both the new keys and the old ones for now; the old response keys will be dropped after a notice period.
Deletes are soft: the folder is archived and cleaned up later. A folder with live folders inside it returns 409 HAS_CHILDREN; a missing folder or parent returns 404 FOLDER_NOT_FOUND.
#
GET/v1/pricing
The credits each operation costs your workspace: your plan's rates with any overrides applied. Every charge is taken from this same table, so you can estimate a job before running it.
curl https://api.abni.ai/v1/pricing \
-H "x-api-key: $ABNI_KEY"
Response: 200 { prices: { create_2k, create_4k, brandify_2k, revise, resize, upload, image_analyse, extract, brand_intake, text_create, … }, updatedAt }, where each value is credits per call. Free; cached for 60 seconds.
#
GET/v1/credits/transactions
Your workspace's credit ledger, newest first. Add level1 (or the older brand) to see what one Level 1 folder actually cost, with totals; since (ISO) sets how far back (default 90 days); limit 1–200.
curl "https://api.abni.ai/v1/credits/transactions?brand=studio-north&since=2026-10-01" \
-H "x-api-key: $ABNI_KEY"
Response: 200 { transactions: [{ txnId, type, credits, description, createdAt, metadata }], totals?: { charged, refunded, net } }. Charges are negative, refunds positive; a refund counts for a folder when it refunds one of that folder's charges. Charges record their folder from 4 Oct 2026. Free.
#
POST/v1/library
Add an image to a folder's library. Pass a public https link and Abni fetches it for you; WebP, AVIF, GIF, HEIC and SVG are converted to PNG or JPEG on the way in.
curl https://api.abni.ai/v1/library \
-X POST \
-H "x-api-key: $ABNI_KEY" \
-H "content-type: application/json" \
-d '{ "brand": "studio-north", "imageUrl": "https://example.com/work/hero.webp" }'
Optional subBrand / campaign set the level. Response: 201 { image: { imageId, url, thumbnailUrl, … } }. One upload operation is charged. Private addresses are refused (400 INVALID_IMAGE_URL), non-images return 415, files over 50 MB return 413; nothing is charged when a link is refused.
#
GET/v1/library/{imageId}/download
A short-lived download link (5 minutes) for a library image; imageId can be the library id or the id from POST /v1/images. Add format=jpeg (white background) or format=webp (keeps transparency) for a copy, with maxSide (256–4096, or 0 for the original size; default 1600; never enlarged) and quality (1–100, default 82).
curl "https://api.abni.ai/v1/library/img-1791104896556-wbyfw3/download?brandSlug=studio-north&format=jpeg&maxSide=1600" \
-H "x-api-key: $ABNI_KEY"
Response: 200 { url, filename }. Free.
#
DELETE/v1/library/{imageId}?brand=…&purge=true
A normal delete moves an image to the trash for 30 days. Add purge=true to remove it for good: the image, every golden-reference copy of it in every style version, and every derived copy, with nothing kept in the trash or in backups. Use it for pictures your workspace must not keep, such as a third party's image you only used to learn a style. Needs a Full access key; works on trashed images; {imageId} can be the library id or the id from POST /v1/images.
curl "https://api.abni.ai/v1/library/img-1791104294797-wc9y26?brand=studio-north&purge=true" \
-X DELETE \
-H "x-api-key: $ABNI_KEY"
Response: 200 { purged: true, keys, versionsRemoved, refsDropped }, listing every stored file removed. Cannot be undone.
#
POST/v1/library/{imageId}/hero
Pick a folder's hero: the image that represents the folder in pickers and on folder cards. One hero per level, so setting a new one clears the old. {imageId} can be the library id or the image id returned by POST /v1/images. DELETE on the same path clears it.
curl https://api.abni.ai/v1/library/img-1791104896556-wbyfw3/hero \
-X POST \
-H "x-api-key: $ABNI_KEY" \
-H "content-type: application/json" \
-d '{ "brand": "studio-north", "level": "brand" }'
level is brand, sub-brand or campaign; pass subBrand / campaign for the deeper levels. Response: 200 { isHero: true, level }. Free. 404 if the image isn't in that brand.
#
GET/v1/brands/{brand}/golden-refs/{domain}
Golden references are the images a folder's style is learned from. {domain} is photos or designs; scope with ?subBrand= / &campaign=. Each level has its own set.
{ "currentVersion": 2, "refs": [ { "refId": "r1", "category": "general", "filename": "a.png", "thumbnailUrl": "https://cdn.abni.ai/…" } ] }
Add from the library: POST …/golden-refs/{domain}/from-library with { libraryImageIds: string[], category?, replace?: boolean }. Up to 5 per folder; the images must already be in the library at the same level; replace: true starts a fresh set. Remove: DELETE …/golden-refs/{domain}/{category}/{refId}.
#
POST/v1/brands/{brand}/extract-jobs
Learn a folder's style (photo style, colours, lighting, composition, visual world) from its golden references. Runs asynchronously; scope with ?subBrand= / &campaign=.
Body (all optional): { contentType?: "photo" | "illustration" | "document", websiteUrl?, autoApproveRules?, keepColours? }. contentType forces the medium. websiteUrl also reads that site and fills in the folder's Purpose (feel, essence, voice, compliance) in the same job. keepColours: true re-learns the style but keeps the folder's current colours (palette, colour tone, saturation, grade). autoApproveRules: true (Full access keys) adopts the image rules the extraction suggests as the folder's brand rules straight away, instead of leaving them as suggestions to review; rules already there are skipped. One extract operation is charged; with websiteUrl, one brand_intake operation as well (reading the site is a separate model call), refunded if the site can't be read or the extraction fails. Both prices are in GET /v1/pricing. Response: 202 { jobId }. Errors: 409 EXTRACTION_IN_PROGRESS, 423 LEVEL_LOCKED, 402 INSUFFICIENT_CREDITS.
Poll: GET /v1/brands/{brand}/extract-jobs/{jobId} → { jobId, status, progress: { step, message, percent? }, result?, error? }. status goes queued → running → completed or failed. A completed result holds photoStyle, visualWorld, suggestedRules and, with autoApproveRules, adoptedRules. Read the learned style with GET /v1/brands/{brand}.
#
POST/v1/text/generate
On-brand copy in a folder's extracted voice, writing rules and house dictionary.
curl https://api.abni.ai/v1/text/generate \
-X POST \
-H "x-api-key: $ABNI_KEY" \
-H "content-type: application/json" \
-d '{ "brand": "studio-north", "mode": "write", "brief": "A short welcome email for new clients", "format": "email, under 120 words" }'
| Field | Notes |
|---|---|
mode | write (brief → new copy), rebrand (rewrite draft in the voice), amend (only the change in instruction), resize (to target) |
brief, draft, instruction, target, format | as the mode needs |
Response: 200 { text, markdown, lintApplied, advisories, itemId, version, credits }. The copy is also saved to the folder's library.
#
POST/v1/text/proofread
Free. Checks any text against the folder's writing rules and house dictionary without rewriting it. Body: { text, brand, subBrand?, campaign? }. Response: 200 { corrected, markdown, applied[], advisories[], rulesChecked }.
#
POST/v1/designs
Makes a banner from your words, a library photo and the folder's fonts, logo and design colours. Abni draws the words exactly as you send them, keeps them readable on the photo (WCAG AA contrast; a backing panel when the photo is busy), keeps the logo's clear space and minimum size, then runs a design check that can nudge positions and sizes. Asynchronous: you get a jobId back straight away.
Body: { level1, level2?, level3?, size, template?, slots: { headline, subhead?, buttonText?, authorRole?, authorName?, imageId?, logo? } }. size: linkedin-cover (1920×1080), linkedin-feed (1200×627), square (1080×1080), portrait (1080×1350), email-header (1200×400). template: overlay, split, panel or all (default — one version of each). imageId is a library image in the folder or a folder above; logo is a logo name (default the folder's primary logo, "none" for no logo).
Master templates. template: "article-cover-panel" makes one fixed-layout article cover: a white panel bottom-left with a byline (authorRole, authorName) and the headline in up to four lines that shrink to fit, logo top-right. It needs slots.imageId and a linkedin-cover or linkedin-feed size.
Response: 202 { jobId, status: "queued", templates, credits }. Costs banner_create per version (a master template: banner_cover; see GET /v1/pricing); versions that fail are refunded automatically. When no crop keeps the photo's subject clear of the words, Abni extends the photo on the text side and charges banner_extend for that, only when it runs. Unknown image or logo: 404 IMAGE_NOT_FOUND / LOGO_NOT_FOUND, before anything is charged.
GET /v1/designs/{jobId} — poll every few seconds (a job usually takes 20–60 s): { status, progress, variants: [{ variantId, template, pngUrl, spec, scores, assessment, contrastFailures, warnings, savedImageId? }], dropped }. pngUrl lasts an hour; spec is the layout (DesignSpec).
POST /v1/designs/{jobId} with { variantId } saves that version to the folder's library: 201 { imageId, credits } (charged as upload; saving the same version again returns the same imageId free). Saved designs are listed with GET /v1/library?kind=design, each with a design field: template, size and the recipe it was made from (words, photo, logo, fonts).
New words on a saved design. Send the recipe's template, size, photo (slots.imageId) and logo with your new words to POST /v1/designs, then save with { variantId, intoImageId }. It becomes the next version of the same design: 201 { imageId, version, credits }, same imageId, and the new headline becomes its title. Each version keeps its own recipe, and POST /v1/images/{imageId}/set-current-version switches back. An intoImageId that isn't a saved design in the job's folder is 404 DESIGN_NOT_FOUND, before anything is charged.
#
POST/v1/trial-invites
For partner integrations enabled by Abni (the workspace needs the trialInvites permission; everyone else gets 403). Makes a personal sign-up link for one person, which you send them yourself. When they open it, they're signed in and a new studio is made with their trial credits ready to use, no card needed. If they already have an Abni account, the link just signs them in.
curl https://api.abni.ai/v1/trial-invites \
-X POST \
-H "x-api-key: $ABNI_KEY" \
-H "content-type: application/json" \
-d '{ "email": "sam@studio-north.co.uk", "companyName": "Studio North", "firstName": "Sam", "brand": "studio-north", "subBrands": ["sam-ward"] }'
brand (optional) is a folder in your workspace to give them: when they sign up, it is copied into their new studio (style, rules, reference images and every image in it). The copy is theirs. Unknown folder → 404 BRAND_NOT_FOUND.
subBrands (optional, with brand) chooses which of that folder’s sub-folders come along. Leave it out and every sub-folder is copied; [] copies the folder itself only; ["sam-ward"] copies the folder plus that one sub-folder. The folder’s own level (style, rules, references and its images) always comes. Use it when you keep one sub-folder per person, so each person’s studio holds the set made for them and not a colleague’s. Unknown sub-folder → 404 SUB_BRAND_NOT_FOUND; subBrands without brand → 400 VALIDATION_ERROR.
Response: 201 { url, expiresAt, inviteId }. The link only works for that email address, works once and expires after 30 days. Throwaway email domains are refused (400 BLOCKED_EMAIL_DOMAIN), and each workspace can make at most 100 a day (429 RATE_LIMITED). Free. Keep inviteId (or the url) in case you need to withdraw it.
#
POST/v1/trial-invites/revoke
Withdraws invites your workspace sent, for example when someone asks not to hear from you again. A withdrawn link stops working: opening it shows a short “This invitation has been withdrawn” page. No account or studio is made, no credits are given and no sign-in email is sent. Same trialInvites permission.
curl https://api.abni.ai/v1/trial-invites/revoke \
-X POST \
-H "x-api-key: $ABNI_KEY" \
-H "content-type: application/json" \
-d '{ "email": "sam@studio-north.co.uk", "reason": "opted out" }'
| Send exactly one of | Withdraws |
|---|---|
inviteId | that invite |
url | the invite with that link (works for every invite, including ones made before inviteId existed) |
email | every invite your workspace sent to that address (from 6 October 2026; use url for older ones) |
reason (optional, up to 200 characters) is kept with the invite. Response: 200 { invites: [{ inviteId, email, status, usedAt? }] }. status is revoked (withdrawn now), already-revoked, used (they had already signed up or signed in, at usedAt; their Abni account is their own, so it isn't changed) or expired. An email with no invites returns an empty list. An inviteId or url that isn't one of your workspace's invites returns 404 INVITE_NOT_FOUND. Free.
Webhooks #
When the pipeline completes (or fails), we POST to your configured webhookUrl:
POST https://your-bot.example.com/abni-callback
content-type: application/json
x-abni-event: image.completed
x-abni-signature: t=1714928400,v1=<HMAC-SHA256 hex of "t=<ts>|<raw body>" using your secret>
{
"event": "image.completed",
"id": "img_a1b2c3d4...",
"version": 1,
"url": "https://cdn.abni.ai/...",
"createdAt": "2026-05-05T10:30:00.000Z",
"metadata": {
"brand": "cor2ed",
"subBrand": "expert-how-to-video",
"campaign": "accepted-style",
"prompt": "a doctor holding a brochure...",
"clientReferenceId": "your-internal-id-123"
}
}
Verifying the signature #
Compute HMAC-SHA256("t=<ts>|<raw body>", secret) with the shared secret you received at provisioning. Compare to the v1= value using a constant-time compare. Reject if not equal, OR if t is older than 5 minutes (replay protection).
Pseudo-code:
const [tPart, v1Part] = headerValue.split(",");
const ts = parseInt(tPart.split("=")[1], 10);
const sig = v1Part.split("=")[1];
if (Math.abs(Date.now() / 1000 - ts) > 300) reject();
const expected = crypto.createHmac("sha256", secret).update(`t=${ts}|${rawBody}`).digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) reject();
Event types #
x-abni-event | When |
|---|---|
image.completed | New image (created or brandified) ready |
image.revised | Revision produced a new version |
image.resized | Resize produced a new version |
image.failed | Pipeline failed permanently |
Retry policy #
At-least-once delivery. Non-2xx responses or timeouts trigger retries via SQS visibility-timeout redrive. After 6 failed receives, the message lands in the dead-letter queue and the result still sits in GET /v1/images/{id} for reconciliation.
Your endpoint should be idempotent — return 2xx for events you’ve already processed (key on id + version).
Rate limits #
Today: enforced by credit balance only. No request-per-second cap. We’ll add explicit rate limiting (with Retry-After headers) if customer volume warrants — you’ll see a 429 RATE_LIMITED if and when that ships.
Support #
- Include
requestIdfrom any error response in your ticket. - Email: team@abni.ai
- Status / changelog: this doc is the source; we’ll publish breaking changes via email to your provisioning contact at least 30 days in advance.