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.
/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
promptrequiredWhat to advertise — product/offer description, or paste a product URL (≥ 10 chars, ≤ 1000).
quality"draft" (3 cr), "standard" (5 cr, default) or "premium" (9 cr). Premium is Pro+ — clamped to standard on lower plans.
countNumber of distinct ad images (default 1). Capped per plan: Pro 20 / Scale 50 / Enterprise 100. Total cost = per-image price × count.
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 '{
"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
}/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
}/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 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 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 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://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 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://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
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 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_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 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_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://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_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://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
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 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
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. Meta fetches it — keep it reachable.
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.
What Meta accepts, per type
imagecaption: yes · filename: no
videocaption: yes · filename: no
audiocaption: no · filename: no
documentcaption: no · filename: required
/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_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_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 could not read your link (404, auth required, unsupported type). Its error is returned verbatim.
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"
}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 5× 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);
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.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.acceptedTemplate send accepted by Meta (wamid assigned) — NOT delivered yet
message.deliveredDelivered to the customer's phone
message.readRead (only if the customer has read receipts on)
message.failedDefinitive send failure, with the Meta error code
message.receivedThe 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
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://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
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://creascale-agent-api.onrender.com/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
}