CreaScale AI API

The CreaScale API lets you programmatically generate static image ads and Meta Ads image campaigns (PDA Framework: Persona × Desire × Awareness), manage marketing angles, send WhatsApp utility templates to your customers, and receive webhooks for every step.

Base URL — advertising (static ads, campaigns, angles, credits): https://api.creascale.ai/api/v1

Base URL — WhatsApp messaging (templates, opt-ins, sends, events): https://creascale-agent-api.onrender.com/api/v1

Two different hosts, one API key. A template call on the advertising base returns 404 — use each base for its own endpoints. All requests require an API key. Static ads cost 3 / 5 / 9 cr per image; image pipeline runs consume 30 credits each. Template messages are never billed by CreaScale — Meta charges your WhatsApp Business Account directly.

Getting access: API keys (up to 3, SHA-256 hashed) are created in Settings → API keys on any active paid plan — Pro $49/mo and up. The WhatsApp messaging API is included from Pro. See plans

Machine-readable spec: the full OpenAPI 3.1 specification (every endpoint, schema and webhook event) is published at /docs/api/openapi.yaml. It is versioned with the code and is the source of truth; this page is generated from it and from the live route handlers.

Authentication

Include your API key in the Authorization header:

curl https://api.creascale.ai/api/v1/credits \
  -H "Authorization: Bearer cs_live_your_key_here"

Keep your API key secret. It provides full access to your account's credits and data.

Plan scope: Pro & Scale keys can call the static ads API (/static-ads) and /credits. The WhatsApp messaging API (/messages, /templates, /optin, /webhooks, /events) is available from Pro. The image-campaign endpoints below (/runs, angles, /generate) require the Enterprise plan.

POST

/api/v1/static-ads

Pro+

The primary CreaScale creative endpoint. Generate static image ads (Meta / TikTok feed creatives) from a single prompt — no video needed. Returns immediately; images render in the background (~8s each). Cost per image: draft 3 / standard 5 (default) / premium 9 credits from your unified wallet. Variations are capped per plan (Pro 20 · Scale 50 · Enterprise 100).

Request Body

promptrequired
string

What to advertise — product/offer description, or paste a product URL (≥ 10 chars, ≤ 1000).

quality
string

"draft" (3 cr), "standard" (5 cr, default) or "premium" (9 cr). Premium is Pro+ — clamped to standard on lower plans.

count
integer

Number of distinct ad images (default 1). Capped per plan: Pro 20 / Scale 50 / Enterprise 100. Total cost = per-image price × count.

product_images_base64
string[]

Optional. Up to 4 base64 data URLs (data:image/...;base64,...), ≤ 3 MB each — used as product references.

curl -X POST https://api.creascale.ai/api/v1/static-ads \
  -H "Authorization: Bearer cs_live_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "prompt": "Matte silicone phone case, 80 DH, ships COD in Morocco",
    "quality": "standard",
    "count": 4
  }'

Response (202 Accepted)

{
  "run_id": "a1b2c3d4-...",
  "status": "generating",
  "quality": "standard",
  "count": 4,
  "credits_debited": 20,
  "credits_remaining": 980,
  "expected_eta_s": 32
}
GET

/api/v1/static-ads/:run_id

Pro+

Poll a static-ads batch. Returns its status plus the public URL of each generated image (0 credits). Poll every few seconds until status is completed or failed. Undelivered images are auto-refunded.

curl https://api.creascale.ai/api/v1/static-ads/RUN_ID \
  -H "Authorization: Bearer cs_live_your_key"

Response (200)

{
  "run_id": "a1b2c3d4-...",
  "status": "completed",
  "quality": "standard",
  "images": [
    { "id": "...", "url": "https://...public...", "file_name": "ad_1.jpg", "mime_type": "image/jpeg" }
  ],
  "count": 4
}
POST

/api/v1/ugc/videos

Pro+

Generate AI video ads (spoken hook, subtitles, assembled MP4) from a prompt or a product URL. Returns immediately; rendering runs in the background (~90s per variant). Credit cost is tier × duration × variants: draft 30 / standard 50 / premium 90 credits at the 16-second reference, scaled linearly by duration_s and floored at half the base rate. A 32s standard video therefore costs 100 credits, an 8s one costs 25.

Request Body

promptrequired
string

What to advertise (≥ 10 chars after enrichment, ≤ 1000). Optional only if product_url is given and scrapable.

product_url
string

Landing page to scrape (SSRF-hardened). Its text enriches the brief verbatim and its product images become visual references.

variant_count
integer

Distinct variants in one call (default 1). Clamped to your plan cap: Free 1 / Starter 2 / Pro 20 / Scale 50 / Enterprise 100. Clamped, never rejected.

tier
string

"draft" (30 cr), "standard" (50 cr, default) or "premium" (90 cr) — at the 16s reference.

duration_s
integer

8 to 120 seconds (default 16). Starter is capped at 16s server-side.

target_country
string

ISO country used for cultural framing (e.g. "MA").

target_language
string

Spoken language (e.g. "ar-MSA", "fr", "en").

voice_gender
string

"male" or "female". Anything else falls back to automatic selection.

product_images_base64
string[]

Up to 4 base64 data URLs (data:image/...;base64,...), ≤ 3 MB each.

dry_run
boolean

Returns the generated scene plan with a 200 and debits NOTHING. Use it to preview a brief before spending credits.

curl -X POST https://api.creascale.ai/api/v1/ugc/videos \
  -H "Authorization: Bearer cs_live_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "product_url": "https://example.com/product",
    "prompt": "Hook: this case survived a 2m drop",
    "tier": "standard",
    "duration_s": 16,
    "variant_count": 3
  }'

