{"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":"Account, domains, sender addresses, WhatsApp connection and webhooks, templates and raw sends.","title":"Mail-o-Mail Developer API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/v1/account/overview":{"get":{"description":"Read-only session-grounding overview: org profile, connected channels (email/WhatsApp/Instagram/web chat), verified from-addresses, WhatsApp accounts by name, tags with counts, template count, active add-ons. The billing block is role-gated: present only for owner/admin credentials (absent, never zeroed, for other OAuth grants).","operationId":"getAccountOverview","responses":{"200":{"description":"Account overview (billing block only for owner/admin credentials)"}},"summary":"Account overview \u2014 call this first in a session"}},"/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/credits":{"get":{"operationId":"checkCredits","responses":{"200":{"description":"Credit balance"}},"summary":"Check credit balance"}},"/api/v1/disconnect":{"post":{"description":"Disconnect the connected account. OAuth tokens are revoked server-side; to reconnect, re-authorize. (For API-key clients, pass rotate_api_key=true to hard-revoke the key.)","operationId":"disconnectAccount","requestBody":{"content":{"application/json":{"schema":{"properties":{"rotate_api_key":{"description":"API-key clients only: rotate the key that authenticated this call (other named keys untouched); new value returned once","type":"boolean"}},"type":"object"}}},"required":false},"responses":{"200":{"description":"Disconnected; includes reconnect guidance"}},"summary":"Disconnect this account from the assistant"}},"/api/v1/domains":{"get":{"operationId":"listDomains","responses":{"200":{"description":"List of domains"}},"summary":"List registered domains"},"post":{"description":"FC-031: the response also carries an additive auto_setup block {supported, reason, provider {id, key, name}, setup_url, dmarc_existing} \u2014 when the domain's DNS provider supports Domain Connect and Mail-o-Mail's template is live there, setup_url is a signed one-click link: hand it to the human, they approve the records AT THEIR DNS PROVIDER (we never see their login), and the domain verifies itself. Otherwise supported is false with a reason and the dns_records above are entered manually.","operationId":"addDomain","requestBody":{"content":{"application/json":{"schema":{"properties":{"domain":{"description":"Domain name","type":"string"}},"required":["domain"],"type":"object"}}},"required":true},"responses":{"201":{"description":"Domain registered with DNS records"}},"summary":"Register a new domain"}},"/api/v1/domains/{domain_id}/verify":{"post":{"operationId":"verifyDomain","parameters":[{"in":"path","name":"domain_id","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"Domain verification status"}},"summary":"Check domain verification status"}},"/api/v1/domains/{domain}/auto-setup":{"get":{"description":"One-click DNS setup via the open Domain Connect protocol (FC-031). Mail-o-Mail looks up where the domain's DNS zone is hosted (nameservers \u2192 provider) and whether that provider speaks Domain Connect with Mail-o-Mail's momv1 template live (config-gated per provider, so the link is never a dead end). Supported \u2192 {supported: true, provider {id, key, name}, setup_url} \u2014 a signed synchronous-flow apply URL for the FULL record set (SES verification TXT, 3 DKIM CNAMEs, SPF as a merge-safe SPFM include, DMARC with the hosted report address, plus the pre-provisioned relay records); the human opens it, signs in at THEIR DNS provider, approves the named records, and is redirected back \u2014 Mail-o-Mail then auto-verifies the domain (poll POST /api/v1/domains/{domain_id}/verify or GET /api/v1/domains to watch it flip). Credential-free by design: no DNS login or token ever passes through Mail-o-Mail. Pre-flight (R2b): an existing DMARC policy is never overwritten \u2014 dmarc_existing: true and dmarc_guidance carry the exact rua tag to add by hand; an existing SPF is merged by the provider. Unsupported \u2192 {supported: false, reason: no_discovery | sync_unsupported | provider_not_approved | template_not_live | signing_key_missing | already_verified, provider, manual_records[], manual_url} \u2014 show the human the records (or the dashboard page) to enter manually. Each call with supported:true mints a fresh link (valid 24h) and a funnel attempt; nothing is written until the human approves at the provider. Free.","operationId":"getDomainAutoSetup","parameters":[{"description":"The registered domain name (as added via POST /api/v1/domains), e.g. example.com","in":"path","name":"domain","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"{success, domain, domain_id, supported, reason, provider {id, key, name}, setup_url (null when unsupported), expires_in_seconds, dmarc_existing, dmarc_guidance, manual_records[], manual_url, cloudflare_hosted, instructions}"},"401":{"description":"Invalid credential"},"404":{"description":"domain_not_found (this organization has not registered that domain)"}},"summary":"One-click DNS setup link (Domain Connect) for a registered domain"}},"/api/v1/from-emails":{"get":{"operationId":"listFromEmails","responses":{"200":{"description":"List of verified sender emails"}},"summary":"List verified sender addresses"},"post":{"operationId":"addFromEmail","requestBody":{"content":{"application/json":{"schema":{"properties":{"display_name":{"description":"Display name","type":"string"},"email":{"description":"Email address","type":"string"}},"required":["email"],"type":"object"}}},"required":true},"responses":{"201":{"description":"From-email added"}},"summary":"Add a sender email address"}},"/api/v1/send":{"post":{"description":"Send an email to one or more recipients with HTML content. Unsubscribed recipients are suppressed with credit refund (T-083). 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'). 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":"sendEmail","requestBody":{"content":{"application/json":{"schema":{"properties":{"bcc":{"description":"BCC recipients, comma-separated (alias of bcc_emails)","type":"string"},"body":{"description":"Email body (HTML or plain text)","type":"string"},"cc":{"description":"CC recipients, comma-separated (alias of cc_emails)","type":"string"},"consent":{"description":"Optional opt-in. Omit to send as usual; no/false/0 refuses the send (error_code 'no_consent').","type":"string"},"from_email":{"description":"Verified sender email address (preferred name; from_email_id is the legacy alias for the same value)","type":"string"},"from_email_id":{"description":"Legacy alias of from_email \u2014 the verified sender email ADDRESS (not a numeric id)","type":"string"},"subject":{"description":"Email subject line","type":"string"},"to":{"description":"Recipient email(s), comma-separated","type":"string"}},"required":["to","subject","body","from_email_id"],"type":"object"}}},"required":true},"responses":{"200":{"description":"Request processed (may indicate recipient unsubscribed, suppression check applied)"},"202":{"description":"Email accepted for delivery"},"429":{"description":"Rate limited (Spend Guard): a template or workspace sending limit is exhausted. Body: {\"error\":\"rate_limited\",\"scope\":\"template|org\",\"limit\":N,\"used\":N,\"window_resets_at\":\"<ISO 8601>\"}. No credits are consumed. Retry after window_resets_at, or raise the limit and retry immediately."}},"summary":"Send an email"}},"/api/v1/send-bulk":{"post":{"description":"Send a campaign to a list of recipients you provide inline right now, each with their own template variables (and optional per-recipient consent) \u2014 no pre-uploaded contact list required. Prefer send-to-tag when contacts are already tagged in the platform; use send-bulk when you already have the recipient list (and any per-person variables) in hand. A recipient whose consent is explicitly no/false/0 is dropped and counted in excluded_no_consent; if everyone opts out the call is refused with error_code 'no_consent'. Also fails (402) on insufficient credits. Variable contract (FC-040 R2): every recipient row must carry the template's required variables; a row missing any is a named per-row failure \u2014 listed in failed_rows [{index, email, missing_variables}], persisted as a terminal skipped row on the campaign, never sent, never charged \u2014 while complete rows send. When every row fails the call is refused with 400 missing_variables (nothing created). on_missing_variables='send_blank' sends every row with empty values instead. 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.","operationId":"sendBulkEmail","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"},"from_email_id":{"description":"Numeric ID of a verified from-email (the 'id' field from GET /api/v1/from-emails) \u2014 NOT an email address, unlike /api/v1/send's from_email_id","type":"integer"},"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"},"recipients":{"description":"Non-empty list of recipients","items":{"properties":{"consent":{"description":"Optional per-recipient opt-in; no/false/0 excludes just this recipient","type":"string"},"email":{"type":"string"},"variables":{"description":"Per-recipient template variables","type":"object"}},"required":["email"],"type":"object"},"type":"array"},"template_id":{"description":"Numeric ID of an existing template (the 'id' field from GET/POST /api/v1/templates) \u2014 NOT the template_number string used elsewhere","type":"integer"}},"required":["template_id","campaign_name","from_email_id","recipients"],"type":"object"}}},"required":true},"responses":{"200":{"description":"Emails queued. Returns {success, message, campaign_id, task_id, recipient_count, excluded_no_consent}. \u2014 {success, campaign_id, task_id, recipient_count, excluded_no_consent, failed_rows[{index, email, missing_variables}], failed_count, skipped_missing_variables, on_missing_variables}"},"400":{"description":"validation_failed / no_consent \u2014 also missing_variables (every row failed; failed_rows[] names them)"},"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."}},"summary":"Send a bulk email campaign with an explicit recipient list"}},"/api/v1/send-template":{"post":{"description":"Send an email using a pre-defined template with variable substitution. Unsubscribed recipients (per org+domain+from-email scoping) are suppressed with credit refund (T-083). Identify the template by template_number (canonical) or the legacy template_id, and the sender by from_email (preferred) or the legacy from_email_id \u2014 every combination is accepted. 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'). Variable contract (FC-040 R2): the row must carry every variable the template requires (its `variables` list from GET /api/v1/templates/{n}) \u2014 an absent key is a NAMED failure (400 missing_variables), never a blank-rendered send; an explicit empty string is a supplied value. on_missing_variables='send_blank' sends with empty values instead. 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":"sendTemplateEmail","requestBody":{"content":{"application/json":{"schema":{"properties":{"consent":{"description":"Optional opt-in. Omit to send as usual; no/false/0 refuses the send (error_code 'no_consent').","type":"string"},"from_email":{"description":"Verified sender email address (preferred name; from_email_id is the legacy alias for the same value)","type":"string"},"from_email_id":{"description":"Legacy alias of from_email \u2014 the verified sender email ADDRESS (not a numeric id)","type":"string"},"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"},"subject":{"description":"Optional: replace the template's subject for this send (subject_override is accepted too)","type":"string"},"template_id":{"description":"Legacy alias: the numeric template id (int or string). Prefer template_number.","type":"string"},"template_number":{"description":"Canonical template identifier (9-digit string, e.g. '000100001' \u2014 from POST/GET /api/v1/templates)","type":"string"},"to":{"description":"Recipient email address","type":"string"},"variables":{"description":"Named values for every variable the template requires (its `variables` from GET /api/v1/templates/{n}); a missing key is refused by name (400 missing_variables) unless on_missing_variables=send_blank","type":"object"}},"required":["to"],"type":"object"}}},"required":true},"responses":{"202":{"description":"Email accepted for delivery"},"400":{"description":"validation_failed / no_consent \u2014 also missing_variables {error_code, missing_variables[], template_variables[]} (FC-040 R2)"},"429":{"description":"Rate limited (Spend Guard): a template or workspace sending limit is exhausted. Body: {\"error\":\"rate_limited\",\"scope\":\"template|org\",\"limit\":N,\"used\":N,\"window_resets_at\":\"<ISO 8601>\"}. No credits are consumed. Retry after window_resets_at, or raise the limit and retry immediately."}},"summary":"Send email using a template"}},"/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/accounts/name":{"post":{"description":"Rename by phone: unique per org, 1-60 chars, blank clears. DISPLAY-ONLY \u2014 sending still uses the phone number.","operationId":"setWhatsappAccountName","requestBody":{"content":{"application/json":{"schema":{"properties":{"name":{"type":"string"},"phone":{"type":"string"}},"required":["phone","name"],"type":"object"}}},"required":true},"responses":{"200":{"description":"Returns {friendly_name, display_name}"},"404":{"description":"Unknown phone"}},"summary":"Set/clear an account's friendly display name"}},"/api/v1/whatsapp/connect-intents":{"post":{"description":"Creates a single-use connect link (30-min expiry, max 5 pending/org). Meta Embedded Signup must be completed by a HUMAN in a browser: hand the returned connect_url to the user, then poll the status endpoint every 30-60 seconds.","operationId":"createWhatsappConnectIntent","requestBody":{"content":{"application/json":{"schema":{"properties":{"source":{"description":"Who initiated the intent (default 'api'; the MCP tool passes 'mcp')","enum":["mcp","api"],"type":"string"}},"type":"object"}}},"required":true},"responses":{"200":{"description":"Returns {token, connect_url, expires_at, instructions}"}},"summary":"Start agent-initiated WhatsApp connection"}},"/api/v1/whatsapp/connect-intents/{token}":{"get":{"description":"pending | completed {account: {phone, name}} | expired. Poll no faster than every 10 seconds (30-60s recommended). When completed, ask the user whether they'd like a webhook set up so their systems receive incoming message data.","operationId":"whatsappConnectIntentStatus","parameters":[{"in":"path","name":"token","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Intent status"},"404":{"description":"Unknown token"}},"summary":"Poll a connect link's progress"}},"/api/v1/whatsapp/media":{"post":{"description":"purpose=template_header: bytes go straight to Meta's Resumable Upload and the response carries header_handle for POST /api/v1/whatsapp/templates \u2014 nothing is stored on Mail-o-Mail. purpose=send_media: the file is hosted on the public media bucket and the response carries url for send-template media_url / campaign media headers. Both: pass content_base64 OR a public https url; the type is detected from the bytes (accepted: image/jpeg, image/png 5 MB; video/mp4, video/3gpp 16 MB; application/pdf 100 MB; image/webp sticker 500 KB) \u2192 415 unsupported_media_type / 413 media_too_large otherwise. Hosted media: per-org storage cap + per-day count (429 media_quota_exceeded / media_daily_limit), unguessable keys, no listing, swept after 30 days unless a send/campaign referenced it. Uploads require a connected WhatsApp account or an active subscription (403 media_upload_not_allowed). Every upload is attributed (org, API key, size, sha256).","operationId":"uploadWhatsappMedia","requestBody":{"content":{"application/json":{"schema":{"properties":{"content_base64":{"description":"File bytes, base64 (or a data: URI). Use this OR url.","type":"string"},"filename":{"description":"Informational only","type":"string"},"media_type":{"description":"Optional guard \u2014 400 media_type_mismatch when the bytes differ","enum":["IMAGE","VIDEO","DOCUMENT","STICKER"],"type":"string"},"purpose":{"enum":["template_header","send_media"],"type":"string"},"url":{"description":"Public https URL to fetch (no login, no redirect). Use this OR content_base64.","type":"string"}},"required":["purpose"],"type":"object"}}},"required":true},"responses":{"200":{"description":"template_header: {header_handle, media_type, mime_type, bytes, sha256, stored:false}; send_media: {url, media_type, mime_type, bytes, sha256, stored:true, unreferenced_ttl_days, quota}"},"400":{"description":"invalid_purpose / invalid_payload / invalid_content / invalid_url / media_unreachable / media_type_mismatch / unsupported_header_type"},"403":{"description":"media_upload_not_allowed"},"413":{"description":"media_too_large (max_bytes)"},"415":{"description":"unsupported_media_type (accepted list)"},"429":{"description":"media_daily_limit / media_quota_exceeded"},"502":{"description":"meta_upload_failed / store_failed"}},"summary":"Upload media (Meta header handle, or hosted public URL)"}},"/api/v1/whatsapp/send-direct":{"post":{"description":"WhatsApp Direct Send (Meta beta): send ONE business-initiated UTILITY message \u2014 order/delivery update, reminder, receipt, account notice \u2014 WITHOUT an approved template and WITHOUT an open 24-hour customer-service window. Only for WhatsApp numbers Meta has enabled for Direct Send (Mail-o-Mail checks each number with Meta weekly and on connect); otherwise refused 403 direct_send_not_available. COMPLIANCE: Meta reviews this traffic \u2014 persistently sending marketing content (offers, promotions, discounts) through Direct Send makes Meta revoke Direct Send for the whole business number (refused afterwards with 403 direct_send_revoked; Meta's 24-hour enforcement pause = 429 direct_send_limited; an org admin's switch-off = 403 direct_send_disabled). Supported: text up to 1024 characters, optionally up to 3 reply buttons (title \u226420); optional report_as label for Meta's reporting (no template is created, chosen or approved by you). Not supported: media, link buttons, authentication category. CONSENT (FC-038 R5b): omitted consent = the stored contact decides (no \u2192 400 no_consent; unknown number \u2192 400 consent_unknown); consent:'yes' sends and saves the contact as consented. Never falls back to a template or session message. Costs 1 credit (refunded if Meta refuses); Meta bills its utility rate to the customer's own Meta account. The workspace daily sending cap applies (429 rate_limited). The message appears in the chat console conversation (message_type direct_utility). 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":"sendWhatsappDirect","requestBody":{"content":{"application/json":{"schema":{"properties":{"buttons":{"description":"Optional reply buttons","items":{"properties":{"id":{"description":"Payload returned on tap","type":"string"},"title":{"maxLength":20,"type":"string"}},"required":["title"],"type":"object"},"maxItems":3,"type":"array"},"consent":{"description":"Optional. Omit = stored consent decides; 'yes' sends + saves consent; no/false/0 refuses","type":"string"},"report_as":{"description":"Optional reporting label, e.g. order_shipped. Messages with the same label are grouped in Meta's reporting. Not a template \u2014 nothing to create or approve.","pattern":"^[a-z0-9_]+$","type":"string"},"send_from":{"description":"Optional connected WhatsApp number (digits); omit = org default","type":"string"},"text":{"description":"The utility message text (transactional content only)","maxLength":1024,"type":"string"},"to":{"description":"Recipient phone, digits with country code, e.g. 919876543210","type":"string"}},"required":["to","text"],"type":"object"}}},"required":true},"responses":{"202":{"description":"{success, message_id (msg_\u2026), wamid, conversation_id, send_from, category: \"utility\", report_as, credits_charged: 1, sent_at}"},"400":{"description":"validation_failed (errors[]), no_consent, consent_unknown, invalid_send_from"},"401":{"description":"Invalid credential"},"402":{"description":"insufficient_credits or subscription_blocked"},"403":{"description":"direct_send_not_available | direct_send_revoked | direct_send_disabled (nothing sent, nothing charged)"},"429":{"description":"rate_limited (workspace daily cap) or direct_send_limited (Meta enforcement pause)"},"502":{"description":"send_failed \u2014 Meta refused for another reason; credit refunded"}},"summary":"Send a WhatsApp utility message without a template (Direct Send)"}},"/api/v1/whatsapp/status":{"get":{"description":"Returns whether the org's WhatsApp Business account is connected (incl. friendly_name/display_name \u2014 the org-chosen label; DISPLAY-ONLY, send_from always remains the phone number). Connecting WhatsApp requires a human to complete Meta Embedded Signup in a browser \u2014 create a connect intent (createWhatsappConnectIntent) to get a link to hand to the user, then poll its status. Once connected, templates and sending work via this API.","operationId":"whatsappStatus","responses":{"200":{"description":"WhatsApp connection status"}},"summary":"Check WhatsApp connection status"}},"/api/v1/whatsapp/webhook":{"get":{"description":"Config for the account matching ?phone= (URL, enabled, has_secret). The signing secret is never readable here \u2014 it is returned once by set/rotate.","operationId":"getWhatsappWebhook","parameters":[{"in":"query","name":"phone","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Webhook config"},"404":{"description":"Unknown phone"}},"summary":"Read one account's outbound-webhook config"},"post":{"description":"Forwards incoming WhatsApp messages to your HTTPS URL as signed JSON (X-Mom-Signature-256). Get the user's explicit consent for the exact URL before calling. The signing secret is returned ONCE in this response. Setting re-enables the webhook.","operationId":"setWhatsappWebhook","requestBody":{"content":{"application/json":{"schema":{"properties":{"phone":{"type":"string"},"secret":{"type":"string"},"url":{"type":"string"}},"required":["phone","url"],"type":"object"}}},"required":true},"responses":{"200":{"description":"Returns {webhook, secret (ONCE), signature}"},"400":{"description":"invalid_url or invalid_secret"},"404":{"description":"Unknown phone"}},"summary":"Create/update an account's outbound webhook"}},"/api/v1/whatsapp/webhook/deliveries":{"get":{"description":"Up to 20 recent deliveries (status, HTTP code, attempts, duration).","operationId":"listWhatsappWebhookDeliveries","parameters":[{"in":"query","name":"phone","required":true,"schema":{"type":"string"}},{"in":"query","name":"limit","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"description":"Returns {deliveries: [...]}"}},"summary":"Recent webhook delivery log"}},"/api/v1/whatsapp/webhook/disable":{"post":{"description":"Config kept; re-enable by setting the webhook again.","operationId":"disableWhatsappWebhook","requestBody":{"content":{"application/json":{"schema":{"properties":{"phone":{"type":"string"}},"required":["phone"],"type":"object"}}},"required":true},"responses":{"200":{"description":"Disabled"},"404":{"description":"No webhook / unknown phone"}},"summary":"Disable message forwarding for one account"}},"/api/v1/whatsapp/webhook/rotate-secret":{"post":{"description":"Old secret dies immediately; the new one is returned once.","operationId":"rotateWhatsappWebhookSecret","requestBody":{"content":{"application/json":{"schema":{"properties":{"phone":{"type":"string"}},"required":["phone"],"type":"object"}}},"required":true},"responses":{"200":{"description":"Returns {secret (ONCE)}"},"404":{"description":"No webhook / unknown phone"}},"summary":"Rotate the webhook signing secret"}},"/api/v1/whatsapp/webhook/test":{"post":{"description":"Fires a canned test event through the real pipeline and reports {delivered, http_status, duration_ms}.","operationId":"testWhatsappWebhook","requestBody":{"content":{"application/json":{"schema":{"properties":{"phone":{"type":"string"}},"required":["phone"],"type":"object"}}},"required":true},"responses":{"200":{"description":"Delivery outcome"},"404":{"description":"No webhook / unknown phone"}},"summary":"Send a signed test event to the webhook"}},"/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"}]}
