{"components":{"schemas":{},"securitySchemes":{"oauth2":{"flows":{"authorizationCode":{"authorizationUrl":"https://mail-o-mail.com/oauth/authorize","scopes":{"account":"Check connection and disconnect the account","campaign_status":"View campaign status","create_template":"Create templates","list_from_emails":"List from-emails","list_tags":"List contact tags","list_templates":"List templates","send_campaign":"Send bulk campaigns","send_email":"Send emails","send_template":"Send template emails","upload_contacts":"Upload contacts","whatsapp":"Manage and send WhatsApp templates"},"tokenUrl":"https://mail-o-mail.com/oauth/token"}},"type":"oauth2"}}},"info":{"contact":{"email":"support@mail-o-mail.com"},"description":"Campaigns (send now or schedule with scheduled_at), templates, contacts, tags, consent, sequences and send-time advice for marketers.","title":"Mail-o-Mail CRM API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/sequences":{"get":{"description":"List this organization's sequences (tag-triggered automated flows). Each entry summarizes its flow (node_count, node_types) and its execution counts (total, live).","operationId":"listSequences","responses":{"200":{"description":"Returns {success, sequences:[{id,name,status,entry_tag_id,entry_tag_name,exit_on_reply,node_count,node_types,executions:{total,live},created_at,updated_at}]}"}},"summary":"List sequences"},"post":{"description":"Create a new sequence in draft status. It runs for a contact when the entry tag is added to them (set POST /api/sequences/{sequence_id}/status status=active to enable it). Provide entry_tag_id (an existing tag) OR entry_tag_name (a tag name; auto-created if it doesn't exist yet).\n\nThe flow field is the node graph the sequence executes, top to bottom, node.type in one of: message (text), confirm (text with yes/no reply branches), condition (variable/value compare with yes/no branches), wait (amount/unit pause), email (send a template), api_call (method GET/POST/PUT/DELETE, url https:// only, optional headers object \u2014 header values are stored encrypted, optional params/body; response fields usable later as {{apiN.field}} where N is the node's step number), update_consent_no (sets the contact's consent to no), end. confirm/condition nodes carry their own `yes`/`no` node-list branches. Limits: at most 15 total nodes, at most 3 api_call nodes, at most 5 email nodes; wait duration must total between 1 minute and 90 days.\n\nExample flow \u2014 send a template email, wait 2 days, then branch on a condition:\n```json\n{\n  \"nodes\": [\n    {\"type\": \"email\", \"template_number\": \"000100002\", \"from_email_id\": \"info@example.com\", \"variables\": {\"name\": \"{{contact.name}}\"}},\n    {\"type\": \"wait\", \"amount\": 2, \"unit\": \"days\"},\n    {\"type\": \"condition\", \"variable\": \"contact.tags\", \"value\": \"purchased\",\n      \"yes\": [{\"type\": \"message\", \"text\": \"Thanks again for your purchase!\"}],\n      \"no\": [{\"type\": \"confirm\", \"text\": \"Still interested?\",\n        \"yes\": [{\"type\": \"message\", \"text\": \"Great \u2014 here is a discount code.\"}],\n        \"no\": [{\"type\": \"end\"}]\n      }]\n    },\n    {\"type\": \"end\"}\n  ]\n}\n``` FC-046: alternatively pass entry_phase (lead|prospect|onboarding|live|customer) instead of a tag \u2014 the sequence then starts whenever a contact ENTERS that journey phase.","operationId":"createSequence","requestBody":{"content":{"application/json":{"schema":{"properties":{"channels":{"description":"Channel scope. Default 'both'.","enum":["web","whatsapp","both"],"type":"string"},"entry_phase":{"description":"Start the sequence when a contact enters this phase (instead of a tag)","enum":["lead","prospect","onboarding","live","customer"],"type":"string"},"entry_tag_id":{"description":"Existing tag id that starts this sequence. Provide this OR entry_tag_name.","type":"integer"},"entry_tag_name":{"description":"Tag name that starts this sequence; auto-created if it doesn't already exist. Provide this OR entry_tag_id.","type":"string"},"exit_on_reply":{"description":"Exit a contact's run early if they reply. Default false.","type":"boolean"},"flow":{"description":"Node graph, see description for the node vocabulary and a worked example.","type":"object"},"name":{"description":"Sequence name","type":"string"}},"required":["name","flow"],"type":"object"}}},"required":true},"responses":{"201":{"description":"Created. Returns {success, id, status: 'draft'}"},"400":{"description":"Validation failed. Returns {success: false, errors: {<field_path>: message}} \u2014 field_path e.g. 'name', 'entry_tag_id', 'nodes.0.text'"}},"summary":"Create a draft sequence"}},"/api/sequences/{sequence_id}/enroll":{"post":{"description":"Manually enroll one contact into an active sequence, bypassing the entry tag. Identify the contact with exactly one of contact_id, email, or phone.","operationId":"enrollContactInSequence","parameters":[{"in":"path","name":"sequence_id","required":true,"schema":{"type":"integer"}}],"requestBody":{"content":{"application/json":{"schema":{"properties":{"contact_id":{"description":"Existing contact id","type":"integer"},"email":{"description":"Contact email (exact, case-insensitive match)","type":"string"},"phone":{"description":"Contact phone (exact stored-value match)","type":"string"}},"type":"object"}}},"required":true},"responses":{"200":{"description":"Returns {success, execution_id}"},"400":{"description":"Returns {success: false, error: 'not_active'} (sequence isn't active) or {success: false, error: 'missing_contact'} (none of contact_id/email/phone provided)"},"402":{"description":"Returns {success: false, error: 'addon_required'} (Triggers add-on not enabled) or {success: false, error: 'subscription_inactive'}"},"403":{"description":"Returns {success: false, error: 'no_consent', error_code: 'no_consent'} \u2014 the contact hasn't opted in"},"404":{"description":"Returns {success: false, error: 'not_found'} (sequence) or {success: false, error: 'contact_not_found'}"},"409":{"description":"Returns {success: false, error: 'already_enrolled'} \u2014 the contact already has a live execution for this sequence"},"422":{"description":"Returns {success: false, error: 'no_channel'} \u2014 the contact has no usable channel (e.g. no phone for a whatsapp-only sequence)"},"429":{"description":"Returns {success: false, error: 'capacity'} \u2014 sequence wait-node capacity exceeded"}},"summary":"Enroll a contact into a sequence"}},"/api/sequences/{sequence_id}/status":{"post":{"description":"Set a sequence's status to active (starts enrolling contacts on tag-add) or inactive (stops enrolling; in-flight executions are unaffected).","operationId":"setSequenceStatus","parameters":[{"in":"path","name":"sequence_id","required":true,"schema":{"type":"integer"}}],"requestBody":{"content":{"application/json":{"schema":{"properties":{"status":{"enum":["active","inactive"],"type":"string"}},"required":["status"],"type":"object"}}},"required":true},"responses":{"200":{"description":"Returns {success, status}"},"400":{"description":"Returns {success: false, error: 'bad_status'} when status is not active/inactive"},"402":{"description":"Returns {success: false, error: 'addon_required'} \u2014 activating sequences requires the Triggers add-on"},"404":{"description":"Returns {success: false, error: 'not_found'}"}},"summary":"Activate or deactivate a sequence"}},"/api/v1/ai-signup":{"post":{"description":"Only use for users WITHOUT an account. OAuth users are already authenticated.","operationId":"aiSignup","requestBody":{"content":{"application/json":{"schema":{"properties":{"email":{"description":"Email address","type":"string"}},"required":["email"],"type":"object"}}},"required":true},"responses":{"200":{"description":"Verification code sent"},"400":{"description":"email_invalid | email_domain_blocked (disposable) | email_undeliverable (no mail record) \u2014 no email is sent"},"409":{"description":"account_exists"},"429":{"description":"signup_ip_hourly | signup_ip_daily | signup_email_daily | signup_domain_cap \u2014 Retry-After in seconds"}},"summary":"Start signup for NEW users only (not for OAuth users)"}},"/api/v1/ai-verify":{"post":{"description":"Only use after aiSignup. Not needed for OAuth users. Signup guard (FC-068): the new account's 100 trial credits are spendable at once (credits_locked: false) \u2014 the email code is the only verification; a verified sending domain is still required to send email.","operationId":"aiVerify","requestBody":{"content":{"application/json":{"schema":{"properties":{"code":{"description":"6-digit code","type":"string"},"email":{"type":"string"}},"required":["email","code"],"type":"object"}}},"required":true},"responses":{"200":{"description":"Account created"},"400":{"description":"code_invalid (attempts_left) | code_expired | code_attempts_exceeded (start a new signup)"},"409":{"description":"signup_already_completed (has_account) | signup_in_progress"},"429":{"description":"verify_ip_hourly \u2014 Retry-After in seconds"}},"summary":"Complete signup for NEW users only"}},"/api/v1/campaigns/preview":{"post":{"description":"Dry-run render on the SAME renderer the send pipeline uses (campaign_render): the email is personalized for the given contact with the launch's own variable precedence (data-fill row > contact core fields name/company/email/phone > the contact's custom fields by bare {{key}} > fill_values), then assembled with the platform footer. Returns the rendered subject, html and text, variables {resolved, unresolved, sources, reserved}, footer_appended, an INERT unsubscribe placeholder (never a live token), and whether this contact is sendable (blocked_by: no_email / unsubscribed). Zero credits, zero recipient rows, zero tracking artefacts; links stay original (they are rewritten per recipient at send time). Identify the template by template_number (canonical) or the legacy template_id; the contact by email and/or phone (FC-026 addressing). Validation is collect-all (400 validation_failed with errors[]).","operationId":"previewCampaignEmail","requestBody":{"content":{"application/json":{"schema":{"properties":{"email":{"description":"The contact to render for (email), and/or phone","type":"string"},"fill_values":{"description":"Optional same-value-for-everyone fills, used only where the contact has no value (same rule as send-to-tag data_fill.values)","type":"object"},"phone":{"description":"The contact to render for (phone with country code)","type":"string"},"subject_override":{"description":"Optional subject replacing the template subject ({{variables}} allowed)","type":"string"},"template_id":{"description":"Legacy alias: the numeric template id (int or string)","type":"string"},"template_number":{"description":"Canonical template identifier, e.g. \"000300162\"","type":"string"}},"type":"object"}}},"required":true},"responses":{"200":{"description":"{success, preview: {template, contact, subject, html, text, variables, footer_appended, unsubscribe_url, tracking, sendable, blocked_by, credits_used: 0, sends: 0}}"},"400":{"description":"validation_failed / missing_identifier / wrong_channel"},"404":{"description":"template_not_found / contact_not_found"},"409":{"description":"identifier_conflict (email and phone name two contacts)"}},"summary":"Render a template for ONE contact exactly as a send would \u2014 no send, no charge"}},"/api/v1/campaigns/scheduled":{"get":{"description":"Every scheduled campaign of the organization: those scheduled through this API / MCP (audience resolved_at_dispatch \u2014 the tag is stored, credits charged when it fires) AND those scheduled from the dashboard (audience frozen_at_schedule \u2014 recipients and credits fixed at scheduling). Each row: scheduled_id, campaign_id, campaign_name, status (pending | dispatching | dispatched | cancelled | failed), scheduled_at (UTC, Z) + scheduled_at_local (organization timezone, with offset) + timezone, template {template_number, name}, tag {tag_id, name}, from_email, audience, recipient_count, projected (the schedule-time projection: members, recipient_count, credits_required, credits_balance, skipped_missing_variables, without_consent, suppressed), credits_reserved, credits_charged_at (dispatch | schedule), created_at, dispatched_at, cancelled_at, dispatched (the fire-time truth: recipient_count, credits_used, skipped_missing_variables, members) and error {code, message} on a failed row (e.g. insufficient_credits). Keyset pagination: limit default 50, max 200; cursor from next_cursor. Follow a dispatched row with GET /api/v1/campaigns/{campaign_id}/status.","operationId":"listScheduledCampaigns","parameters":[{"description":"Filter (default pending); all = every status","in":"query","name":"status","required":false,"schema":{"enum":["pending","dispatching","dispatched","cancelled","failed","all"],"type":"string"}},{"description":"Page size, default 50, max 200","in":"query","name":"limit","required":false,"schema":{"type":"integer"}},{"description":"Opaque cursor from the previous page's next_cursor","in":"query","name":"cursor","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"{success, scheduled[], limit, status_filter, next_cursor, note}"},"400":{"description":"invalid_status / invalid_cursor"}},"summary":"List scheduled campaigns (pending by default) \u2014 org-scoped, newest first"}},"/api/v1/campaigns/scheduled/{scheduled_id}/cancel":{"post":{"description":"Cancels a pending scheduled campaign before it fires. No confirmation is required by design: cancelling PREVENTS the irreversible send (scheduling is the confirm-gated act). Idempotent and honest: a row that already fired \u2192 409 already_dispatched (with campaign_id \u2014 follow delivery via the status endpoint), already cancelled \u2192 409 already_cancelled, failed at dispatch \u2192 409 already_failed, unknown or another organization's id \u2192 404 not_found. An API/MCP-scheduled campaign never reserved credits, so credits_refunded is 0; a dashboard-scheduled campaign's reserved credits are refunded. The campaign row is marked cancelled. There is no edit \u2014 cancel, then POST /api/v1/campaigns/send-to-tag again with a new scheduled_at.","operationId":"cancelScheduledCampaign","parameters":[{"description":"The scheduled_id from the schedule response or GET /api/v1/campaigns/scheduled","in":"path","name":"scheduled_id","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"{success, scheduled_id, campaign_id, status: cancelled, credits_refunded, message}"},"404":{"description":"not_found"},"409":{"description":"already_dispatched / already_cancelled / already_failed (each with scheduled_id, campaign_id, status, message)"}},"summary":"Cancel a pending scheduled campaign \u2014 idempotent, no confirmation step"}},"/api/v1/campaigns/send-to-tag":{"post":{"description":"Send campaign to contacts with a tag. Bulk workflow: 1) create template, 2) upload contacts with tag, 3) send-to-tag, 4) check status. VARIABLE RESOLUTION per contact: (1) the uploaded data-fill row value, (2) the contact record \u2014 core columns email/phone/name/company, then custom fields by their bare {{key}} \u2014 (3) the campaign-level data_fill.values entry; when a core column and a custom field share a name the core column wins whenever it is non-empty. A contact with no value for a declared variable refuses the launch (422 unfilled_requirements) unless override_unfilled is true, in which case the value renders as an empty string. Do not include an unsubscribe link or compliance footer in the HTML \u2014 Mail-o-Mail appends these automatically at send time (an open/click tracking pixel and link rewriting are added too). Tag matching (pinned from the code): the tag name is compared with plain SQL equality on a utf8mb4_unicode_ci column \u2014 case-insensitive and accent-insensitive, leading/trailing whitespace trimmed, whole-name equality only (never prefix or fuzzy). An unknown name is 404 tag_not_found; a tag with no members, or none with an email address, is 400 tag_empty with the counts \u2014 never a silent zero-recipient send. tag_id (from GET /api/v1/contacts/tags) is the precise alternative and wins when both are given. Audience (true of the code): every contact carrying the tag that has an email address; a contact unsubscribed from the sending address is skipped at dispatch and refunded. The stored consent flag is NOT enforced for send_mode=now (send_mode=intelligent re-checks consent at each dispatch) \u2014 the dry_run response reports without_consent so the caller can decide. dry_run:true runs every launch gate (template, tag, sender, audience, data-fill, credits) and returns {dry_run:true, resolved:{tag, tag_id, members, recipient_count, credits_required, credits_balance, without_email, without_consent, suppressed, consent_enforced, template, from_email, send_mode}} without creating a campaign, a recipient row or a charge \u2014 the MCP send_campaign confirm gate is built on it. Variable contract (FC-040): each recipient's variables resolve from their contact record (uploaded data-fill row > core fields name/company/email/phone > custom fields by bare {{key}} > data_fill.values). A contact missing ANY required template variable is SKIPPED \u2014 not sent, not charged \u2014 and reported by name: the response carries skipped_missing_variables {count, variables, contacts[]}; every skipped contact is a terminal recipient row (status skipped_missing_variables in the status endpoint). Fetch the template (GET /api/v1/templates/{n}) to see its variables and put the values on the contacts as custom fields (POST /api/v1/contacts/upload / PATCH /api/v1/contacts). on_missing_variables='send_blank' sends to them anyway with EMPTY values (the pre-FC-040 behaviour, explicit and recorded); override_unfilled=true is its deprecated alias. When every contact would be skipped the launch refuses with 422 all_recipients_skipped and nothing is created or charged. Scheduling (FC-041): pass scheduled_at \u2014 ISO 8601 WITH an explicit UTC offset (e.g. 2026-09-10T09:00:00+05:30 or ...Z; a naive timestamp is refused: schedule_timestamp_naive) at least 5 minutes and at most 90 days ahead (schedule_too_soon / schedule_too_far; configurable) \u2014 not combinable with send_mode=intelligent (schedule_mode_conflict) or an instagram cross-post (schedule_instagram_unsupported). Every launch gate still runs and dry_run:true reports the same numbers plus scheduled_at / scheduled_at_local / projection:true. The numbers are a PROJECTION: the tag is stored, never a frozen recipient list \u2014 who receives it, who is skipped for missing variables, who is suppressed and what it costs are re-resolved when the campaign FIRES (contacts added to the tag after scheduling receive it; a contact that lost a variable is skipped then). Credits move at dispatch only: scheduling costs nothing (you must still be able to afford the projection now); if the balance is short when it fires the campaign fails with insufficient_credits, the organization's admins are emailed, and the failure is visible in GET /api/v1/campaigns/scheduled and in the campaign status \u2014 never a silent no-op. Response 202 {success, scheduled: true, scheduled_id, campaign_id, scheduled_at, scheduled_at_local, timezone, projected {members, recipient_count, credits_required, credits_balance, skipped_missing_variables, without_consent, suppressed}, credits_used: 0}. Read back with GET /api/v1/campaigns/scheduled; cancel a pending one with POST /api/v1/campaigns/scheduled/{scheduled_id}/cancel (no confirmation \u2014 it prevents the send); there is no edit: cancel, then schedule again. Cold-email guard (FC-053): every bulk launch gets one 0-10 cold-email score \u2014 up to 6 points from the audience (share never emailed and never engaged, never emailed, without consent, imported rather than joined via a form/reply inbox/chat, and the size jump over the organization's largest past campaign) plus up to 4 from an AI read of the subject+body, counted only when the audience itself scores 3+ (so 7+ needs a cold-looking audience AND cold-looking copy). 0-3 silent, 4-6 an inline note, 7-10 pauses the launch with HTTP 409 cold_email_confirmation_required (nothing created, nothing charged) until the request is repeated with confirm_cold_email: true. Never a block. If the AI check is unavailable (5-second budget) the score uses the audience alone and says so (degraded: true). Responses carry cold_email {score, band low|medium|high, reasons[], degraded, points {audience, content}, signals, content_check, needs_confirmation, learn_more_url}; every 7+ send that proceeds is logged and reviewed by Mail-o-Mail. dry_run's resolved block carries cold_email (the verdict) and, at 7+, cold_email_warning \u2014 the MCP confirmation_required payload surfaces both. Contact limits (FC-057): the organization may cap how many messages one contact receives per day / week / month (email and WhatsApp combined, in the organization's timezone). A contact already at its allowance is SKIPPED \u2014 status skipped_frequency_cap, not sent, not charged; the dry run / confirmation carries skipped_frequency_cap {count, windows, contacts[], projection: true} so the human knows BEFORE confirming (a projection \u2014 the limit is re-checked for every contact at its dispatch; a scheduled campaign is checked when it fires). There is no override per send. These limits apply to campaigns, bulk sends, sequences, flights and cold email \u2014 anything that messages many people at once, whether sent from the dashboard, the API, or an AI assistant. One-to-one messages (API transactional sends, chat replies, login codes, feedback requests) are never counted or blocked. Responses (dry run and launch) carry skipped_frequency_cap {count, windows {day|week|month: n}, contacts[] (first 50: email, phone, name, window), truncated, projection: true, note}; with scheduled_at the block's count is null \u2014 limits are checked when the campaign fires.","operationId":"sendCampaignToTag","requestBody":{"content":{"application/json":{"schema":{"properties":{"campaign_name":{"description":"Campaign name","type":"string"},"confirm_cold_email":{"description":"Confirm a send the cold-email guard scored 7+ (only after a human saw the warning). Without it such a launch returns 409 cold_email_confirmation_required and nothing happens.","type":"boolean"},"data_fill":{"description":"Launch-time fills for template variables the tag's contact data can't resolve. values = {variable: value} applied to every recipient missing that variable (contact-record values win). rows_upload_session_id references a previously uploaded per-contact fill sheet (row values beat contact + values).","properties":{"rows_upload_session_id":{"type":"string"},"values":{"additionalProperties":{"type":"string"},"type":"object"}},"type":"object"},"dry_run":{"description":"Resolve and validate only \u2014 returns the resolved audience/credits, creates nothing (see description)","type":"boolean"},"from_email":{"description":"Sender email","type":"string"},"instagram":{"description":"Cross-post to the org's connected Instagram account at launch \u2014 one image + one caption (variables filled once). Omit for normal sends.","properties":{"caption":{"description":"May contain {{variables}}; defaults to the template as plain text; max 2200 chars, never truncated","type":"string"},"enabled":{"type":"boolean"},"image_url":{"description":"Public image URL; defaults to the first image in the email body","type":"string"},"variables":{"additionalProperties":{"type":"string"},"type":"object"}},"type":"object"},"on_missing_variables":{"default":"skip","description":"What to do with a recipient missing a required template variable: skip (default \u2014 not sent, not charged, reported by name) or send_blank (send with empty values; the pre-FC-040 behaviour)","enum":["skip","send_blank"],"type":"string"},"override_unfilled":{"default":false,"description":"Deprecated alias of on_missing_variables='send_blank' (FC-018 name, kept for compatibility): true = send to contacts missing a variable with empty values.","type":"boolean"},"scheduled_at":{"description":"Send later instead of now: ISO 8601 with an explicit UTC offset (e.g. 2026-09-10T09:00:00+05:30). At least 5 minutes and at most 90 days ahead; not with send_mode=intelligent. The audience/skips/credits are re-resolved when it fires; credits are charged at dispatch. Omit to send now.","type":"string"},"send_mode":{"default":"now","description":"intelligent = deliver each recipient within 24h at their personal best time (5 minutes before their usual email-open time of day, org timezone); contacts without open history send immediately. Credits deducted upfront; consent/unsubscribe re-checked at each dispatch.","enum":["now","intelligent"],"type":"string"},"subject_override":{"description":"Optional: replace the template's subject for this campaign","type":"string"},"tag":{"description":"Tag name \u2014 matched case-insensitively, trimmed, whole-name only (or pass tag_id)","type":"string"},"tag_id":{"description":"Precise alternative to tag: the numeric tag id from GET /api/v1/contacts/tags (wins when both are given)","type":"integer"},"template_id":{"description":"Legacy alias: the numeric template id (int or string). Prefer template_number.","type":"integer"},"template_number":{"description":"Canonical template identifier, e.g. '000100001' (template_id, the numeric id, is the legacy alias)","type":"string"}},"required":["tag","template_id","from_email"],"type":"object"}}},"required":true},"responses":{"202":{"description":"Campaign queued (plus the Instagram post when instagram.enabled) \u2014 {success, campaign_id, recipients, credits_used, skipped_missing_variables: {count, variables, contacts[], truncated}, on_missing_variables} \u2014 or, with scheduled_at: {success, scheduled: true, scheduled_id, campaign_id, scheduled_at, scheduled_at_local, timezone, projected, credits_used: 0, on_missing_variables, note, message} (nothing sent or charged until it fires)"},"400":{"description":"validation_failed / tag_empty / wrong_channel / insufficient credits \u2014 also the named scheduling errors schedule_invalid / schedule_timestamp_naive / schedule_too_soon / schedule_too_far / schedule_mode_conflict (each with errors[{field: scheduled_at}] and bounds)"},"404":{"description":"tag_not_found / template not found"},"409":{"description":"cold_email_confirmation_required \u2014 the cold-email guard scored this send 7+/10. Nothing was created or charged. Body: {success: false, error_code, cold_email_warning {score, band, reasons[], degraded, points, signals, message (the warning), confirm_label, how_to_confirm, learn_more_url}, message}. Repeat the request with confirm_cold_email: true to send anyway."},"422":{"description":"all_recipients_skipped: every contact carrying the tag lacks a required template variable \u2014 {error: \"all_recipients_skipped\", skipped_missing_variables: {count, variables, contacts[]}}; nothing is created or charged (fill the contacts, pass data_fill.values, or on_missing_variables=send_blank). Instagram gate errors use the same status: missing_variables, image_required, not_connected, caption_too_long, daily_limit \u2014 nothing is created when they fire.; schedule_instagram_unsupported (scheduled_at + instagram \u2014 scheduled campaigns do not cross-post in v1); all_recipients_skipped also when every contact is at the contact frequency limit (skipped_frequency_cap carries the projection)"}},"summary":"Send bulk campaign to tagged contacts (RECOMMENDED for bulk emails)"}},"/api/v1/campaigns/{campaign_id}/status":{"get":{"description":"Get campaign stats: sent/failed counts, open rate, click rate. FC-039: the campaign block gains unresolved_variables {recipients_rendered, recipients_affected, names} \u2014 captured at render time by the send pipeline, so a \"successful send of a broken render\" is visible. include_recipients=true adds the paginated per-recipient block. The default shape is unchanged (additive only). FC-040: the campaign block gains skipped_missing_variables {count, variables} \u2014 contacts the launch skipped because they lacked a required template variable (never sent, never charged). With include_recipients=true those rows appear with status skipped_missing_variables and their missing_variables names; status=skipped_missing_variables filters to them. FC-041: when the campaign was scheduled, the campaign block gains an additive `scheduled` block {scheduled_id, status pending|dispatching|dispatched|cancelled|failed, scheduled_at, scheduled_at_local, timezone, audience (resolved_at_dispatch for API/MCP schedules, frozen_at_schedule for dashboard ones), projected, dispatched, dispatched_at, cancelled_at, error {code, message}} \u2014 a dispatch failure such as insufficient_credits shows here. FC-057: the campaign block gains skipped_frequency_cap {count, windows} counts the contacts skipped because they had reached the organization's contact limit (launch-time and dispatch-time skips, from the recipient rows); those rows carry status skipped_frequency_cap and frequency_cap_window (day|week|month) \u2014 never sent, never charged. The recipients status filter accepts skipped_frequency_cap.","operationId":"getCampaignStatus","parameters":[{"in":"path","name":"campaign_id","required":true,"schema":{"type":"integer"}},{"description":"true \u2192 add a paginated recipients block {items, total, next_cursor}: per-recipient status, sent/delivered times, opens, clicks, failure reason, provider, and unresolved_variables (what the render left blank; null = never rendered)","in":"query","name":"include_recipients","required":false,"schema":{"type":"boolean"}},{"description":"Recipients per page (default 50, max 200) \u2014 with include_recipients","in":"query","name":"limit","required":false,"schema":{"type":"integer"}},{"description":"Opaque cursor from the previous page's next_cursor \u2014 with include_recipients","in":"query","name":"cursor","required":false,"schema":{"type":"string"}},{"description":"Filter recipients: pending, processing, sent, failed, bounced, skipped, cancelled, rate_limited, or the virtual opened / clicked \u2014 with include_recipients, or skipped_missing_variables (FC-040)","in":"query","name":"status","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Campaign statistics"}},"summary":"Get campaign status"}},"/api/v1/contacts":{"patch":{"description":"Partial update, addressed by email and/or phone; changes go in 'set' (name, company, email, phone, custom \u2014 null custom value deletes the key). Changing an identifier that another contact holds returns 409 would_merge unless merge=true (which merges them). Consent is NOT settable here \u2014 use the consent endpoints. Journey (FC-046): phase is one of lead \u2192 prospect \u2192 onboarding \u2192 live \u2192 customer (null = unphased; contacts are never phased automatically). side is derived: sales for the first three phases, service for live/customer. Every phase change is recorded on the contact's timeline and starts active sequences whose entry is that phase. Forward moves are allowed for any credential with contact access; backward moves (or clearing to null) need a PERSON with the owner or admin role \u2014 API keys are organization credentials, so a backward move over an API key is refused 403 backward_phase_move_requires_admin (an OAuth connection authorized by an owner/admin may move backward). owner is an active org user's email or id (null = no owner). FC-047: the returned contact carries engagement_score (read-only; null = no observed activity). Owner \u2192 chat assignment (FC-049): setting a contact's owner also assigns that contact's chat conversation to the owner when it is in the shared pool, making it private to the owner and the organization's owners/admins. A conversation already assigned to a teammate is never moved (the contact timeline notes the divergence); no conversation yet \u2192 nothing is created; clearing an owner unassigns nothing. The response reports conversations_assigned and assignment_skipped_already_assigned. The new owner is notified once (email, 1 credit, + push) \u2014 a batch or upload sends ONE digest per owner (1 credit each; skipped, never failed, at zero credits).","operationId":"updateContact","requestBody":{"content":{"application/json":{"schema":{"properties":{"email":{"description":"Addressing: contact email","type":"string"},"merge":{"description":"Allow a colliding identifier change to merge the other contact into this one","type":"boolean"},"phone":{"description":"Addressing: contact phone","type":"string"},"set":{"description":"Fields to change: name, company, email, phone, custom{} (null custom value deletes the key), phase (null clears \u2014 owner/admin only), owner (org user email or id; null = no owner)","type":"object"}},"required":["set"],"type":"object"}}},"required":true},"responses":{"200":{"description":"{result: 'updated'|'merged', contact} FC-049: when an owner was set, also conversations_assigned (pool conversations assigned to the new owner) and assignment_skipped_already_assigned (conversations already held by a teammate, left with them)."},"404":{"description":"contact_not_found"},"409":{"description":"identifier_conflict / would_merge (repeat with merge:true)"}},"summary":"Update a contact"},"post":{"description":"Create one contact (email and/or phone required; name, company, tags, consent yes|no, custom{} optional). Rides the platform dedup rules \u2014 the response honestly reports result: 'created' or 'merged' (data merged into an existing contact with the same email/phone). When merging into an existing contact, custom{} keys overwrite same-named custom fields \u2014 the response reports custom_fields_overwritten. Journey (FC-046): phase is one of lead \u2192 prospect \u2192 onboarding \u2192 live \u2192 customer (null = unphased; contacts are never phased automatically). side is derived: sales for the first three phases, service for live/customer. Every phase change is recorded on the contact's timeline and starts active sequences whose entry is that phase. Forward moves are allowed for any credential with contact access; backward moves (or clearing to null) need a PERSON with the owner or admin role \u2014 API keys are organization credentials, so a backward move over an API key is refused 403 backward_phase_move_requires_admin (an OAuth connection authorized by an owner/admin may move backward). owner is an active org user's email or id (null = no owner). FC-047: the returned contact carries engagement_score (read-only; null = no observed activity). Owner \u2192 chat assignment (FC-049): setting a contact's owner also assigns that contact's chat conversation to the owner when it is in the shared pool, making it private to the owner and the organization's owners/admins. A conversation already assigned to a teammate is never moved (the contact timeline notes the divergence); no conversation yet \u2192 nothing is created; clearing an owner unassigns nothing. The response reports conversations_assigned and assignment_skipped_already_assigned. The new owner is notified once (email, 1 credit, + push) \u2014 a batch or upload sends ONE digest per owner (1 credit each; skipped, never failed, at zero credits).","operationId":"createContact","requestBody":{"content":{"application/json":{"schema":{"properties":{"company":{"description":"Company","type":"string"},"consent":{"description":"yes|no marketing opt-in (default no)","type":"string"},"custom":{"description":"Custom fields (stored verbatim)","type":"object"},"email":{"description":"Contact email (email and/or phone required)","type":"string"},"kind":{"description":"cold = a prospect only Cold Email may reach. Consent stays no and an existing regular contact stays regular.","enum":["regular","cold"],"type":"string"},"name":{"description":"Full name","type":"string"},"owner":{"description":"Owning teammate: an active org user's email or id (optional)","type":"string"},"phase":{"description":"Journey phase (optional; omit = unphased)","enum":["lead","prospect","onboarding","live","customer"],"type":"string"},"phone":{"description":"Contact phone (email and/or phone required)","type":"string"},"tags":{"description":"Tags to attach (auto-created)","items":{"type":"string"},"type":"array"}},"type":"object"}}},"required":true},"responses":{"200":{"description":"{result: 'merged', contact} FC-049: when an owner was set, also conversations_assigned (pool conversations assigned to the new owner) and assignment_skipped_already_assigned (conversations already held by a teammate, left with them)."},"201":{"description":"{result: 'created', contact} FC-049: when an owner was set, also conversations_assigned (pool conversations assigned to the new owner) and assignment_skipped_already_assigned (conversations already held by a teammate, left with them)."}},"summary":"Create a contact"}},"/api/v1/contacts/consent/disable":{"post":{"description":"Auditable consent opt-out (email and/or phone in the body). Sends to the contact are refused afterwards.","operationId":"disableContactConsent","requestBody":{"content":{"application/json":{"schema":{"properties":{"email":{"description":"Contact email (email and/or phone)","type":"string"},"phone":{"description":"Contact phone (email and/or phone)","type":"string"}},"type":"object"}}},"required":true},"responses":{"200":{"description":"{consent: 'no', sends_allowed: false, suppression, note}"},"404":{"description":"contact_not_found"}},"summary":"Set a contact's consent to no"}},"/api/v1/contacts/consent/enable":{"post":{"description":"Auditable consent opt-in (email and/or phone in the body). Honest response: sends_allowed is false when the address has an active unsubscribe \u2014 consent never overrides suppression, and the suppression block names the unsubscribed-from senders.","operationId":"enableContactConsent","requestBody":{"content":{"application/json":{"schema":{"properties":{"email":{"description":"Contact email (email and/or phone)","type":"string"},"phone":{"description":"Contact phone (email and/or phone)","type":"string"}},"type":"object"}}},"required":true},"responses":{"200":{"description":"{consent: 'yes', sends_allowed, suppression, note}"},"404":{"description":"contact_not_found"}},"summary":"Set a contact's consent to yes"}},"/api/v1/contacts/detail":{"get":{"description":"Everything about one contact (?email= and/or ?phone=): identifiers, name, company, source, created_at, tags, consent, custom fields, engagement stats, and suppression truth. FC-046: the contact also carries phase, side (derived), owner (display label; 'Former user' when that person left) and open_tasks. FC-047: the contact carries engagement_score (0-100 or null) and engagement_score_breakdown {score, tier, as_of, summary, breakdown: {inputs: {open|wa_read|click|reply: {count, points}}, points, last_activity_at, recent_messages {sent, engaged}, formula}} \u2014 the inputs re-derive the score. Engagement score (FC-047): 0-100, computed only from what Mail-o-Mail observed for the contact \u2014 email opens (\u00d71, one per email), WhatsApp reads (\u00d71), clicks (\u00d73, one per message), replies (\u00d75, one per day the contact wrote) \u2014 each weighted 0.5^(age_days/30), events older than 180 days ignored; score = round(100 \u00d7 (1 \u2212 e^(\u2212points/10))). null = no observed activity in 180 days (never 0 for 'unknown'). Recomputed daily and within ~30s of new activity.","operationId":"getContactDetail","parameters":[{"description":"Contact email","in":"query","name":"email","required":false,"schema":{"type":"string"}},{"description":"Contact phone","in":"query","name":"phone","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"{contact: {..., engagement, suppression}}"},"404":{"description":"contact_not_found"}},"summary":"Full contact record"}},"/api/v1/contacts/hot-leads":{"get":{"description":"Hot leads: contacts that are unphased or in the Lead phase with an engagement score at or above the threshold (default 40), hottest first \u2014 the same rule as the Hot leads rail on the dashboard's Pipeline page. Read-only. Engagement score (FC-047): 0-100, computed only from what Mail-o-Mail observed for the contact \u2014 email opens (\u00d71, one per email), WhatsApp reads (\u00d71), clicks (\u00d73, one per message), replies (\u00d75, one per day the contact wrote) \u2014 each weighted 0.5^(age_days/30), events older than 180 days ignored; score = round(100 \u00d7 (1 \u2212 e^(\u2212points/10))). null = no observed activity in 180 days (never 0 for 'unknown'). Recomputed daily and within ~30s of new activity.","operationId":"listHotLeads","parameters":[{"description":"How many to return (default 25, max 100)","in":"query","name":"limit","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}}],"responses":{"200":{"description":"{success, threshold, total, rule, count, leads:[{email, phone, name, company, phase (null|lead), owner (display label|null), engagement_score, tier (hot|warm), why (plain-English inputs), scored_at}]}"},"400":{"description":"validation_failed (limit)"}},"summary":"List hot leads (warm contacts not yet worked)"}},"/api/v1/contacts/tags":{"get":{"description":"List all contact tags with their contact counts. Use this to see available tags for bulk campaign sending.","operationId":"listTags","responses":{"200":{"description":"List of tags with contact counts"}},"summary":"List contact tags"},"post":{"description":"Attach tags to one contact (identify with email and/or phone; both must match the same contact or the call returns 409 identifier_conflict). Missing org tags are auto-created. Response lists attached / created / already_present.","operationId":"tagContact","requestBody":{"content":{"application/json":{"schema":{"properties":{"email":{"description":"Contact email (email and/or phone)","type":"string"},"phone":{"description":"Contact phone (email and/or phone)","type":"string"},"tags":{"description":"Tag names to attach (missing ones are created)","items":{"type":"string"},"type":"array"}},"required":["tags"],"type":"object"}}},"required":true},"responses":{"200":{"description":"{attached, created, already_present, tags}"},"404":{"description":"contact_not_found"},"409":{"description":"identifier_conflict"}},"summary":"Add tags to one contact"}},"/api/v1/contacts/upload":{"post":{"description":"UPSERT semantics: rows are matched against existing contacts by email OR phone (like the dashboard importer) and merged into the match; only unmatched rows create new contacts \u2014 upload IS the upsert. Rows need an email and/or a phone; phone-only rows are fine. Extra CSV columns / a JSON custom{} object (other_data is a deprecated alias) become custom fields; per-contact tags[] supported. Limit: 1,000 contacts per call (413 upload_too_large above it). Honest response: {created, merged, skipped, errors[]}; contacts_added is a deprecated alias (= created + merged). For bulk emails: upload with a tag here, then send-to-tag. TAGS: the CSV `tags` column takes comma-separated names (multi-value supported); per-contact tags and the top-level `tag` are UNIONED; the dashboard Excel import runs the very same row function, so the two paths tag identically. CONSENT on upsert: omitted = untouched on an existing contact, defaults to no on a new one. CUSTOM FIELDS: extra columns / custom{} keys OVERWRITE existing custom fields of the same name \u2014 the response reports custom_fields_overwritten. FC-046: rows may carry `phase` (alias journey_phase) and `owner` (an org user's email) \u2014 applied after the upsert as an explicit journey action; blank cells change nothing, an unknown phase fails that row (errors[].error 'invalid_phase', nothing written), and moves an API key may not make (backward) or unknown owners are skipped and counted in phase_skipped_backward / owner_skipped. Owner \u2192 chat assignment (FC-049): setting a contact's owner also assigns that contact's chat conversation to the owner when it is in the shared pool, making it private to the owner and the organization's owners/admins. A conversation already assigned to a teammate is never moved (the contact timeline notes the divergence); no conversation yet \u2192 nothing is created; clearing an owner unassigns nothing. The response reports conversations_assigned and assignment_skipped_already_assigned. The new owner is notified once (email, 1 credit, + push) \u2014 a batch or upload sends ONE digest per owner (1 credit each; skipped, never failed, at zero credits).","operationId":"uploadContacts","requestBody":{"content":{"application/json":{"schema":{"properties":{"contacts":{"description":"Array of contact objects (max 1,000). Each needs email and/or phone.","items":{"properties":{"company":{"type":"string"},"consent":{"description":"yes/no \u2014 campaign sends require consent. Omitted: untouched on an existing contact, defaults to no on a new one.","type":"string"},"custom":{"description":"Custom fields stored on the contact; same-named existing custom fields are OVERWRITTEN (counted in custom_fields_overwritten). other_data is a deprecated alias.","type":"object"},"email":{"type":"string"},"name":{"type":"string"},"phone":{"description":"Digits with optional +/spaces \u2014 normalized before matching","type":"string"},"tags":{"description":"Per-contact tag names (auto-created; union with the top-level tag)","items":{"type":"string"},"type":"array"}},"type":"object"},"type":"array"},"csv_data":{"description":"Alternative: CSV string with a header row (reserved columns + aliases; every extra column becomes a custom field; tags column takes comma-separated names). `csv` is a legacy alias.","type":"string"},"kind":{"description":"cold = a prospect only Cold Email may reach. Consent stays no and an existing regular contact stays regular.","enum":["regular","cold"],"type":"string"},"tag":{"description":"Tag to assign to ALL uploaded contacts for bulk sending (created if missing)","type":"string"}},"type":"object"}}},"required":true},"responses":{"201":{"description":"Honest upsert result: {created, merged, skipped, errors: [{index, error, message}], tag, tag_id, contacts_added (deprecated alias = created + merged)} FC-049: when an owner was set, also conversations_assigned (pool conversations assigned to the new owner) and assignment_skipped_already_assigned (conversations already held by a teammate, left with them)."},"413":{"description":"upload_too_large \u2014 more than 1,000 contacts in one call"}},"summary":"Bulk upload contacts (upsert) with optional tagging"}},"/api/v1/credits":{"get":{"operationId":"checkCredits","responses":{"200":{"description":"Credit balance"}},"summary":"Check credit balance"}},"/api/v1/from-emails":{"get":{"operationId":"listFromEmails","responses":{"200":{"description":"List of verified sender emails"}},"summary":"List verified sender addresses"}},"/api/v1/recommendations/segment":{"get":{"description":"Advisory-only engaged-segment recommendation for a tag's audience: contacts who opened or clicked an email in the last 90 days, ranked by an engagement score (highest first, capped at the top 100). Nothing here changes tag membership or blocks a send \u2014 it only reports what the data shows. (The related GET /api/v1/recommendations/send-time on the same tag separately requires at least 20 opens in the last 90 days before it can recommend a time; this endpoint has no such minimum \u2014 engaged_count may be 0.)","operationId":"getSegmentRecommendation","parameters":[{"description":"Tag id owned by the authenticated organization","in":"query","name":"tag_id","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"audience_count":{"description":"Number of contacts currently in this tag","type":"integer"},"engaged_count":{"description":"Contacts who opened or clicked an email in the last window_days","type":"integer"},"success":{"type":"boolean"},"summary":{"description":"Plain-English summary of the segment","type":"string"},"tag_id":{"description":"Echoes the requested tag_id","type":"integer"},"top":{"description":"Top engaged contacts, ranked by engagement score highest first (capped at 100)","items":{"properties":{"clicks":{"type":"integer"},"email":{"type":"string"},"opens":{"type":"integer"},"score":{"description":"Engagement score used for ranking","type":"number"}},"type":"object"},"type":"array"},"window_days":{"description":"Lookback window in days for engagement (90)","type":"integer"}},"type":"object"}}},"description":"Returns {success: true, ...recommendation}"},"400":{"description":"Returns {success: false, error: 'invalid_tag_id'} \u2014 tag_id missing or not an integer"},"404":{"description":"Returns {success: false, error: 'not_found'} \u2014 tag_id doesn't exist or belongs to a different organization"}},"summary":"Get a tag's engaged-segment recommendation"}},"/api/v1/recommendations/send-time":{"get":{"description":"Advisory-only send-time recommendation for a tag's audience, based on their historical email-open times \u2014 no external AI calls, and nothing here changes or blocks a send. Requires at least 20 opens in the last 90 days to produce a recommendation (enough_data is false with fewer, and best_hour/best_hour_end/weekdays/histogram come back null/empty). Pair with POST /api/v1/campaigns/send-to-tag's send_mode=intelligent to actually deliver at each recipient's personal best time.","operationId":"getSendTimeRecommendation","parameters":[{"description":"Tag id owned by the authenticated organization","in":"query","name":"tag_id","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"audience_count":{"description":"Number of contacts currently in this tag","type":"integer"},"best_hour":{"description":"Recommended local start hour, 0-23, org timezone; null when enough_data is false","type":"integer"},"best_hour_end":{"description":"Recommended local end hour, org timezone; null when enough_data is false","type":"integer"},"enough_data":{"description":"False when sample_opens is below min_opens \u2014 best_hour/best_hour_end/weekdays/histogram are then null/empty and summary explains why","type":"boolean"},"histogram":{"description":"Hour-of-day \u2192 open-count breakdown behind the recommendation; null when enough_data is false","type":"object"},"min_opens":{"description":"Opens required in the window before a recommendation is produced (20)","type":"integer"},"sample_opens":{"description":"Opens actually found in the window (may be below min_opens)","type":"integer"},"success":{"type":"boolean"},"summary":{"description":"Plain-English summary of the recommendation, or of why there isn't enough data yet","type":"string"},"tag_id":{"description":"Echoes the requested tag_id","type":"integer"},"timezone":{"description":"Organization timezone the hours are expressed in","type":"string"},"weekdays":{"description":"Weekday names the audience opens most on; empty when enough_data is false","items":{"type":"string"},"type":"array"},"window_days":{"description":"Lookback window in days for open history (90)","type":"integer"}},"type":"object"}}},"description":"Returns {success: true, ...recommendation}"},"400":{"description":"Returns {success: false, error: 'invalid_tag_id'} \u2014 tag_id missing or not an integer"},"404":{"description":"Returns {success: false, error: 'not_found'} \u2014 tag_id doesn't exist or belongs to a different organization"}},"summary":"Get a tag's send-time recommendation"}},"/api/v1/templates":{"get":{"description":"Each entry carries id, template_number (canonical identifier), name, channel, status, subject, message_body, variables, created_at, updated_at. FC-040 R1: each template's `variables` is the exact list of placeholders its render requires (subject + body, platform-reserved names such as unsubscribe_url excluded, stale declared names dropped) \u2014 for campaign sends these must exist on each contact (core field or custom field) or the contact is skipped; declared_variables is the stored declaration for diagnosis.","operationId":"listTemplates","responses":{"200":{"description":"List of templates with IDs, names, and variables \u2014 each with template_number and variables (the exact placeholders it requires; contacts missing one are skipped by campaign sends)"}},"summary":"List email templates"},"post":{"description":"Create a new email template with HTML content. Variables like {{name}} are auto-detected. Variables ({{name}} tokens) are auto-detected from subject + html_content; platform-reserved names (unsubscribe_url) are excluded and returned as reserved_variables_ignored. Returns template_id AND template_number \u2014 template_number is the canonical identifier for send-template, send-to-tag and GET/PATCH/DELETE /api/v1/templates/{template_number}. Do not include an unsubscribe link or compliance footer in the HTML \u2014 Mail-o-Mail appends these automatically at send time (an open/click tracking pixel and link rewriting are added too). Validation is collect-all: a 400 carries errors:[{field, error}, ...] naming every problem at once (error_code 'validation_failed').","operationId":"createTemplate","requestBody":{"content":{"application/json":{"schema":{"properties":{"description":{"description":"Template description","type":"string"},"html_content":{"description":"HTML email body. Do not include an unsubscribe link or compliance footer in the HTML \u2014 Mail-o-Mail appends these automatically at send time (an open/click tracking pixel and link rewriting are added too).","type":"string"},"name":{"description":"Template name","type":"string"},"rate_limit_per_day":{"description":"Optional Spend Guard cap: max sends of this template per day in your org's timezone (omit/null = unlimited)","type":"integer"},"rate_limit_per_hour":{"description":"Optional Spend Guard cap: max sends of this template per UTC clock hour (omit/null = unlimited)","type":"integer"},"subject":{"description":"Email subject line","type":"string"}},"required":["name","subject","html_content"],"type":"object"}}},"required":true},"responses":{"201":{"description":"Template created, returns template_id and detected variables"}},"summary":"Create a new email template"}},"/api/v1/templates/{template_number}":{"patch":{"description":"Partial update of an EMAIL template: pass only the fields to change. Variables are RE-DETECTED from the new subject + html_content (variables_changed.added/removed in the response); platform-reserved names (unsubscribe_url) are excluded and reported as reserved_variables_ignored. Do not include an unsubscribe link or compliance footer in the HTML \u2014 Mail-o-Mail appends these automatically at send time (an open/click tracking pixel and link rewriting are added too). template_number never changes. WhatsApp templates are refused (400 wrong_channel) \u2014 they are Meta-approved artifacts. Validation is collect-all: a 400 carries errors:[{field, error}, ...] naming every problem at once (error_code 'validation_failed').","operationId":"updateTemplate","parameters":[{"description":"Canonical template_number (e.g. '000100001'); the legacy numeric template id is accepted too","in":"path","name":"template_number","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"properties":{"description":{"description":"New description","type":"string"},"html_content":{"description":"New HTML body \u2014 replaces the stored body entirely","type":"string"},"name":{"description":"New template name","type":"string"},"subject":{"description":"New subject line ({{variables}} allowed)","type":"string"}},"type":"object"}}},"required":true},"responses":{"200":{"description":"Updated: {success, template, variables_changed, reserved_variables_ignored}"},"400":{"description":"invalid_payload / unknown_field / wrong_channel / validation_failed"},"404":{"description":"template_not_found"}},"summary":"Update an email template in place"}},"/api/v1/whatsapp/send-template":{"post":{"description":"Send an APPROVED WhatsApp template to a single recipient. Resolve by template_name or template_number. Costs 1 credit. Consent: with `consent` omitted the stored contact consent is authoritative (no \u2192 no_consent; unknown contact \u2192 consent_unknown \u2014 pass consent:yes only for a real opt-in). Nothing is sent and nothing is charged on a refusal. Contact limits (FC-057): this single-recipient send is EXEMPT \u2014 presumed transactional, it is never blocked by the organization's contact frequency limits and never counts toward them.","operationId":"sendWhatsappTemplate","requestBody":{"content":{"application/json":{"schema":{"properties":{"button_url_param":{"description":"Legacy: suffix value for the FIRST dynamic URL button. Prefer buttons.url_suffixes, which names the variable explicitly.","type":"string"},"buttons":{"description":"Per-send button parameters. What can change per send: a dynamic URL button's suffix and a quick-reply button's payload (the value your webhook receives in button_reply.selection.id when tapped). Button labels and static URLs are fixed at Meta approval and cannot be sent. Errors: button_not_dynamic (suffix for a variable no dynamic button uses), button_index_invalid (index is not a quick-reply position), payload_too_long (payload over 256 characters).","properties":{"quick_replies":{"description":"Per-quick-reply payloads. Buttons without a payload here fall back to the template's default payload, then to the label echo.","items":{"properties":{"index":{"description":"Which quick-reply button, counting quick replies only, 0-based, in template order","type":"integer"},"payload":{"description":"Developer-defined payload returned in the tap webhook (button_reply.selection.id). Omitted = WhatsApp echoes the button label.","maxLength":256,"type":"string"}},"required":["index","payload"],"type":"object"},"type":"array"},"url_suffixes":{"additionalProperties":{"type":"string"},"description":"Dynamic URL suffixes keyed by the button's {{variable}} name, e.g. {\"order_id\": \"ORD-1042\"}. Appended to the button's registered base URL.","type":"object"}},"type":"object"},"consent":{"description":"Optional. CHANGED 2026-09-03 (FC-038 R5b): omitted = the STORED contact's consent decides \u2014 stored no \u2192 400 no_consent, no stored contact \u2192 400 consent_unknown. 'yes' only when the recipient opted in (sends and marks the contact consented); no/false/0 refuses.","type":"string"},"header_image":{"description":"Legacy: public image URL for an image header \u2014 prefer media_url (host with POST /api/v1/whatsapp/media purpose=send_media).","type":"string"},"header_text":{"type":"string"},"language":{"description":"Default 'en'","type":"string"},"media_url":{"description":"Public URL of the header media (image/video/document) for a media-header template; media type is fixed on the template. To host a file for this use POST /api/v1/whatsapp/media with purpose=send_media (publicly hosted, quota-counted, kept once a send used it) \u2014 not purpose=template_header, which returns a Meta handle for template creation only.","type":"string"},"send_from":{"description":"Optional: which connected WhatsApp number to send from (digits). Always the PHONE NUMBER \u2014 account friendly names are display-only and not accepted here. Omit = the template's linked account, else the org default.","type":"string"},"template_name":{"type":"string"},"template_number":{"type":"string"},"to":{"description":"Recipient phone, e.g. 919876543210","type":"string"},"variables":{"description":"Body variable values in order","items":{"type":"string"},"type":"array"}},"required":["to"],"type":"object"}}},"required":true},"responses":{"200":{"description":"Message sent, returns wamid"},"400":{"description":"Refused: invalid_send_from or send_from_template_mismatch (numbers named in the error)"},"429":{"description":"Rate limited (Spend Guard): this template's hourly/daily sending limit or the workspace daily cap is exhausted. Body: {\"error\":\"rate_limited\",\"scope\":\"template|org\",\"limit\":N,\"used\":N,\"window_resets_at\":\"<ISO 8601>\"}. Nothing sent, no credit consumed. Retry after window_resets_at, or raise the limit and retry immediately."}},"summary":"Send an approved WhatsApp template message"}},"/api/v1/whatsapp/templates":{"get":{"description":"List WhatsApp templates with Meta approval status. Only APPROVED templates can be sent. Each row carries name, status, category, language, rejection_reason, the linked template_number and `components` as a parsed JSON array (the exact structure submitted to Meta).","operationId":"listWhatsappTemplates","responses":{"200":{"description":"List of WhatsApp templates"}},"summary":"List WhatsApp templates"}},"/api/v1/whoami":{"get":{"description":"Returns the connected Mail-o-Mail account (org, user email, credits, verified senders, WhatsApp status), plus api_key_name/api_key_prefix identifying which named key made the call (null for OAuth). Use to test a connection or confirm disconnect/reconnect.","operationId":"whoami","responses":{"200":{"description":"Connection info for the current credential"}},"summary":"Check which account is connected"}}},"security":[{"oauth2":["send_email","send_template","list_templates","create_template","list_from_emails","upload_contacts","list_tags","send_campaign","campaign_status","whatsapp","account"]}],"servers":[{"description":"Production server","url":"https://mail-o-mail.com"}]}