Response (202 Accepted)

{
  "project_id": "a1b2c3d4-...",
  "project_ids": ["a1b2c3d4-...", "b2c3d4e5-...", "c3d4e5f6-..."],
  "batch_id": "d4e5f6a7-...",
  "count": 3,
  "requested_variants": 3,
  "variant_cap": 20,
  "variant_count": 3,
  "status": "generating",
  "credits_debited": 150,
  "credits_remaining": 850,
  "expected_eta_s": 270
}

batch_id is null when variant_count is 1. When your request exceeds the plan cap, requested_variants and variant_count differ — the call succeeds with fewer variants and debits only what it created.

Errors

400
error

Prompt shorter than 10 characters after enrichment, or product_url that failed URL validation.

402
error

Insufficient credits — carries credits_required. Any project already created is rolled back, so a partial batch is never billed.

403
error

Monthly video cap reached (Starter 2/month, Pro 12/month; Scale and Enterprise are bounded by credits only). The message states the cap and the count already used.

429
error

Max 10 creations per minute per API key.

503
error

Provider temporarily unavailable (fal_locked: true). NOTHING is debited — retry in a few minutes.

GET

/api/v1/ugc/videos/:id

Pro+

Poll one project (0 credits). Returns its status, the ad copy, and every scene with a signed MP4 URL valid for 1 hour — download or re-sign before it expires; the link is not permanent. 400 on a malformed UUID, 404 if the project is not yours.

curl https://api.creascale.ai/api/v1/ugc/videos/PROJECT_ID \
  -H "Authorization: Bearer cs_live_your_key"

Response (200)

{
  "id": "a1b2c3d4-...",
  "name": "Phone case — drop test",
  "status": "completed",
  "variant_count": 1,
  "target_language": "fr",
  "target_country": "MA",
  "ad_copy": { "headline": "...", "primary_text": "..." },
  "final_video_url": "https://...signed...  (1h)",
  "scenes": [
    { "id": "...", "scene_order": 1, "scene_kind": "hook", "status": "completed",
      "subtitle": "This case survived a 2m drop",
      "video_url": "https://...signed... (1h)" }
  ]
}
GET

/api/v1/ugc/videos

Pro+

List your projects, newest first (0 credits). Query: limit (1–100, default 20) and offset (≥ 0). Returns total so you can paginate without guessing.

curl "https://api.creascale.ai/api/v1/ugc/videos?limit=10&offset=0" \
  -H "Authorization: Bearer cs_live_your_key"

Response (200)

{
  "data": [
    { "id": "...", "name": "...", "status": "completed", "variant_count": 3,
      "target_language": "fr", "created_at": "2026-08-15T09:00:00Z" }
  ],
  "total": 42,
  "limit": 10,
  "offset": 0
}
GET

/api/v1/ugc/batch/:batch_id

Pro+

Aggregate status of a multi-variant batch (0 credits). Post once with variant_count: N, then poll this single endpoint instead of N calls to /ugc/videos/:id. Each finished variant carries its signed MP4 (1 hour). 400 on a malformed UUID, 404 if the batch is not yours.

curl https://api.creascale.ai/api/v1/ugc/batch/BATCH_ID \
  -H "Authorization: Bearer cs_live_your_key"

Response (200)

{
  "batch_id": "d4e5f6a7-...",
  "total": 3,
  "done": 2,
  "counts": { "completed": 2, "generating": 1 },
  "complete": false,
  "variants": [
    { "project_id": "...", "name": "Variant 1", "status": "completed",
      "tier": "standard", "duration_s": 16,
      "video_url": "https://...signed... (1h)" }
  ]
}

done counts variants that actually have a playable MP4 — not variants marked completed. complete is true only when every variant is downloadable.

POST

/api/v1/runs

Create a new pipeline run. Returns immediately with a run ID. The pipeline processes in the background (2-5 minutes).

Request Body

product_urlrequired
string

Landing page URL to analyze (HTTPS recommended)

product_description
string

Product/service description (helps Claude generate better angles)

niche
string

Market niche (e.g., skincare, SaaS, fitness)

target_country
string

Target country code (e.g., US, FR, MA)

target_language
string

Output language (e.g., en, fr, ar)

brand_tone
string

Brand voice (e.g., professional, playful, luxurious)

name
string

Run name for your reference

product_image_urls
string[]

Optional. Up to 5 public image URLs (jpeg/png/webp/gif, ≤15MB each). Used as visual references for image generation. SSRF-protected.

product_images_base64
object[]

Optional. Up to 5 base64-encoded images: { file_name?, mime_type, data }. Combined with product_image_urls, total ≤ 5. MIME + magic bytes validated.

Optional Product Images

Provide your own product photos as references — the AI uses them to keep visuals on-brand and product-accurate. If omitted, the pipeline auto-extracts images from product_url. Max 5 images total (URLs + base64 combined), 15MB each. Allowed MIME: jpeg, png, webp, gif.

curl -X POST https://api.creascale.ai/api/v1/runs \
  -H "Authorization: Bearer cs_live_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "product_url": "https://example.com/product",
    "product_image_urls": [
      "https://cdn.example.com/photo1.jpg",
      "https://cdn.example.com/photo2.jpg"
    ]
  }'
curl -X POST https://api.creascale.ai/api/v1/runs \
  -H "Authorization: Bearer cs_live_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "product_url": "https://example.com/product",
    "niche": "skincare",
    "target_country": "US",
    "target_language": "en"
  }'

Response (202 Accepted)

{
  "id": "a1b2c3d4-...",
  "status": "processing",
  "credits_consumed": 30,
  "credits_remaining": 750,
  "created_at": "2026-04-15T12:00:00Z",
  // present only when product_image_urls or product_images_base64 supplied:
  "images_uploaded": 2,
  "image_errors": []
}
GET

