{"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":"Customer conversations: contact history and notes, assignment, internal notes, canned responses, routing, presence and service metrics.","title":"Mail-o-Mail Support API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/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/chat/canned-responses":{"get":{"description":"Canned responses are the organization's reusable plain-text replies, addressed by their shortcut (typed after / in the chat console). {{variables}} fill in from the customer's contact record the same way campaigns do (a contact column, then a custom field); a variable the contact has no value for is left blank, and the console shows which ones before the agent sends. Inserting one never sends it. Reading the library: any credential; changing it: owners/admins and API keys.","operationId":"listCannedResponses","responses":{"200":{"description":"{success, canned_responses: [{name, shortcut, body, variables [names], use_count, last_used_at, updated_at}]}"}},"summary":"List the organization's canned responses"},"post":{"description":"Owners/admins and API keys only. Canned responses are the organization's reusable plain-text replies, addressed by their shortcut (typed after / in the chat console). {{variables}} fill in from the customer's contact record the same way campaigns do (a contact column, then a custom field); a variable the contact has no value for is left blank, and the console shows which ones before the agent sends. Inserting one never sends it. Reading the library: any credential; changing it: owners/admins and API keys.","operationId":"createCannedResponse","requestBody":{"content":{"application/json":{"schema":{"properties":{"body":{"description":"Plain text up to 4000 characters; {{variables}} allowed","type":"string"},"name":{"description":"Up to 80 characters","type":"string"},"shortcut":{"description":"Lowercase letters, digits, - and _ (at least one letter, up to 32); unique in the organization","type":"string"}},"required":["name","shortcut","body"],"type":"object"}}},"required":true},"responses":{"201":{"description":"{success, canned_response: {name, shortcut, body, variables [names], use_count, last_used_at, updated_at}}"},"400":{"description":"validation_failed (errors[] per field)"},"403":{"description":"canned_admin_only"},"409":{"description":"shortcut_taken"}},"summary":"Create a canned response"}},"/api/v1/chat/canned-responses/{shortcut}":{"delete":{"description":"Owners/admins and API keys only. Replies already sent are unaffected.","operationId":"deleteCannedResponse","parameters":[{"in":"path","name":"shortcut","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"{success, deleted: true, shortcut}"},"403":{"description":"canned_admin_only"},"404":{"description":"not_found"}},"summary":"Delete a canned response"},"patch":{"description":"Owners/admins and API keys only. Fields left out keep their value.","operationId":"updateCannedResponse","parameters":[{"in":"path","name":"shortcut","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"properties":{"body":{"description":"Plain text up to 4000 characters; {{variables}} allowed","type":"string"},"name":{"description":"Up to 80 characters","type":"string"},"shortcut":{"description":"Lowercase letters, digits, - and _ (at least one letter, up to 32); unique in the organization","type":"string"}},"type":"object"}}},"required":true},"responses":{"200":{"description":"{success, canned_response: {name, shortcut, body, variables [names], use_count, last_used_at, updated_at}}"},"400":{"description":"validation_failed"},"403":{"description":"canned_admin_only"},"404":{"description":"not_found"},"409":{"description":"shortcut_taken"}},"summary":"Change a canned response (any of name, shortcut, body)"}},"/api/v1/chat/conversations/assign":{"post":{"description":"Assigning makes the conversation PRIVATE to the assignee and owners/admins. Who may assign: owners/admins (and API keys) anything; the current assignee may hand it to a teammate or return it to the pool; anyone else may only take a POOL conversation for themselves. The assignee must be an active teammate whose role can own conversations (viewer seats cannot). Sticky: a closed conversation reopens and returns to the same assignee when the customer writes again. When the contact has no owner, the assignee becomes its owner (an existing owner is never replaced). The assignee is notified once: an email from the organization's default sender (1 credit; skipped \u2014 never failed \u2014 at zero credits) plus a browser push (free). Self-assignment notifies nobody. Chat privacy (FC-048): a conversation ASSIGNED to a teammate is visible only to that teammate and the organization's owners/admins; unassigned conversations are the shared pool every teammate sees. An API key is the organization's credential and is treated as admin (reads every conversation, may assign anyone); an OAuth grant acts as the person who authorized it, so another teammate's private conversation is refused with 403 conversation_private. FC-050: new conversations may also be assigned automatically by team routing (GET /api/v1/chat/routing).","operationId":"assignChatConversation","requestBody":{"content":{"application/json":{"schema":{"properties":{"assignee":{"description":"The teammate's email address (an active user of your organization)","type":"string"},"email":{"description":"The contact's email (email and/or phone)","type":"string"},"phone":{"description":"The contact's phone (email and/or phone)","type":"string"}},"required":["assignee"],"type":"object"}}},"required":true},"responses":{"200":{"description":"{success, contact {email, phone, name}, changed (false = already that way), action (assigned|reassigned|handoff|unassigned), assignee {name, email}|null, previous_assignee {name, email}|null, visibility (private|pool), owner_note, notified {email: sent|skipped_self|skipped_no_credits|skipped_no_sender|failed|not_applicable, push: queued|no_devices|not_configured|...}, message}"},"400":{"description":"validation_failed (no email/phone, or no assignee), assignee_inactive, assignee_role_cannot_own"},"403":{"description":"conversation_private (an OAuth user who cannot see it) or assign_role_denied (R5 ladder)"},"404":{"description":"contact_not_found / assignee_not_found"},"409":{"description":"assignment_changed \u2014 someone else changed the assignee a moment ago"}},"summary":"Assign a contact's chat conversation to a teammate"}},"/api/v1/chat/conversations/notes":{"post":{"description":"Internal notes are never delivered to the customer \u2014 not by WhatsApp, email, web chat or any webhook. Only your team sees them. A note sits in the conversation thread in the chat console, marked as team-only, and on the contact's timeline; it is not a reply (unread, waiting time and first-response time are untouched) and it is not returned by GET /api/v1/contacts/messages. Mentions ping teammates: a browser push always (free), plus an email when the organization turned on mention emails (1 credit each; skipped, not failed, with no credits). On a PRIVATE (assigned) conversation only people who can see it \u2014 its assignee and owners/admins \u2014 may be mentioned; mentioning someone does not give them access (mention_not_visible).","operationId":"addInternalNote","requestBody":{"content":{"application/json":{"schema":{"properties":{"body":{"description":"The note (up to 4000 characters)","type":"string"},"email":{"description":"Contact email (email and/or phone)","type":"string"},"mentions":{"description":"Teammates' emails to ping (up to 10)","items":{"type":"string"},"type":"array"},"phone":{"description":"Contact phone (email and/or phone)","type":"string"}},"required":["body"],"type":"object"}}},"required":true},"responses":{"201":{"description":"{success, note {note_id (msg_<id>), body, created_at, author, mentions [{name, push, email}], delivered_to_customer: false}, message}"},"400":{"description":"validation_failed (errors[]) / mention_not_visible / missing_identifier"},"403":{"description":"conversation_private (an OAuth person who cannot see the conversation)"},"404":{"description":"contact_not_found"}},"summary":"Add an internal note to a contact's conversation (team-only, never sent)"}},"/api/v1/chat/conversations/unassign":{"post":{"description":"The conversation becomes visible to every teammate again. Owners/admins, API keys and the current assignee may do this. Notifies nobody. Chat privacy (FC-048): a conversation ASSIGNED to a teammate is visible only to that teammate and the organization's owners/admins; unassigned conversations are the shared pool every teammate sees. An API key is the organization's credential and is treated as admin (reads every conversation, may assign anyone); an OAuth grant acts as the person who authorized it, so another teammate's private conversation is refused with 403 conversation_private.","operationId":"unassignChatConversation","requestBody":{"content":{"application/json":{"schema":{"properties":{"email":{"description":"The contact's email (email and/or phone)","type":"string"},"phone":{"description":"The contact's phone (email and/or phone)","type":"string"}},"type":"object"}}},"required":true},"responses":{"200":{"description":"{success, contact {email, phone, name}, changed (false = already that way), action (assigned|reassigned|handoff|unassigned), assignee {name, email}|null, previous_assignee {name, email}|null, visibility (private|pool), owner_note, notified {email: sent|skipped_self|skipped_no_credits|skipped_no_sender|failed|not_applicable, push: queued|no_devices|not_configured|...}, message}"},"400":{"description":"validation_failed (no email/phone, or no assignee), assignee_inactive, assignee_role_cannot_own"},"403":{"description":"conversation_private (an OAuth user who cannot see it) or assign_role_denied (R5 ladder)"},"404":{"description":"contact_not_found / assignee_not_found"},"409":{"description":"assignment_changed \u2014 someone else changed the assignee a moment ago"}},"summary":"Return a contact's chat conversation to the shared pool"}},"/api/v1/chat/routing":{"get":{"description":"Owners/admins and API keys only. Team routing (FC-050): an organization may hand NEW inbound conversations out automatically \u2014 mode pool (default: nobody is auto-assigned), round_robin (the next available teammate in turn) or load_balanced (the available teammate with the fewest open conversations; ties go round-robin). A conversation that already has an assignee always returns to them (sticky); with away_reroute on, one whose assignee is away or outside working hours is re-routed instead. Available = active, inside working hours, not switched to Away, and not excluded from auto-routing by an admin; when nobody is available the conversation stays in the shared pool. An auto-assigned conversation is an assigned conversation: private to the assignee and owners/admins. The assignee gets a free push; the email (1 credit) only when email_on_auto is on.","operationId":"getChatRouting","responses":{"200":{"description":"{success, mode (pool|round_robin|load_balanced), away_reroute, email_on_auto, team [{name, email, role, available, status, status_label, away, working_hours, working_hours_summary, timezone, effective_timezone, auto_routing, routable, open_conversations}]}"},"403":{"description":"routing_admin_only"}},"summary":"Read the organization's chat routing settings and team availability"},"put":{"description":"Owners/admins and API keys only. Any subset of the three fields; the rest keep their value. Team routing (FC-050): an organization may hand NEW inbound conversations out automatically \u2014 mode pool (default: nobody is auto-assigned), round_robin (the next available teammate in turn) or load_balanced (the available teammate with the fewest open conversations; ties go round-robin). A conversation that already has an assignee always returns to them (sticky); with away_reroute on, one whose assignee is away or outside working hours is re-routed instead. Available = active, inside working hours, not switched to Away, and not excluded from auto-routing by an admin; when nobody is available the conversation stays in the shared pool. An auto-assigned conversation is an assigned conversation: private to the assignee and owners/admins. The assignee gets a free push; the email (1 credit) only when email_on_auto is on.","operationId":"setChatRouting","requestBody":{"content":{"application/json":{"schema":{"properties":{"away_reroute":{"description":"re-route a returning customer whose assignee is unavailable","type":"boolean"},"email_on_auto":{"description":"also email the auto-assignee (1 credit each); push is always sent","type":"boolean"},"mode":{"enum":["pool","round_robin","load_balanced"],"type":"string"}},"type":"object"}}},"required":true},"responses":{"200":{"description":"{success, mode (pool|round_robin|load_balanced), away_reroute, email_on_auto, team [{name, email, role, available, status, status_label, away, working_hours, working_hours_summary, timezone, effective_timezone, auto_routing, routable, open_conversations}]}"},"400":{"description":"validation_failed (errors[] per field)"},"403":{"description":"routing_admin_only"}},"summary":"Change the organization's chat routing settings"}},"/api/v1/chat/service-metrics":{"get":{"description":"Service is measured per cycle: a cycle starts at the customer message that finds no open cycle (a new conversation, or one that reopens a closed conversation) and ends when the conversation is closed. The first-response clock stops at the first agent reply the customer can receive \u2014 internal notes never count. With business hours on, clocks run only while a teammate is inside their working hours. Ratings come from the question asked on close: web chat (free), email (1 credit), WhatsApp only inside the 24-hour window (otherwise skipped \u2014 never sent outside it). An API key sees the whole organization; an OAuth connection that is not an owner/admin sees only its own numbers.","operationId":"getServiceMetrics","parameters":[{"description":"Days to look back","in":"query","name":"window","required":false,"schema":{"default":7,"enum":[7,30],"type":"integer"}}],"responses":{"200":{"description":"{success, enabled, window_days, scope (organization|self), since, targets {first_response_minutes, resolution_minutes, business_hours}, csat_enabled, summary {cycles, resolved, median_first_response_seconds, median_first_response, median_resolution_seconds, median_resolution, breached, breach_rate, csat_asked, csat_responses, csat_average, csat_response_rate}, per_assignee [summary + assignee {name, email}]}"},"400":{"description":"validation_failed (window must be 7 or 30)"}},"summary":"The team's service numbers: response times, missed targets, customer ratings"}},"/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/messages":{"get":{"description":"The contact's last chat messages (?email= / ?phone=, ?limit= default 5 max 20), oldest-first, sanitized like the chat console (subject + stats chips for email, WhatsApp account display name, delivered/read moments). Empty list when the contact has no chat conversation. Chat privacy (FC-048): a conversation ASSIGNED to a teammate is visible only to that teammate and the organization's owners/admins; unassigned conversations are the shared pool every teammate sees. An API key is the organization's credential and is treated as admin (reads every conversation, may assign anyone); an OAuth grant acts as the person who authorized it, so another teammate's private conversation is refused with 403 conversation_private. FC-051: internal team notes are not included \u2014 they are never part of what is exchanged with the contact. FC-052: when the organization set response targets or turned ratings on, the response also carries `service` {status, sla (the open cycle's live clock: clock, status ok|warning|breached, target_minutes, elapsed_seconds, remaining_seconds, running), last_rating}.","operationId":"getContactMessages","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"}},{"description":"How many (default 5, max 20)","in":"query","name":"limit","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"description":"{messages: [...]}"},"404":{"description":"contact_not_found"}},"summary":"One contact's recent chat messages"}},"/api/v1/contacts/notes":{"get":{"description":"Notes on one contact, addressed by email and/or phone. Each note: id, body, author (display name; \"API\" for key-written notes), created_at, edited_at, editable_until. No numeric contact or user ids.","operationId":"listContactNotes","parameters":[{"description":"Contact email (email and/or phone)","in":"query","name":"email","required":false,"schema":{"type":"string"}},{"description":"Contact phone (email and/or phone)","in":"query","name":"phone","required":false,"schema":{"type":"string"}},{"description":"Page size (default 20, max 100)","in":"query","name":"limit","required":false,"schema":{"maximum":100,"type":"integer"}},{"description":"Offset","in":"query","name":"offset","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"description":"{success, notes:[...], total, limit, offset}"},"404":{"description":"contact_not_found"}},"summary":"List one contact's notes (newest first)"},"post":{"description":"Adds a free-text note to one contact's record (it joins the contact timeline). The author may edit it for 15 minutes, then it is frozen. A note written with an API key has no author and cannot be edited.","operationId":"addContactNote","requestBody":{"content":{"application/json":{"schema":{"properties":{"body":{"description":"The note text","maxLength":5000,"type":"string"},"email":{"description":"Contact email (email and/or phone)","type":"string"},"phone":{"description":"Contact phone (email and/or phone)","type":"string"}},"required":["body"],"type":"object"}}},"required":true},"responses":{"201":{"description":"{success, note:{id, body, author, created_at, editable_until}}"},"400":{"description":"validation_failed"},"404":{"description":"contact_not_found"}},"summary":"Add a note to a contact"}},"/api/v1/contacts/notes/{note_id}":{"patch":{"description":"Author only. After 15 minutes the note is part of the record: 409 note_frozen.","operationId":"editContactNote","parameters":[{"description":"Note id","in":"path","name":"note_id","required":true,"schema":{"type":"integer"}}],"requestBody":{"content":{"application/json":{"schema":{"properties":{"body":{"description":"The new note text","maxLength":5000,"type":"string"}},"required":["body"],"type":"object"}}},"required":true},"responses":{"200":{"description":"{success, note}"},"403":{"description":"note_not_author"},"404":{"description":"note_not_found"},"409":{"description":"note_frozen"}},"summary":"Edit a note within its 15-minute window"}},"/api/v1/contacts/stats":{"get":{"description":"Stats for one contact (?email= and/or ?phone=): received counts per channel, opens, clicks, last open/click/message timestamps, unsubscribed truth.","operationId":"getContactStats","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":"{stats: {received, opens, clicks, last_open_at, last_click_at, last_message_at, unsubscribed}}"},"404":{"description":"contact_not_found"}},"summary":"One contact's engagement stats"}},"/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/tasks":{"get":{"description":"Each task: id, title, due_date, status (open|done), state (open|due_today|overdue|done \u2014 'today' in the organization's timezone), assignee, created_at, completed_at.","operationId":"listContactTasks","parameters":[{"description":"Contact email (email and/or phone)","in":"query","name":"email","required":false,"schema":{"type":"string"}},{"description":"Contact phone (email and/or phone)","in":"query","name":"phone","required":false,"schema":{"type":"string"}},{"description":"Page size (default 50, max 200)","in":"query","name":"limit","required":false,"schema":{"maximum":200,"type":"integer"}},{"description":"Offset","in":"query","name":"offset","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"description":"{success, tasks:[...], limit, offset}"},"404":{"description":"contact_not_found"}},"summary":"List one contact's tasks (open first)"},"post":{"description":"A follow-up task: title, optional due_date (YYYY-MM-DD), optional assignee (an active org user's email or id). The assignee defaults to the creator; with an API key there is no creator, so the task is unassigned unless assignee is given.","operationId":"createContactTask","requestBody":{"content":{"application/json":{"schema":{"properties":{"assignee":{"description":"Assignee: an org user's email or id (optional)","type":"string"},"due_date":{"description":"Due date YYYY-MM-DD (optional)","format":"date","type":"string"},"email":{"description":"Contact email (email and/or phone)","type":"string"},"phone":{"description":"Contact phone (email and/or phone)","type":"string"},"title":{"description":"What needs doing","maxLength":200,"type":"string"}},"required":["title"],"type":"object"}}},"required":true},"responses":{"201":{"description":"{success, task}"},"400":{"description":"validation_failed (errors[] names title / due_date / assignee)"},"404":{"description":"contact_not_found"}},"summary":"Create a task on a contact"}},"/api/v1/contacts/tasks/{task_id}/complete":{"post":{"description":"Logs the completion on the contact's timeline. Repeat calls return 409 task_already_done.","operationId":"completeContactTask","parameters":[{"description":"Task id","in":"path","name":"task_id","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"{success, task}"},"404":{"description":"task_not_found"},"409":{"description":"task_already_done"}},"summary":"Mark a task done"}},"/api/v1/profile/presence":{"get":{"description":"Presence decides who team routing may hand new conversations to. Team routing (FC-050): an organization may hand NEW inbound conversations out automatically \u2014 mode pool (default: nobody is auto-assigned), round_robin (the next available teammate in turn) or load_balanced (the available teammate with the fewest open conversations; ties go round-robin). A conversation that already has an assignee always returns to them (sticky); with away_reroute on, one whose assignee is away or outside working hours is re-routed instead. Available = active, inside working hours, not switched to Away, and not excluded from auto-routing by an admin; when nobody is available the conversation stays in the shared pool. An auto-assigned conversation is an assigned conversation: private to the assignee and owners/admins. The assignee gets a free push; the email (1 credit) only when email_on_auto is on.","operationId":"getPresence","parameters":[{"description":"A teammate's email. Required for an API key (the organization's credential, not a person); an OAuth grant omits it for its own presence (owners/admins may name a teammate).","in":"query","name":"user","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"{success, presence {email, away, away_since, working_hours {mon..sun: [[HH:MM, HH:MM]]}|null, working_hours_summary, timezone (override)|null, effective_timezone, organization_timezone, in_working_hours, available, status (available|away|off_hours|inactive), status_label}}"},"400":{"description":"user_required (API key without user)"},"403":{"description":"presence_admin_only (an OAuth non-admin naming a teammate)"},"404":{"description":"user_not_found"}},"summary":"Read availability: Away switch, working hours, available now"}},"/api/v1/profile/presence/away":{"post":{"description":"Away removes the person from auto-routing immediately; the very next new conversation skips them. Coming back takes effect just as fast. Conversations they already hold stay theirs.","operationId":"setAway","requestBody":{"content":{"application/json":{"schema":{"properties":{"away":{"type":"boolean"},"user":{"description":"Teammate email (required for an API key)","type":"string"}},"required":["away"],"type":"object"}}},"required":true},"responses":{"200":{"description":"{success, presence {email, away, away_since, working_hours {mon..sun: [[HH:MM, HH:MM]]}|null, working_hours_summary, timezone (override)|null, effective_timezone, organization_timezone, in_working_hours, available, status (available|away|off_hours|inactive), status_label}}"},"400":{"description":"validation_failed / user_required"},"403":{"description":"presence_admin_only"},"404":{"description":"user_not_found"}},"summary":"Switch Away on or off"}},"/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/whatsapp/send-message":{"post":{"description":"Send free-form text (optionally with up to 3 quick-reply buttons) WITHOUT a template. Only allowed inside WhatsApp's 24-hour customer-service window (the 24h after the customer's last message). Outside it the call is refused with 422 window_closed before anything is sent \u2014 use send-template then. Costs 1 credit; mirrors into the chat console. FC-048: an OAuth grant cannot send into a teammate's private (assigned) conversation \u2014 403 conversation_private.","operationId":"sendWhatsappSessionMessage","requestBody":{"content":{"application/json":{"schema":{"properties":{"buttons":{"description":"Optional quick-reply buttons, at most 3; titles up to 20 characters (WhatsApp limits)","items":{"properties":{"id":{"description":"Payload id returned when tapped (\u2264256 chars)","type":"string"},"title":{"description":"Button label, \u226420 characters","type":"string"}},"required":["title"],"type":"object"},"type":"array"},"consent":{"description":"Optional. Omit to send as usual; 'no'/'false'/'0' refuses (no_consent).","type":"string"},"send_from":{"description":"Optional connected WhatsApp number to send from (digits, never a friendly name). Omit = the number the customer last wrote to, else the org default.","type":"string"},"text":{"description":"Message text, up to 4096 characters","type":"string"},"to":{"description":"Recipient phone, digits only, e.g. 919876543210","type":"string"}},"required":["to","text"],"type":"object"}}},"required":true},"responses":{"202":{"description":"Sent. Returns {success, message_id: 'msg_<n>', conversation_window_expires_at, credits_charged: 1}"},"400":{"description":"Validation refusal (missing fields, caps exceeded, no_consent, invalid_send_from)"},"402":{"description":"Insufficient credits"},"422":{"description":"window_closed \u2014 the 24h window is not open (or the customer never messaged you). Body carries last_inbound_at (or null) and a hint to use send-template. No credits consumed, nothing sent."},"502":{"description":"WhatsApp refused the send; the credit is refunded"}},"summary":"Send a free-form WhatsApp session message (24h window)"}},"/api/v1/whatsapp/send-otp":{"post":{"description":"Send a one-time password via an APPROVED AUTHENTICATION template. You generate and verify the code; this endpoint only delivers it. FC-042: the recipient is saved as a contact (consent untouched) and the send appears in the chat console as a masked row; the code is encrypted at rest and never returned by any API, export or webhook (dashboard step-up reveal only, audited). 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":"sendWhatsappOtp","requestBody":{"content":{"application/json":{"schema":{"properties":{"language":{"type":"string"},"otp_code":{"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":{"description":"Meta template name (or use template_number)","type":"string"},"template_number":{"description":"MoM template number (or use template_name)","type":"string"},"to":{"type":"string"}},"required":["to","otp_code"],"type":"object"}}},"required":true},"responses":{"200":{"description":"OTP sent"},"400":{"description":"Refused: invalid_send_from or send_from_template_mismatch"},"429":{"description":"Rate limited (Spend Guard): this OTP template's own hourly/daily sending limit is exhausted (the workspace daily cap does not apply to OTPs). Body: {\"error\":\"rate_limited\",\"scope\":\"template\",\"limit\":N,\"used\":N,\"window_resets_at\":\"<ISO 8601>\"}. Nothing sent, no credit consumed. Retry after window_resets_at, or raise the template's limit and retry immediately."}},"summary":"Send a WhatsApp OTP message"}},"/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/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"}]}
