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://agent.creascale.ai/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.
/api/v1/static-ads
Pro+The primary CreaScale creative endpoint. Generate static image ads (Meta / TikTok feed creatives) — one spec, or a batch of up to 50 different specs in one call. Returns 202 immediately; every image is queued and processed in the background (no rate-limit 429: calls are queued). Cost per image: draft 3 / standard 5 (default) / premium 9 credits from your unified wallet. Images per call are capped per plan (Pro 20 · Scale 50 · Enterprise 100).
Quality control before delivery. Every image is checked: the text matches your lines exactly (no extra, duplicated or misspelled line), every legible line of each product label matches your label_lock and the real label read on your product photo, the right number of products and hands, no alcohol or tobacco, no fake buttons or badges, full bleed, product size, and at least 1080 px on the short side (1080×1350 in 4:5). A failed check triggers up to 2 automatic retries that escalate (better model, 2K, then your real product composited). An image still flawed is delivered flagged and its credits are refunded when the defect is ours.
Request Body
product_urlEasiest mode: the public URL of ONE product page. CreaScale reads the page (photos, name, price, description), plans a different marketing angle for each image (persona × desire × awareness), writes the headline (French, English or Arabic) and composes the scene. Nothing else is required.
ideaOptional, with product_url: your own idea in your words (≤ 500 chars), e.g. "focus on the gift for Mother's Day". Every concept follows it.
promptScene / creative direction (≤ 2000 chars): setting, light, people, props. Put the ad text in headline / subline / cta, not here. Required unless product_url, headline, subline, products or ads is given.
headlineExact headline written on the image (≤ 120 chars).
sublineExact small line (offer, price, format…), ≤ 200 chars.
extra_linesUp to 2 more exact small lines (e.g. "Cash on delivery").
ctaOptional call to action (≤ 40 chars). Without it, no button, pill or badge is drawn.
languageISO code of the text on the image ("fr", "en", "ar"…). Default: detected from the headline.
text_mode"exact" (default when headline / subline / cta is given: these lines and nothing else), "none" (no text at all) or "auto" (CreaScale writes one headline in `language`).
productsUp to 4 real products: { name, image_urls (≤ 4 public photo URLs), label_lock (the exact label text, one string per printed line), quantity }. Exactly these products appear — no invented extra one.
product_mode"generate" (default: the model redraws the product from the photos) or "composite" (your real product photo is cut out and placed in the generated scene — pixel-exact label; for packshots and still lifes, not for hands holding the product).
aspect_ratio"4:5" (default), "1:1", "9:16", "3:4", "2:3" or "16:9".
min_product_heightMinimum height of the tallest product, as a fraction of the image height (default 0.4 when products are given).
allow_alcohol / allow_tobaccoDefault false: no alcohol or tobacco in the picture.
countVariations of this spec (default 1). Camera angle and light vary; text and products never do. With product_url: the number of different angles.
quality"draft" (3 cr), "standard" (5 cr, default) or "premium" (9 cr, 2K). Premium is Pro+ — clamped to standard on lower plans.
adsBatch: up to 50 different specs (each with its own prompt / headline / products…). Top-level fields act as defaults.
product_image_urlsLegacy: up to 4 public photo URLs when you cannot split them per product. Prefer products.
product_images_base64Optional. 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 '{
"quality": "premium",
"prompt": "Warm still life on a walnut table, amber evening light",
"headline": "Two perfumes, one gift.",
"subline": "Gift set · 2 × 10 ml · $25",
"language": "en",
"products": [{
"name": "Amber eau de parfum",
"image_urls": ["https://cdn.example.com/bottle.png"],
"label_lock": ["AMBER", "EAU DE PARFUM", "50 ml"]
}]
}'Response (202 Accepted)
{
"run_id": "a1b2c3d4-...",
"status": "queued",
"engine": "v2",
"quality": "premium",
"count": 1,
"credits_debited": 9,
"credits_remaining": 391,
"queue_ahead": 0,
"expected_eta_s": 55,
"product_images_used": 1,
"items": [
{ "ordinal": 1, "text_mode": "exact", "language": "en", "headline": "Two perfumes, one gift.", "product_mode": "generate", "aspect_ratio": "4:5", "products": 1 }
]
}Errors: 400 nothing to generate · 402 out of credits · 403 storage or Starter (existing subscribers) monthly cap · 429 queue_full more than 500 images already waiting for your account (poll, then retry) · 422 product_url_* the product page could not be read (not a product page, no photo, unreachable) — nothing is charged.
/api/v1/static-ads/:run_id
Pro+Poll a static-ads batch (0 credits) every ~15 s until status is completed or failed. Each delivered image carries its pixel size and its quality-control result. Images not delivered, or delivered with a defect that is ours (refund_due: true), are refunded when the batch closes.
curl https://api.creascale.ai/api/v1/static-ads/RUN_ID \
-H "Authorization: Bearer cs_live_your_key"Response (200)
{
"run_id": "a1b2c3d4-...",
"engine": "v2",
"status": "completed",
"progress": { "total": 2, "done": 2, "failed": 0, "running": 0, "queued": 0 },
"refund": { "images": 0, "done": true },
"images": [
{
"id": "...", "url": "https://...public...", "ordinal": 1,
"width": 1843, "height": 2304, "aspect_ratio": "4:5", "upscale": 1,
"product_mode": "generate",
"text": { "mode": "exact", "lines": ["Two perfumes, one gift.", "Gift set · 2 × 10 ml · $25"], "language": "en" },
"qc": { "status": "pass", "issues": [], "attempts": 1, "fault": null },
"refund_due": false
}
],
"items": [{ "ordinal": 1, "status": "done", "attempts": 1 }],
"count": 2
}qc.status is pass, fail (with the exact defects in qc.issues and qc.fault = ours or request) or unchecked (the check could not run — never reported as pass). With product_url, each image also carries its concept: angle, funnel stage, persona, desire, headline and ready-to-paste Meta ad copy.
/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
promptrequiredWhat to advertise (≥ 10 chars after enrichment, ≤ 1000). Optional only if product_url is given and scrapable.
product_urlLanding page to scrape (SSRF-hardened). Its text enriches the brief verbatim and its product images become visual references.
variant_countDistinct variants in one call (default 1). Clamped to your plan cap: Free 1 / Starter (existing subscribers) 2 / Pro 20 / Scale 50 / Enterprise 100. Clamped, never rejected.
tier"draft" (30 cr), "standard" (50 cr, default) or "premium" (90 cr) — at the 16s reference.
duration_s8 to 120 seconds (default 16). Starter (existing subscribers) is capped at 16s server-side.
target_countryISO country used for cultural framing (e.g. "MA").
target_languageSpoken language (e.g. "ar-MSA", "fr", "en").
voice_gender"male" or "female". Anything else falls back to automatic selection.
product_images_base64Up to 4 base64 data URLs (data:image/...;base64,...), ≤ 3 MB each.
dry_runReturns 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
400Prompt shorter than 10 characters after enrichment, or product_url that failed URL validation.
402Insufficient credits — carries credits_required. Any project already created is rolled back, so a partial batch is never billed.
403Monthly video cap reached (Starter (existing subscribers) 2/month, Pro 12/month; Scale and Enterprise are bounded by credits only). The message states the cap and the count already used.
429Max 10 creations per minute per API key.
503Provider temporarily unavailable (fal_locked: true). NOTHING is debited — retry in a few minutes.
/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)" }
]
}/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
}/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.
/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_urlrequiredLanding page URL to analyze (HTTPS recommended)
product_descriptionProduct/service description (helps Claude generate better angles)
nicheMarket niche (e.g., skincare, SaaS, fitness)
target_countryTarget country code (e.g., US, FR, MA)
target_languageOutput language (e.g., en, fr, ar)
brand_toneBrand voice (e.g., professional, playful, luxurious)
nameRun name for your reference
product_image_urlsOptional. Up to 5 public image URLs (jpeg/png/webp/gif, ≤15MB each). Used as visual references for image generation. SSRF-protected.
product_images_base64Optional. 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": []
}/api/v1/runs
List your pipeline runs with pagination and optional status filter.
Query Parameters
limitResults per page (default: 20, max: 100)
offsetPagination offset (default: 0)
statusFilter 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"/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"
}
]
}/api/v1/runs/:id/media/:media_id/url
EnterpriseGet 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"/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 anglesAI 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"
}'instructionPlain-language description of what to change (required).
reference_image_base64Optional — for product swap. Must be valid PNG/JPEG/WebP.
reference_image_mimeOptional — 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://agent.creascale.ai/api/v1
Same cs_live_ API keys as the advertising API, different host. Endpoints on this base: /templates, /messages, /optin, /events, /webhooks.
Prerequisites
WhatsApp connectedYour number is connected via the Agent tab (official Meta Cloud API). No number = 409 no_whatsapp_channel.
Templates approvedTemplates are created and approved in Meta Business Manager (a payment method must be attached to your WABA — Meta refuses all templates without one).
Feature enabledtemplates_enabled is on for your workspace. Off = 409 templates_disabled on every send.
BillingCreaScale 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.
/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://agent.creascale.ai/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
torequiredCustomer phone in international digits (E.164 without +).
templaterequiredTemplate name, exactly as approved in Meta.
languagerequiredTemplate language code (fr, ar, en, …) — must match an approved variant.
body_paramsFlat array of body variables, in {{1}}, {{2}}, … order.
header_paramsHeader variables, if the template has a text header.
button_url_paramDynamic URL suffix, if the template has a dynamic-URL button.
quick_reply_payloadsPayloads 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 · reason | Meaning |
|---|---|
| 400 (Idempotency-Key) | Header missing — it is required (1-255 chars), never optional. |
| 400 invalid_body / invalid_field | Body is not a JSON object, or a field has the wrong type. |
| 400 unknown_fields | Unsupported keys (e.g. Meta's components) — listed in the response. |
| 400 body_params_count_mismatch | Wrong number of body variables — expected/received counts returned. |
| 409 templates_disabled | The templates feature is not enabled on your workspace. |
| 409 template_unknown | No template with this name/language on your account — run POST /templates/sync. |
| 409 template_not_approved | Status is pending / rejected / paused — only approved templates can be sent. |
| 409 optin_missing | No active consent for this customer (or revoked by a STOP). |
| 409 marketing_optin_missing | Consent exists but is not an explicit marketing opt-in. |
| 409 daily_cap_reached | Your configured daily send cap is reached — resumes tomorrow. |
| 409 monthly_cap_reached | Your plan's monthly send volume is reached (Pro 10,000 · Scale 100,000 · Enterprise unlimited) — resumes next month. |
| 409 free_cap_reached | The account's free allowance is used up. It is a lifetime total and never resets: upgrade to keep sending. |
| 409 plan_required | No active subscription on the account. Pick a plan to send WhatsApp messages. |
| 409 free_trial_exhausted | Lifetime trial allowance is used up — pick a paid plan to keep sending. |
| 409 no_whatsapp_channel | No WhatsApp number is connected on the account — connect one in the Agent tab. |
| 409 outside_send_hours | Outside the sending hours you configured (evaluated in your workspace timezone). |
| 409 no_whatsapp_channel | No 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 agent.creascale.ai, 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://agent.creascale.ai/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_templateUtility template sent when a Shopify order is created.
order_confirm_textFree-text confirmation for the non-official rail. Max 1024 chars.
shipping_confirm_templateTemplate sent when the order becomes fulfilled.
order_followup_textGeneric follow-up after the customer replies (24h window). Max 1024.
followup_confirmed_textFollow-up when the customer tapped the confirm button.
followup_cancelled_textFollow-up when the customer tapped the cancel button.
sheet_watch_enabledWatch a Google Sheet — a new row triggers a send. Opt-in: false clears it.
sheet_watch_urlGoogle Sheet link shared for reading. Only docs.google.com is accepted; any other host is refused.
sheet_watch_templateTemplate sent to new Sheet rows.
sheet_watch_mapColumn 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://agent.creascale.ai/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_fieldsA 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_valuesA 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_patchThe 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 / authenticationA customer who has messaged your WhatsApp number has one automatically (reply-only model). A STOP revokes it — permanently, until the customer opts back in.
marketingMust 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.
/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://agent.creascale.ai/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
toCustomer phone (E.164, with or without +) or Instagram IGSID. The most recent conversation with that customer on the channel is used.
conversation_idAlternative to to: target one thread precisely (from GET /conversations or the message.received webhook).
textrequiredUp to 900 characters.
channel"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_conversationThis 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_windowMore than 24 h since the customer's last message. meta_error carries Meta's text verbatim.
409 monthly_cap_reachedTexts 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_failedAny other Meta refusal (expired token, blocked account…) with meta_error and meta_code. Nothing was sent.
42930 texts per minute per key, under the global 60 req/min.
/api/v1/messages/media
Pro+Send a photo, a video, a document or an audio file: you pass a publicly reachable HTTPS URL, and there is nothing for you 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.
Changed on 2026-09-17. We used to hand your link to Meta, which fetched it itself. Measured on a customer account: a delivered voice note turned into “This audio is no longer available” on the recipient's phone after about 3 h 30 — while the link still answered 200 and had been fetched exactly once. Meta only promises retention (30 days) for uploaded media; for a link, its documentation promises nothing. So we now fetch your link once and upload the bytes to Meta, then send the message by media object id. Your request does not change — you still send link. Your link only needs to be reachable for the duration of the call (12 s).
curl -X POST https://agent.creascale.ai/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
toCustomer phone (E.164, with or without +). Alternative: conversation_id, to target one thread precisely.
typerequired"image" · "video" · "document" · "audio".
linkrequiredPublicly reachable HTTPS URL, up to 2048 characters. We fetch it once, at call time. HTTPS is required on the whole path: a redirect that leaves HTTPS is refused — your file is often a customer's voice note, and cleartext makes it both readable and substitutable in transit.
captionImage and video only, up to 900 characters. Meta's limitation, not ours.
filenameDocument only, and required there — without it your customer sees a truncated URL.
voiceAudio only. true = delivered as a WhatsApp voice note (plays inline, waveform, auto-download) instead of an audio file to download. Meta requires an Ogg file encoded with OPUS, mono; the play button only shows at 512 KB or less. false or omitted = audio file, as before. Since we upload, we also check the container before sending: bytes that are not Ogg/OPUS mono return 400 media_voice_format_invalid, rather than letting Meta accept them and silently degrade the message.
What Meta accepts, per type
imagecaption: yes · filename: no
videocaption: yes · filename: no
audiocaption: no · filename: no · voice: yes (Ogg/OPUS mono)
documentcaption: no · filename: required
How long the call takes, and what timeout to set
The download and the upload happen inside your request, so this call is noticeably longer than on the old rail. Measured against Meta on 2026-09-18, 5 runs on a 19 KB file: DNS 2 ms, reading your link 47 ms (median), uploading to Meta 2.1 s (median, 3.4 s worst). The dominant cost is the upload, and for small files it is almost independent of size — it is a floor of Meta's endpoint, not a cost proportional to your file.
preparationReading your link + uploading to Meta. Past it: 504 media_timeout.
sendingHanding the message to Meta, with bounded retries.
total ceilingSet your client timeout to 90 s at least. 20 s suited the old rail and no longer does.
Going past your own timeout is not an unknown result. Use Idempotency-Key and replay the same key: you get the original response (duplicate: true), or 409 idempotency_key_in_flight (wait, replay), or a fresh send if the first one failed. Never two messages. It is resending by hand that creates the duplicate, not replaying.
Response
message_idOur id for this send. GET /messages/{id} accepts it, and message.* acknowledgements carry it.
provider_message_idThe Meta wamid — the key every delivery acknowledgement quotes.
media_idThe media object id at Meta, kept 30 days. Verifiable with your own WABA token: GET /v21.0/{media_id} returns mime_type, file_size and sha256. null when the media went out by link.
delivery"uploaded" in the nominal case. "link" only above 16 MB — which can only happen with a document: the upload does not fit in one synchronous request, so the media goes out by link as before and its copy at Meta becomes perishable again. Assert this field if your customers must be able to reopen the file hours later.
Refusals you can now see
400 media_link_blockedURL we refuse to fetch: scheme, private IP, cloud metadata endpoint, too many redirects, or a redirect that leaves HTTPS. Permanent — fix the URL, do not replay.
400 media_too_largeBeyond Meta's size limit for that type. detail carries the size we read. Permanent.
400 media_voice_format_invalidvoice: true on bytes that are not Ogg/OPUS mono. Permanent.
502 media_link_unreachableYour host did not answer, or did not answer 2xx, within 12 s. Replayable.
502 media_upload_failedMeta refused the upload. meta_error and meta_code are Meta's own. Replayable.
504 media_timeoutPreparation went past its 20 s ceiling. Replayable — with the SAME Idempotency-Key.
503 media_busyToo many media bytes already being read on our server. We hold your bytes in memory while uploading them, so we cap it — a named refusal beats a saturated server. Replayable in a few seconds.
The 400s and the 503 are our refusals, not Meta's — so they never carry a meta_error field. We do not sign “Meta” an error Meta never said.
/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://agent.creascale.ai/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_linkNot HTTPS, unparseable, or over 2048 characters.
400 caption_not_supportedCaption on an audio or a document. Meta's rule — refused here rather than as an opaque (#100) from Graph.
400 filename_requiredA document without a filename reaches your customer as a truncated URL.
400 invalid_voicevoice is not a boolean. It is never silently ignored.
400 voice_not_supportedvoice: true on a type other than audio.
400 invalid_channelInstagram is not served: no document, no audio, no caption there.
409 outside_service_windowMore than 24 h since the customer's last message. Media is a service message, exactly like free text. Send a template.
409 monthly_cap_reachedMedia 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_expiredMeta no longer serves this media: webhook media ids expire after 7 days.
413 media_too_largeAbove Meta's limit for that type. Refused before downloading.
502 send_failedMeta refused the send right away. Its error is returned verbatim.
message.failedMeta accepted the link (200) and refused the file when it fetched it: link unreachable, wrong type or encoding, too large. Arrives as a message.failed event with error_code (typically 131053) and error_details.
42930 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 sizeGET /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"
}For an inbound message, id is the message_id of its message.received webhook, and at is the same instant: the WhatsApp timestamp (messages received since 2026-09-17). Deduplicate webhook and history on message_id, or on provider_message_id, present on both sides.
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 · SyncPOST /templates/sync, then GET /templates — your order-confirmation template must be status approved.
2 · Opt-inBefore the first send to a customer, POST /optin/import with the dated declaration (for utility: an inbound WhatsApp message from them also counts).
3 · SendPOST /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 · Track202 ≠ 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 · ReceiveSubscribe to message.received: the customer's reply, including the tapped button payload (CONFIRM:8832) and the conversation_id, lands on your endpoint.
6 · ActRoute 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 fastReply 2xx immediately, process async. Failed deliveries are retried up to 20× with backoff — a slow handler manufactures duplicates upstream.
DeduplicateStore processed (event, send_id / message id) pairs and skip repeats — retries WILL redeliver events you already handled.
Verify, fail closedVerify X-CreaScale-Signature on the RAW body (see Webhooks below). If your webhook secret env is missing, return 401 — never 200.
Kill-switchGate the whole rail behind one env var (e.g. CREASCALE_ENABLED) so you can stop it without redeploying.
One voiceIf 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);
// A retry of the SAME delivery carries the same X-CreaScale-Delivery id: dedupe on it.
// (To match a message against GET /conversations/{id}/messages, use evt.data.message_id.)
const key = req.headers['x-creascale-delivery'];
if (!key) return res.sendStatus(400);
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.customer);
}
});
});Meta Ads API
Pro+Everything the Meta Ads tab does, from your own system: connect a Meta access (a system user token from the client's Business), attach ad accounts (a media buyer manages every client from one CreaScale account), read each account's tree with its KPIs and a verdict per ad against your breakeven CPA, launch a campaign, add an ad set to a running campaign or ads to an ad set, pause and rescale. Tokens are stored encrypted and never returned.
Base URL: https://agent.creascale.ai/api/v1/ads
Same cs_live_ API keys, from the Pro plan (one connection, one ad account). Enterprise attaches up to 25 accounts, reads the portfolio, writes the whole account logic and drives the same calls from Claude through the CreaScale MCP connector. The subscription is re-read on every call (403 pro_required or enterprise_required otherwise).
Endpoints
GET /ads/accountsConnections and attached accounts (labels, Page, pixel, Instagram, readiness and blockers, last sync).
POST /ads/loginsConnect a pasted token { access_token, label? }. Meta validates it first (GET /me). 201 new connection, 200 same Meta user reconnected (token replaced). One ad account + one Page visible = attached automatically.
GET /ads/logins/:id/assetsBusinesses, ad accounts, Pages, pixels this token sees now, plus the ad accounts already attached.
DELETE /ads/logins/:idRemove the connection and its accounts; the token is purged from the vault.
POST /ads/accountsAttach an account { login_id, ad_account_id, page_id, pixel_id?, instagram_id?, business_id?, label? }. Every id is re-checked against the token's inventory (409 account_already_attached, max_accounts_reached).
PATCH /ads/accounts/:idChange page_id, pixel_id, instagram_id, business_id or label. The ad account itself never changes.
DELETE /ads/accounts/:idDetach the account (nothing is deleted in Meta).
POST /ads/accounts/:id/pixelCreate (or pick up) the account's pixel. 409 pixel_exists.
POST /ads/accounts/:id/health/refreshRe-read account_status, disable_reason and payment method in Meta. 409 token_invalid | account_access_refused.
GET /ads/accounts/:id/insights?level=campaign|adset|ad&days=7|30, from CreaScale's table (synced every 6 h), never Meta live. accountSyncedAt: null = never synced.
POST /ads/accounts/:id/insights/refreshSync the last 30 days now. 503 meta_unavailable writes nothing.
POST /ads/ad-accountsCreate an ad account when the token sees none { login_id, business_id?, page_id?, currency?, pixel? }. Meta allows one account until a payment is confirmed; MAD/TND/KWD are not ad-account currencies (USD by default).
Breakeven CPA, KPIs and verdicts
Set a breakeven CPA per account, then every ad and ad set gets a verdict, just maths: an ad is cut when its spend reaches the CPA times (results + 1), so 0 result at 1 × the CPA is off, 1 result waits until 2 ×, and so on. Verdicts are suggested, never applied: pausing or rescaling stays your call.
GET /ads/accounts/:id/logicThe account logic with its defaults: breakeven CPA, cut tolerance, scale rules, chosen KPIs. targetEditable: this plan can set the breakeven CPA (every paid plan); editable: it can write the rest (Enterprise).
PUT /ads/accounts/:id/logicPartial write, merged with the stored logic: { target_cpa_minor } on every paid plan, the rest on Enterprise. 400 invalid_logic with errors[].
GET /ads/accounts/:id/kpis?days=7|30&level=campaign|adset|ad, the account's KPI set, in total and per entity, from CreaScale's table.
GET /ads/accounts/:id/tree?days=7|30&refresh=1, campaigns, ad sets and ads (also those created outside CreaScale), KPIs per node and a verdict per ad set and ad. acceptsAdset per campaign and accepts per ad set say what can be added there. refresh=1 re-reads the inventory from Meta first.
GET /ads/portfolioEnterprise. One row per attached account: KPIs in its own currency, breakeven CPA, verdict counts, live campaigns. From CreaScale's table, never Meta live; an unreadable account comes back with error: read_failed, never zeros.
Launch and manage campaigns
A launch is a job: the spec is validated and normalized first (400 invalid_spec with one code per fault), then the campaign, the ad set and the ads are created paused, each object is read back from Meta, and everything is activated bottom-up (ads → ad set → campaign). Budgets are in minor units of the account currency(1500 = 15.00 USD). The account must be ready (active connection, active account, payment method).
POST /ads/accounts/:id/campaignsLaunch { objective: sales | messages, daily_budget, link?, destination?, targeting: { countries, age_min?, age_max?, … }, ads: [{ image_url | image_hash | video_url | video_id | post_id | media_ref, primary_text, headline?, description?, link?, cta? }], utm?, idempotency_key?, draft_token? }. 202 queued; 200 replay on the same idempotency_key; 409 account_not_ready. With adset_id: ads added to an existing ad set, nothing else changes. With campaign_id: a new ad set (budget, targeting, ads) in an existing campaign that has no campaign budget (409 campaign_cbo otherwise). Targeting the EU: the new ad set carries a beneficiary and a payer (dsa_beneficiary, dsa_payor; else the ad account's saved defaults, else the Page name). A read-only ad account fails with account_read_only before anything is written.
POST /ads/accounts/:id/campaigns/draftSame body as a launch. Checks everything (spec, landing page, media) and writes nothing at Meta: the recap, the checks and a draft_token valid 30 min. Pass it to the launch to launch exactly what was checked.
POST /ads/media/sign{ content_type, bytes }, a signed upload URL into a private bucket (video up to 50 MB, image up to 10 MB) and its storage: ref, to send as media_ref.
GET /ads/media/creascaleThe 20 latest CreaScale static ads of your account, usable as image_url.
GET /ads/launches/:idStatus queued | running | done | failed, steps[], campaignId / adsetId / adIds as soon as they exist, errorCode (meta_unavailable, meta_refused, token_invalid, lp_unreachable, verify_failed, …), retryable.
POST /ads/launches/:id/retryRetry a failed launch when retryable: nothing already created is recreated. 409 launch_not_retryable.
GET /ads/accounts/:id/launchesThe 50 most recent launches of the account.
GET /ads/accounts/:id/objects?kind=campaign|adset|ad|creative|image|video, objects CreaScale knows for the account. ?refresh=1 re-reads status / effective_status from Meta (≤ 50 per call). 503 meta_unavailable.
POST /ads/objects/:id/status{ status: ACTIVE | PAUSED } on a campaign, an ad set or an ad, written then read back. 409 status_not_applied.
PATCH /ads/objects/:id/budget{ daily_budget_minor } on an ad set, written then read back. 409 budget_not_applied.
DELETE /ads/objects/:idDelete a campaign in Meta (status DELETED read back); its known ad sets and ads follow.
curl -X POST https://agent.creascale.ai/api/v1/ads/accounts/<account id>/campaigns \
-H "Authorization: Bearer cs_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"objective": "sales",
"daily_budget": 1500,
"link": "https://your-store.com/pages/offer",
"targeting": { "countries": ["MA"], "age_min": 18, "age_max": 45 },
"ads": [
{ "image_url": "https://your-cdn.com/creative-1.jpg", "primary_text": "Your text", "headline": "Your headline", "cta": "SHOP_NOW" }
]
}'
# then, until status is done or failed:
curl https://agent.creascale.ai/api/v1/ads/launches/<launch id> \
-H "Authorization: Bearer cs_live_your_key_here"Connect a token, then list accounts
curl -X POST https://agent.creascale.ai/api/v1/ads/logins \
-H "Authorization: Bearer cs_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{ "access_token": "<system user token>", "label": "Client A" }'
curl https://agent.creascale.ai/api/v1/ads/accounts \
-H "Authorization: Bearer cs_live_your_key_here"The system user token must carry ads_management, ads_read, business_management, pages_show_list and pages_read_engagement, with the ad accounts, Pages and pixels assigned to that system user. Server-side purchases go through the CAPI relay described in the Meta Ads tab (with pixel_id when several accounts are attached).
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.completedPipeline run finished successfully (images + ad copies ready)
run.failedPipeline run failed
angles.readyAngles generated, run paused for review
angles.addedNew AI angles added to a run
angles.validatedAngles validated, generation starting
images.completedImages generated, ad copy in progress
media.ai_editedAn existing media was edited via POST /runs/:id/media/:mid/ai-edit
template.approvedMeta approved a template — it becomes sendable
template.rejectedMeta rejected a template — the reason ships with the event
message.acceptedSend accepted by Meta (wamid assigned), template or free message — NOT delivered yet
message.deliveredDelivered to the customer's phone
message.readRead (only if the customer has read receipts on)
message.failedDefinitive send failure: error (label), error_code (Meta code, e.g. 131053 for a media refused after acceptance) and error_details
message.receivedThe customer REPLIED: text, media or button tap. Carries message_id (the history id) and at (the WhatsApp timestamp)
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 20 times: the delay doubles from 30 s (30 s, 60 s, 120 s…) and is capped at 6 h, about 62 h of automatic retries in total. Any 2xx from your endpoint stops the series; after the 20th failure the delivery is marked as failed. Every retry carries the same X-CreaScale-Delivery id.
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 agent.creascale.ai 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
urlrequiredHTTPS endpoint, 10–2048 chars. Private, loopback and link-local addresses are refused (SSRF protection).
eventsWhich events to receive. Defaults to ["run.completed", "run.failed"]. Names come from the list above.
curl -X POST https://agent.creascale.ai/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
400Non-HTTPS URL, or an address that fails the SSRF check.
400Unknown 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.
400Maximum 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://agent.creascale.ai/api/v1/webhooks/WEBHOOK_ID \
-H "Authorization: Bearer cs_live_your_key"Rate Limits
| Limit | Value |
|---|---|
| Per API key (global) | 60 requests / minute |
| Create run / relaunch | 10 / minute |
| Generate creatives | 5 / minute |
| Batch duplicate / Add angles | 3 / minute |
| AI image edit | 10 / minute · 3 credits (standard) / 15 (premium) |
| Static ad credits | draft 3 / standard 5 / premium 9 per image · 10 / minute |
| New run credits | 30 credits |
| Relaunch credits | 12 credits |
| Batch duplicate max | 50 copies / call |
| Max API keys | 3 per account |
| Max webhooks | 5 per account |
Error Codes
| Code | Meaning |
|---|---|
| 400 | Bad request — invalid parameters or URL |
| 401 | Invalid API key — missing, malformed, or revoked |
| 402 | Insufficient credits — recharge or upgrade plan |
| 403 | Plan 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 |
| 404 | Not found — run or webhook doesn't exist or isn't yours |
| 429 | Rate limited — max 60 requests per minute per key |
| 500 | Internal error — retry or contact support |
Error Response Format
{
"error": "Insufficient credits",
"credits_remaining": 10,
"credits_required": 30
}