/api/v1/runs

List your pipeline runs with pagination and optional status filter.

Query Parameters

limit
integer

Results per page (default: 20, max: 100)

offset
integer

Pagination offset (default: 0)

status
string

Filter by status: pending, running, completed, failed

curl "https://api.creascale.ai/api/v1/runs?limit=10&status=completed" \
  -H "Authorization: Bearer cs_live_your_key"
GET

/api/v1/runs/:id

Get full run details including PDAs (angles), generated images (signed URLs, 1 hour TTL), and ad copies.

{
  "id": "a1b2c3d4-...",
  "status": "completed",
  "pdas": [
    {
      "id": "...",
      "name": "The Skeptic Who Became a Believer",
      "persona": "35-45F, tried everything, trust issues",
      "desire": "Something that actually works",
      "awareness": "warm",
      "hook": "I was the last person who'd try this..."
    }
  ],
  "media": [
    {
      "id": "...",
      "pda_id": "...",
      "file_url": "https://...signed-url...",
      "generation_model": "gemini-3-pro-image"
    }
  ],
  "ad_copies": [
    {
      "id": "...",
      "pda_id": "...",
      "primary_text_v1": "She tried 47 products...",
      "headline_1": "Finally. Real Results.",
      "cta_button": "Shop Now"
    }
  ]
}
GET

/api/v1/runs/:id/media/:media_id/url

Enterprise

Get a fresh signed download URL for a single generated image (0 credits). Use this when the 1-hour signed URLs from GET /runs/:id have expired — e.g. to download assets from a queue worker long after the run completed.

curl https://api.creascale.ai/api/v1/runs/RUN_ID/media/MEDIA_ID/url \
  -H "Authorization: Bearer cs_live_your_key"
GET

/api/v1/credits

Check your current credit balance and plan info.

{
  "credits_remaining": 650,
  "credits_recharge": 150,
  "credits_total": 800,
  "credits_limit": 780,
  "plan": "enterprise",
  "status": "active",
  "current_period_end": "2026-05-15T00:00:00Z"
}

Angle Manipulation

Manipulate marketing angles before generating creatives. Create a run with auto_generate: false (default) to pause at angle review, then use these endpoints.

GET /runs/:id/angles

List all angles for a run. No credit cost.

{
  "data": [{
    "id": "uuid",
    "pda_number": 1,
    "name": "Budget-conscious parent",
    "persona": "...",
    "desire": "...",
    "awareness": "cold",
    "hook": "...",
    "priority": "high"
  }],
  "total": 8,
  "run_status": "reviewing_angles"
}

PUT /runs/:id/angles/:angle_id

Edit an angle (0 credits). Run must be in reviewing_angles status.

{ "hook": "New hook text", "persona": "Updated persona" }

POST /runs/:id/angles/:angle_id/duplicate

Duplicate with AI variation (2 credits). Returns new angle with varied hook.

DELETE /runs/:id/angles/:angle_id

Delete an angle (+2 credits refunded). Min 1 angle must remain.

POST /runs/:id/angles/batch-duplicate

Batch-duplicate for scaling to 999+ variants (2 credits per copy, max 50/call). Same marketing angle, different visual compositions.

{
  "duplicates": [
    { "angle_id": "uuid-1", "copies": 10 },
    { "angle_id": "uuid-2", "copies": 5 }
  ]
}

POST /runs/:id/angles/add

Add 5 new AI-generated angles (20 credits). Returns 202 — angles generated in background.

Generate Creatives

POST /runs/:id/generate

Validate angles and launch combined image + ad copy generation. Returns 202 immediately. Fires angles.validated, images.completed, then run.completed webhooks.

{
  "approved_angle_ids": ["uuid-1", "uuid-3", "uuid-5"]
}
// Omit to keep all angles

AI Image Edit

POST /runs/:id/media/:media_id/ai-edit

Regenerate an existing image with a natural-language edit instruction — 3 credits (standard quality) or 15 credits (premium quality) via the optional quality field. Synchronous response — typically 10-30s. Auto-refunds credits if AI fails. Optional reference_image_base64 for product-swap (replace the product in the ad with the reference). Fires media.ai_edited webhook on success. Retry on us: a second edit on the same source image within 15 minutes is free — the response returns free_retry: true and credits_consumed: 0. Capped at 2 free retries per source image per hour.

curl -X POST https://api.creascale.ai/api/v1/runs/RUN_ID/media/MEDIA_ID/ai-edit \
  -H "Authorization: Bearer cs_live_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "instruction": "change background to white, remove discount badge"
  }'
instruction
string, 1-500 chars

Plain-language description of what to change (required).

reference_image_base64
base64 string, ≤10MB decoded

Optional — for product swap. Must be valid PNG/JPEG/WebP.

reference_image_mime
image/png | jpeg | webp

Optional — declared MIME, validated against magic bytes.

Rate limit: 10/min per API key (on top of the 60/min global). Returns 422 if the AI cannot process the edit (credits auto-refunded), 404 if media doesn't belong to the run, 413 if reference image exceeds 10MB.

WhatsApp Messaging API

Pro+

Send approved WhatsApp utility templates from your own number — order confirmations, shipping updates, follow-ups — including outside the 24-hour service window, and receive your customers' replies (button clicks included) as webhooks. This is the rail our production COD merchants run their order-confirmation flow on.

Messaging base URL: https://creascale-agent-api.onrender.com/api/v1

Same cs_live_ API keys as the advertising API, different host. Endpoints on this base: /templates, /messages, /optin, /events, /webhooks.

Prerequisites

WhatsApp connected
cockpit

Your number is connected via the Agent tab (official Meta Cloud API). No number = 409 no_whatsapp_channel.

Templates approved
Meta

Templates are created and approved in Meta Business Manager (a payment method must be attached to your WABA — Meta refuses all templates without one).

Feature enabled
setting

templates_enabled is on for your workspace. Off = 409 templates_disabled on every send.

Billing
Meta-direct

CreaScale never bills template messages — Meta charges your WhatsApp Business Account directly.

No-code alternative: merchants on any paid plan can manage templates and the automatic order-confirmation (sent in the customer's language when an order is created) directly from the Agent tab → WhatsApp templates — no API required.

POST

/api/v1/messages/template

Pro+

Queue an approved template for sending. Returns 202: the send is queued, not yet delivered — the worker posts it to Meta with bounded retries. Track it with GET /api/v1/messages/{send_id} or webhooks.

curl -X POST https://creascale-agent-api.onrender.com/api/v1/messages/template \
  -H "Authorization: Bearer cs_live_your_key" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-8832-confirmation" \
  -d '{
    "to": "212600000001",
    "template": "order_confirmation",
    "language": "fr",
    "body_params": ["Yassine", "8832", "329 DH"],
    "quick_reply_payloads": ["CONFIRM:8832", "CANCEL:8832"]
  }'

Request Body

torequired
string

Customer phone in international digits (E.164 without +).

templaterequired
string

Template name, exactly as approved in Meta.

languagerequired
string

Template language code (fr, ar, en, …) — must match an approved variant.

body_params
string[]

Flat array of body variables, in {{1}}, {{2}}, … order.

header_params
string[]

Header variables, if the template has a text header.

button_url_param
string

Dynamic URL suffix, if the template has a dynamic-URL button.

quick_reply_payloads
string[]

Payloads for quick-reply buttons (e.g. "CONFIRM:8832"). Echoed back verbatim in message.received when the customer taps.

Response (202 Accepted)

{
  "send_id": "b3f1c2d0-...",
  "status": "queued",
  "duplicate": false,              // true = this Idempotency-Key was already used → same send_id, no new message
  "service_window_minutes_left": 1240   // present when the 24h service window is currently open
}

Import the opt-in first — or nothing is sent. Without a consent record for that customer, every send is refused with 409 optin_missing, even a utility one. The case that catches everyone: a shopper who bought on your store but never messaged your WhatsApp number has no consent — that is nearly all of your customers on day one. Call POST /api/v1/optin/import in the same code path, right before your first send to a customer.

body_params is a flat array — not Meta's components shape. Every official Graph example shows components: [{ type: "body", parameters: […] }]; this API rejects it with 400 unknown_fields (the offending keys are listed). We only produce text parameters, so accepting the shape without supporting its content would hand you a 202 for a send that dies at Meta a minute later. A wrong count is refused too — 400 body_params_count_mismatch returns expected_body_params and received_body_params, so you never have to guess.

Errors — 400 = fix the request · 409 = fix the state

400 = fix the request — no idempotency key is consumed, replay it corrected with the same key. 409 = fix the state (consent, template approval, daily cap, sending hours): the request itself is fine, so do not change it.

Code · reasonMeaning
400 (Idempotency-Key)Header missing — it is required (1-255 chars), never optional.
400 invalid_body / invalid_fieldBody is not a JSON object, or a field has the wrong type.
400 unknown_fieldsUnsupported keys (e.g. Meta's components) — listed in the response.
400 body_params_count_mismatchWrong number of body variables — expected/received counts returned.
409 templates_disabledThe templates feature is not enabled on your workspace.
409 template_unknownNo template with this name/language on your account — run POST /templates/sync.
409 template_not_approvedStatus is pending / rejected / paused — only approved templates can be sent.
409 optin_missingNo active consent for this customer (or revoked by a STOP).
409 marketing_optin_missingConsent exists but is not an explicit marketing opt-in.
409 daily_cap_reachedYour configured daily send cap is reached — resumes tomorrow.
409 monthly_cap_reachedYour plan's monthly send volume is reached (Pro 10,000 · Scale 100,000 · Enterprise unlimited) — resumes next month.
409 free_cap_reachedThe Free plan's monthly allowance is used up — upgrade to keep sending, or wait for the 1st of the month. The messaging API is Pro+, so this is reachable only when a formerly paid account fell back to Free (past_due / canceled) while its key was still honoured.
409 free_trial_exhaustedLifetime trial allowance is used up — pick a paid plan to keep sending.
409 no_whatsapp_channelNo WhatsApp number is connected on the account — connect one in the Agent tab.
409 outside_send_hoursOutside the sending hours you configured (evaluated in your workspace timezone).
409 no_whatsapp_channelNo connected WhatsApp number on the account.

Manage templates

GET /api/v1/templates

Local mirror of your WABA templates: name, language, status (approved / pending / rejected — with the Meta rejection reason), and category (utility / marketing / authentication).

POST /api/v1/templates/sync

Pull the current template list from Meta (read-only Graph call, no sends). Run it after every approval or edit in Meta Business Manager — template_unknown on a send usually just means the sync hasn't run yet.

POST /api/v1/templates · DELETE /api/v1/templates/{name}

Create a template (submitted to Meta review, comes back as pending) or delete one from your WABA. DELETE returns 204, or 409 when no WhatsApp channel / WABA is connected.

Meta can re-classify a template AFTER approval (category update events). A template approved as utility and later reclassified marketing immediately requires the explicit marketing opt-in — your sends start failing with 409 marketing_optin_missing, not silently.

Automation settings

Which template goes out when an order is created, which one on shipment, the follow-up texts, and the Google Sheet watch. Until now these could only be set by clicking in the cockpit — an ERP driving everything else through the API still needed a human in a browser for this.

Messaging base only. These two routes live on creascale-agent-api.onrender.com, like /messages/template. They do not exist on api.creascale.ai, which serves the advertising pipeline. Available from Pro.

GET /api/v1/templates/config

Returns the ten writable settings plus your plan entitlements. Fields prefixed readonly_ are readable but not writable: they are plan rights, not settings — reading them is what lets you avoid writing blind.

curl https://creascale-agent-api.onrender.com/api/v1/templates/config \
  -H "Authorization: Bearer cs_live_your_key"
{
  "order_confirm_template": "order_confirmation",
  "order_confirm_text": null,
  "shipping_confirm_template": "shipping_update",
  "order_followup_text": null,
  "followup_confirmed_text": "Order confirmed, delivery within 48h.",
  "followup_cancelled_text": null,
  "sheet_watch_enabled": false,
  "sheet_watch_url": null,
  "sheet_watch_template": null,
  "sheet_watch_map": null,
  "readonly_templates_enabled": true,
  "readonly_template_daily_cap": null,
  "readonly_template_hours": null,
  "readonly_timezone": "Africa/Casablanca",
  "readonly_plan": "pro",
  "readonly_sends_monthly_cap": 10000,
  "readonly_sends_lifetime_cap": null,
  "readonly_templates_max": null
}

PATCH /api/v1/templates/config

The body is a delta: an absent key is left untouched, null clears the setting. The response is the state re-read from the database, not an echo of your request — so a partial refusal is visible without a second call.

order_confirm_template
string|null

Utility template sent when a Shopify order is created.

order_confirm_text
string|null

Free-text confirmation for the non-official rail. Max 1024 chars.

shipping_confirm_template
string|null

Template sent when the order becomes fulfilled.

order_followup_text
string|null

Generic follow-up after the customer replies (24h window). Max 1024.

followup_confirmed_text
string|null

Follow-up when the customer tapped the confirm button.

followup_cancelled_text
string|null

Follow-up when the customer tapped the cancel button.

sheet_watch_enabled
boolean

Watch a Google Sheet — a new row triggers a send. Opt-in: false clears it.

sheet_watch_url
string|null

Google Sheet link shared for reading. Only docs.google.com is accepted; any other host is refused.

sheet_watch_template
string|null

Template sent to new Sheet rows.

sheet_watch_map
object|null

Column mapping { phone_idx, var_idx[≤3], header }. You supply it — the API does not guess. Without it the sweep falls back to a heuristic and may read the wrong columns.

curl -X PATCH https://creascale-agent-api.onrender.com/api/v1/templates/config \
  -H "Authorization: Bearer cs_live_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "order_confirm_template": "order_confirmation",
    "shipping_confirm_template": "shipping_update"
  }'

Errors

400 unknown_fields
error

A key outside the list above. The offending names come back in fields[]. Nothing is written — a setting silently ignored would leave you believing your order confirmation was armed while nothing goes out.

400 invalid_values
error

A value the validator refused (a Sheet URL outside docs.google.com, a text over its limit). The rejected keys are named in fields[], and every OTHER key in the same request is left untouched.

400 empty_patch
error

The body contained no writable key.

What stays in the cockpit

The agent's own identity — its prompt, payment mode, escalation number, contact e-mail — is not writable here. Neither is the sending rail toggle, nor the monthly and lifetime send caps: those are plan rights, and they change with a subscription, not with a request. Deleting a template through DELETE /api/v1/templates/{name} automatically clears any setting pointing at it, so an automation never fires at a template that no longer exists.

Opt-ins & consent

Two consent levels, enforced server-side on every send:

utility / authentication
active opt-in

A customer who has messaged your WhatsApp number has one automatically (reply-only model). A STOP revokes it — permanently, until the customer opts back in.

marketing
explicit opt-in

Must be imported with a dated declaration. “They once wrote to us” does NOT count — implied marketing sends are the number-one cause of WhatsApp number bans.

POST /api/v1/optin/import

Declare a consent you collected outside WhatsApp (checkout checkbox, form). The three declaration fields are required — Meta treats undeclared marketing sends as spam:

{
  "customer": "212600000001",
  "origin": "checkout_checkbox",            // where the consent was collected
  "collected_at": "2026-08-01T14:32:00Z",   // when
  "consent_text": "I agree to receive order updates and offers on WhatsApp"
}

GET /api/v1/optin/{customer}

Current consent state for a phone number — check it before a campaign instead of collecting 409s.

DELETE /api/v1/optin/{customer}

Honour a STOP you received anywhere else — phone call, website, support desk. Idempotent, and the revocation is absorbing: it lands on every spelling of the number, so no rail can send to that customer again. Sending to someone who opted out is the number-one way to lose a WhatsApp Business account, so wire this into wherever you record consent.

POST

/api/v1/messages/text

Pro+

Free-text reply from your CRM, inside the 24-hour window opened by the customer's last message. Outside that window Meta refuses (code 131047): you get 409 outside_service_window with Meta's error verbatim — send a template instead. No HUMAN_AGENT tag is set.

curl -X POST https://creascale-agent-api.onrender.com/api/v1/messages/text \
  -H "Authorization: Bearer cs_live_your_key" \
  -H "Content-Type: application/json" \
  -d '{ "to": "212600000001", "text": "Hi Yassine, your parcel ships tomorrow." }'

Request Body

to
string

Customer phone (E.164, with or without +) or Instagram IGSID. The most recent conversation with that customer on the channel is used.

conversation_id
uuid

Alternative to to: target one thread precisely (from GET /conversations or the message.received webhook).

textrequired
string

Up to 900 characters.

channel
string

"whatsapp" (default) or "instagram". Only with to.

Every text takes over the conversation. The AI agent goes silent on that thread until you call POST /api/v1/conversations/{id}/resolve. A CRM that never calls resolve keeps the agent mute forever for that customer.

Refusals

409 no_conversation
state

This customer never wrote to you on that channel: there is no window to use. Send a template, or wait for their message.

409 outside_service_window
state

More than 24 h since the customer's last message. meta_error carries Meta's text verbatim.

409 monthly_cap_reached
quota

Texts count against your plan's monthly cap (Pro 10,000 · Scale 100,000 · Enterprise unlimited), cockpit and API combined. No credits are debited. Response carries cap and used. free_cap_reached is never returned here: a Free account is refused upstream with plan_required — that reason exists only on POST /messages/template.

502 send_failed
Meta

Any other Meta refusal (expired token, blocked account…) with meta_error and meta_code. Nothing was sent.

429
rate

30 texts per minute per key, under the global 60 req/min.

POST

/api/v1/messages/media

Pro+

Send a photo, a video, a document or an audio file by link: you pass a publicly reachable HTTPS URL and Meta fetches it. No bytes go through CreaScale, and there is nothing to upload. Same rail as free text, so the same rules apply — 24-hour window, same monthly cap, takeover until /resolve, no credits. WhatsApp only.

curl -X POST https://creascale-agent-api.onrender.com/api/v1/messages/media \
  -H "Authorization: Bearer cs_live_your_key" \
  -H "Content-Type: application/json" \
  -d '{ "to": "212600000001", "type": "image",
        "link": "https://cdn.example.com/jewels/nour-ring.jpg",
        "caption": "The Nour ring, 1,200 MAD" }'

Request Body

to
string

Customer phone (E.164, with or without +). Alternative: conversation_id, to target one thread precisely.

typerequired
string

"image" · "video" · "document" · "audio".

linkrequired
url

Publicly reachable HTTPS URL, up to 2048 characters. Meta fetches it — keep it reachable.

caption
string

Image and video only, up to 900 characters. Meta's limitation, not ours.

filename
string

Document only, and required there — without it your customer sees a truncated URL.

What Meta accepts, per type

image
5 MB

caption: yes · filename: no

video
16 MB

caption: yes · filename: no

audio
16 MB

caption: no · filename: no

document
100 MB

caption: no · filename: required

GET

/api/v1/messages/media/{handle}

Download a file a customer sent you. The handle comes from the media[].url field of the message.received webhook: replay that URL as-is, with your API key. You cannot fetch it yourself — the URL Meta returns expires in 5 minutes and requires your WhatsApp Business token, which stays in CreaScale's vault. We do both hops for you, in memory, and keep nothing.

// The webhook tells you what arrived:
{
  "event": "message.received",
  "data": {
    "customer": "212600000001",
    "text": "",                      // the caption, if any
    "media": [{
      "type": "image",               // image · video · audio · document · sticker
      "mime": "image/jpeg",
      "size": 84213,
      "voice": false,                // true only for a WhatsApp voice note
      "filename": null,
      "url": "https://creascale-agent-api.onrender.com/api/v1/messages/media/wa_1234567890",
      "expires_in_days": 7
    }]
  }
}

Download on receipt, and store it on your side. This is a window, not an archive: Meta expires a media id received by webhook after 7 days, and CreaScale stores no file — not on disk, not in the database. After that you get 410 media_expired and the file is gone for good. For the same reason, GET /conversations/{id}/messages returns a neutral label (“photo”, “voice note”), never the media.

Refusals

400 invalid_link
request

Not HTTPS, unparseable, or over 2048 characters.

400 caption_not_supported
request

Caption on an audio or a document. Meta's rule — refused here rather than as an opaque (#100) from Graph.

400 filename_required
request

A document without a filename reaches your customer as a truncated URL.

400 invalid_channel
request

Instagram is not served: no document, no audio, no caption there.

409 outside_service_window
state

More than 24 h since the customer's last message. Media is a service message, exactly like free text. Send a template.

409 monthly_cap_reached
quota

Media counts against the same monthly cap as text, cockpit and API combined. No free_cap_reached on this rail either — see POST /messages/template.

410 media_expired
state

Meta no longer serves this media: webhook media ids expire after 7 days.

413 media_too_large
state

Above Meta's limit for that type. Refused before downloading.

502 send_failed
Meta

Meta could not read your link (404, auth required, unsupported type). Its error is returned verbatim.

429
rate

30 media sends and 60 downloads per minute per key.

Delivery tracking

GET /api/v1/messages/{send_id}

{
  "send_id": "b3f1c2d0-...",
  "status": "sent",                  // queued → sent · failed (definitive, with the Meta error code)
  "provider_message_id": "wamid.…",  // Meta message id, once accepted
  "error": null,
  "created_at": "2026-08-09T18:02:11Z"
}

delivered / read receipts arrive as webhook events when Meta reports them — customers can disable read receipts, so never treat their absence as a failure.

GET /api/v1/events?since=

Reconciliation poll: the same events as webhooks, pulled on your schedule. Push gives you latency, poll gives you the guarantee — run a periodic /events sweep even with webhooks configured, so a missed delivery never becomes a lost order confirmation.

Conversations & inbox

The read side of a third-party CRM: one number, several operators, each answering from your tool. Inbound is already pushed to you (message.received, now carrying conversation_id); these routes give you the threads.

GET /api/v1/conversations?limit=&channel=&since=

{
  "data": [{
    "id": "9c2e…",
    "customer": "212600000001",       // not masked: it is your customer
    "channel": "whatsapp",
    "language": "fr",
    "state": "human",                 // human = one of your operators has the thread (agent silent)
    "last_message_at": "2026-09-04T20:11:03Z",
    "last_preview": "Merci, c'est expédié.",
    "last_direction": "outbound"
  }],
  "next_since": "2026-09-04T20:11:03Z"   // pass it back as ?since= to get only what changed
}

GET /api/v1/conversations/counts

{ "all": 534, "whatsapp": 66, "instagram": 468, "escalations": 3 }   // real totals, never a page size

GET /api/v1/conversations/{id}/messages?limit=&after=

{
  "data": [
    { "id": "…", "from": "customer", "text": "Bonjour, c'est expédié ?", "at": "2026-09-04T20:10:41Z", "direction": "inbound",  "status": null },
    { "id": "…", "from": "human",    "text": "Oui, demain.",             "at": "2026-09-04T20:11:03Z", "direction": "outbound", "status": "delivered" }
  ],
  "next_after": "2026-09-04T20:11:03Z"
}

POST /api/v1/conversations/{id}/resolve

Hands the thread back to the AI agent after a text reply (yours or a cockpit one). Idempotent. Returns { "conversation_id": "…", "state": "agent" }.

Before you build a multi-operator CRM, a Meta constraint: a number migrated to Cloud API no longer works in the WhatsApp Business app. “One main phone + operator phones” is impossible: either Cloud API and everything goes through your CRM (assignment and claim are your app's job — fully covered here), or the multi-device WhatsApp Business app (5 devices max) with no API at all.

ERP integration guide — order confirmations A→Z

The exact flow we run in production with a Moroccan COD merchant's ERP: confirm every order on WhatsApp with CONFIRM / CANCEL buttons, track the answer, never double-send, never double-reply.

1 · Sync
setup

POST /templates/sync, then GET /templates — your order-confirmation template must be status approved.

2 · Opt-in
per customer

Before the first send to a customer, POST /optin/import with the dated declaration (for utility: an inbound WhatsApp message from them also counts).

3 · Send
on order created

POST /messages/template with Idempotency-Key = a stable business key ("order-8832-confirmation") — a webhook retry or a network replay can never text the customer twice.

4 · Track
async

202 ≠ delivered. Watch message.accepted / message.failed webhooks (or poll GET /messages/{send_id}) and surface failures to your ops — a failed confirmation is a call to make.

5 · Receive
webhook

Subscribe to message.received: the customer's reply, including the tapped button payload (CONFIRM:8832) and the conversation_id, lands on your endpoint.

6 · Act
your system

Route by the ORDER ID inside the button payload — never by “this customer's latest pending order”: a customer with two open orders would flip the wrong one.

Your receiver — the rules that survive production

ACK fast
< 10 s

Reply 2xx immediately, process async. Failed deliveries are retried 5× with backoff — a slow handler manufactures duplicates upstream.

Deduplicate
idempotence

Store processed (event, send_id / message id) pairs and skip repeats — retries WILL redeliver events you already handled.

Verify, fail closed
HMAC

Verify X-CreaScale-Signature on the RAW body (see Webhooks below). If your webhook secret env is missing, return 401 — never 200.

Kill-switch
env flag

Gate the whole rail behind one env var (e.g. CREASCALE_ENABLED) so you can stop it without redeploying.

One voice
anti double-reply

If the CreaScale AI agent is live on the same number, do not auto-reply to message.received from your system too — the customer would get two answers. Record the event; let the agent (or your team) own the conversation.

const express = require('express');
const crypto = require('crypto');
const app = express();

// Kill-switch: one env var stops the rail without a redeploy.
const ENABLED = process.env.CREASCALE_ENABLED === 'true';
const SECRET = process.env.CREASCALE_WEBHOOK_SECRET; // fail CLOSED if missing

const seen = new Set(); // use a DB table in production

app.post('/creascale-webhook', express.raw({ type: 'application/json' }), (req, res) => {
  if (!ENABLED) return res.sendStatus(503);
  if (!SECRET) return res.sendStatus(401); // never 200 without verification

  const sig = req.headers['x-creascale-signature'];
  const ts = req.headers['x-creascale-timestamp'];
  const body = req.body.toString('utf8');
  const expected = 'sha256=' + crypto.createHmac('sha256', SECRET)
    .update(ts + '.' + body).digest('hex');
  const fresh = Math.abs(Date.now() / 1000 - Number(ts)) <= 300;
  if (!fresh || !crypto.timingSafeEqual(Buffer.from(sig || ''), Buffer.from(expected))) {
    return res.status(401).send('Invalid signature');
  }

  const evt = JSON.parse(body);
  const key = evt.event + ':' + (evt.data.send_id || evt.data.message_id || '');
  if (seen.has(key)) return res.sendStatus(200); // retry replay — already handled
  seen.add(key);

  res.sendStatus(200); // ACK FIRST — then process async
  setImmediate(() => {
    if (evt.event === 'message.received' && evt.data.button_payload) {
      // "CONFIRM:8832" → confirm ORDER 8832 — never "the customer's latest order".
      const [action, orderId] = evt.data.button_payload.split(':');
      // handleOrderAnswer(action, orderId, evt.data.from);
    }
  });
});

Webhooks

Register HTTPS endpoints to receive notifications. Payloads are signed with HMAC-SHA256.

Events

Both bases accept the same 14 events on POST /webhooks and share your subscriptions — subscribe once, on either base. Ad-pipeline events are emitted by the advertising API; template and message events by the messaging service. An unknown event name in the subscription is refused with 400 (never silently dropped).

run.completed
event

Pipeline run finished successfully (images + ad copies ready)

run.failed
event

Pipeline run failed

angles.ready
event

Angles generated, run paused for review

angles.added
event

New AI angles added to a run

angles.validated
event

Angles validated, generation starting

images.completed
event

Images generated, ad copy in progress

media.ai_edited
event

An existing media was edited via POST /runs/:id/media/:mid/ai-edit

template.approved
event

Meta approved a template — it becomes sendable

template.rejected
event

Meta rejected a template — the reason ships with the event

message.accepted
event

Template send accepted by Meta (wamid assigned) — NOT delivered yet

message.delivered
event

Delivered to the customer's phone

message.read
event

Read (only if the customer has read receipts on)

message.failed
event

Definitive send failure, with the Meta error code

message.received
event

The customer REPLIED — text or button tap (button_payload included)

Signature Verification

Each delivery includes X-CreaScale-Signature and X-CreaScale-Timestamp headers. Compute HMAC-SHA256 of ${timestamp}.${rawBody} using your webhook secret. Reject events with timestamps older than 5 minutes to prevent replay attacks.

const crypto = require('crypto');

function verifySignature(body, signature, timestamp, secret) {
  // Reject if timestamp older than 5 min (prevents replay)
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - parseInt(timestamp)) > 300) return false;

  const signedString = timestamp + '.' + body;
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(signedString)
    .digest('hex');
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  );
}

// In your Express handler (use raw body parser):
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const sig = req.headers['x-creascale-signature'];
  const ts = req.headers['x-creascale-timestamp'];
  const body = req.body.toString('utf8');
  if (!verifySignature(body, sig, ts, WEBHOOK_SECRET)) {
    return res.status(401).send('Invalid signature');
  }
  const event = JSON.parse(body);
  // Process event...
  res.sendStatus(200);
});

Webhook Payload

{
  "event": "run.completed",
  "timestamp": "2026-04-15T12:05:00Z",
  "data": {
    "run_id": "a1b2c3d4-...",
    "status": "completed"
  }
}

Retry Policy

Failed deliveries are retried up to 5 times with exponential backoff (30s, 60s, 120s, 120s, 120s). After 5 failures, the delivery is marked as failed.

Subscribe & manage

Subscriptions are created through the API — there is no dashboard step. Both bases share your subscriptions, so you register once, on either one.

The plan requirement differs per base. On api.creascale.ai webhook management is Enterprise; on creascale-agent-api.onrender.com it follows the messaging API and is available from Pro. A 403 on one base does not mean your key is wrong — try the other.

POST /api/v1/webhooks

urlrequired
string

HTTPS endpoint, 10–2048 chars. Private, loopback and link-local addresses are refused (SSRF protection).

events
string[]

Which events to receive. Defaults to ["run.completed", "run.failed"]. Names come from the list above.

curl -X POST https://creascale-agent-api.onrender.com/api/v1/webhooks \
  -H "Authorization: Bearer cs_live_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-erp.example.com/creascale/hook",
    "events": ["message.received", "message.delivered", "template.approved"]
  }'

Response (201 Created)

{
  "id": "a1b2c3d4-...",
  "url": "https://your-erp.example.com/creascale/hook",
  "secret": "whsec_...",
  "events": ["message.received"]
}

The signing secret is returned exactly once, in this response. Later listings only show a masked prefix. Lose it and the only fix is to delete the subscription and create a new one — that is what makes signature verification meaningful.

Errors

400
error

Non-HTTPS URL, or an address that fails the SSRF check.

400
error

Unknown event name. The WHOLE subscription is refused and the offending names are listed — a typo never silently trims your subscription down to the events that happened to be spelled right.

400
error

Maximum 5 webhooks per account. Delete one first.

GET /api/v1/webhooks

Lists your subscriptions, newest first. Each carries id, url, events, is_active, created_at and secret_masked (a prefix only — enough to tell two subscriptions apart, never enough to sign).

{
  "data": [
    { "id": "a1b2c3d4-...",
      "url": "https://your-erp.example.com/creascale/hook",
      "secret_masked": "whsec_12...",
      "events": ["message.received"],
      "is_active": true,
      "created_at": "2026-08-15T09:00:00Z" }
  ]
}

DELETE /api/v1/webhooks/{id}

Returns 204 with no body. A malformed UUID gives 400; an id that exists but belongs to another account gives 404, not 403 — we never confirm the existence of someone else's resource.

curl -X DELETE https://creascale-agent-api.onrender.com/api/v1/webhooks/WEBHOOK_ID \
  -H "Authorization: Bearer cs_live_your_key"

Rate Limits

LimitValue
Per API key (global)60 requests / minute
Create run / relaunch10 / minute
Generate creatives5 / minute
Batch duplicate / Add angles3 / minute
AI image edit10 / minute · 3 credits (standard) / 15 (premium)
Static ad creditsdraft 3 / standard 5 / premium 9 per image · 10 / minute
New run credits30 credits
Relaunch credits12 credits
Batch duplicate max50 copies / call
Max API keys3 per account
Max webhooks5 per account

Error Codes

CodeMeaning
400Bad request — invalid parameters or URL
401Invalid API key — missing, malformed, or revoked
402Insufficient credits — recharge or upgrade plan
403Plan too low for this endpoint — Pro/Scale can call the static-ads API; WhatsApp messaging is included from Pro; image campaigns (/runs, angles, webhooks) need Enterprise
404Not found — run or webhook doesn't exist or isn't yours
429Rate limited — max 60 requests per minute per key
500Internal error — retry or contact support

Error Response Format

{
  "error": "Insufficient credits",
  "credits_remaining": 10,
  "credits_required": 30
}