# Meisa API — Full Integration Spec (for humans and AI coding assistants) You are integrating an application with Meisa, a behavioral lifecycle email platform for SaaS. This file is the complete, authoritative description of Meisa's public REST API. Use it to write a correct integration. Do not invent endpoints, fields, or capabilities that are not listed here. ================================================================================ CORE MENTAL MODEL — READ THIS FIRST ================================================================================ Meisa is built around one idea: your application syncs each user's CURRENT STATE to Meisa, and Meisa's automations (configured once in the dashboard by a non-engineer) react to changes in that state. Your code does NOT need to manage tags, decide who enters which sequence, or hardcode email logic. You sync data; Meisa reacts. In practice, a typical integration uses just two endpoints: 1. POST /contacts/upsert/ — push the user's current state (on signup, login, plan change, etc.) 2. POST /emails/trigger/ — send transactional/triggered email (verification codes, receipts, etc.) Everything else (tagging users, enrolling them in onboarding/win-back sequences, reacting to events) is configured in Meisa as automations and happens automatically when the data you sync changes. When you (the AI) plan an integration, map the user's needs like this: - "Send a one-time-password / verification / password-reset / receipt email" -> POST /emails/trigger/ with a trigger_key - "Start onboarding when a user signs up" -> upsert the user; a Meisa automation enrolls them - "Win back a user who churned" -> upsert with the new state (e.g. plan='churned'); automation enrolls them - "Tag users who upgraded / churned / hit a milestone" -> upsert the changed fields; automation adds/removes tags - "React to an in-product action (e.g. used a feature)" -> POST /events/track/ with an event_name; automation reacts - "Don't email people who unsubscribed/bounced" -> GET /contacts/api/unsubscribed-emails/ and suppress locally - "Send a marketing broadcast / announcement to a segment" -> NOT via this API. Done by a human in the Meisa dashboard. ================================================================================ BASE URL & AUTHENTICATION ================================================================================ Base URL: https://api.meisa.io/api/v1 All paths below are relative to that base. All requests are JSON (Content-Type: application/json). Authentication: send your API key in the X-API-Key header on EVERY request. X-API-Key: meisa_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx Keys are created in the Meisa dashboard under Settings -> API Keys. Each key has: - an environment: "live" or "test" - a set of scopes (permissions) — a request is rejected with 403 if the key lacks the scope - rate limits (default 60 requests/minute, 1000/hour; can be raised per key) Verify a key works before building: GET /ping/ (requires only a valid key, no specific scope) -> 200 { "status": "ok", "api_key": { "name", "environment", "scopes", "rate_limit_per_minute", ... }, "product": { "id", "name" } } SCOPES (these are the only real scopes): contacts:read read contacts and the unsubscribed-emails list contacts:write create/update contacts, upsert, add/remove tags on a contact events:track record custom events sequences:read list sequences, read a contact's enrollments sequences:enroll enroll contacts in sequences emails:trigger send triggered/transactional emails tags:write (issuable but currently a no-op; tag changes use contacts:write — see Tags section) ================================================================================ ENDPOINT: POST /contacts/upsert/ (scope: contacts:write) ★ THE CORE ENDPOINT ================================================================================ Create a new contact or update an existing one. This is how you sync user state to Meisa. A contact is matched first by external_id (if provided), then by email. email is required. Request body fields: email string REQUIRED. The contact's email. Also the upsert key. external_id string optional but RECOMMENDED. Your internal user ID (stable, unique). Lets you re-key even if the user changes their email. first_name string optional last_name string optional display_name string optional source string optional. One of: api, import, manual, signup, initial_sync. Default "api". On UPDATE, source is never overwritten (the first source sticks). custom_fields object optional. Arbitrary key/value data about the user. MERGED shallowly on update (existing keys you don't send are preserved; keys you send overwrite). This is where almost all your product data goes (plan, usage counts, etc.). tag_names string[] optional. Tags to ADD to the contact (creates tags that don't exist). Only adds; never removes. Adding a tag fires any "tag added" automation. There is NO "name", "status", or "tags"/"tag_ids" field on upsert. Status is managed by Meisa. Example request: curl -X POST "https://api.meisa.io/api/v1/contacts/upsert/" \ -H "X-API-Key: meisa_live_your_key" \ -H "Content-Type: application/json" \ -d '{ "email": "user@example.com", "external_id": "usr_12345", "first_name": "Sarah", "custom_fields": { "plan": "pro", "billing_cycle": "annual", "signin_count": 27, "signup_date": "2026-01-12T08:30:00Z" }, "tag_names": ["paying-customer"] }' Response 200: the full contact object, including: id, email, first_name, last_name, full_name, display_name, external_id, source, status, custom_fields, tags ([{id,name,slug,color}]), preferences, total_emails_sent, total_emails_opened, open_rate, last_email_sent_at, last_activity_at, created_at, updated_at. custom_fields guidance: send the fields your automations will key off of. A real production integration (LigoSocial) syncs custom_fields like: plan, billing_cycle, signup_date, last_active, signin_count, onboarding_completed, country, job_title, linkedin_url, ideas_generated, posts_generated, comments_generated, language, referral_source. Use whatever your product needs — the keys are arbitrary. Numbers stay numbers; ISO 8601 strings for dates. When to call upsert: on signup, on login (cheap way to keep last_active/signin_count fresh), on plan upgrade/downgrade, on churn/cancel, and whenever a field your automations care about changes. ================================================================================ ENDPOINT: POST /events/track/ (scope: events:track) ================================================================================ Record a custom event for a contact. Use this for discrete in-product actions ("activated_feature", "completed_onboarding", "hit_usage_limit") that you want Meisa automations to react to. The contact must already exist (upsert them first). Request body: event_name string REQUIRED. e.g. "feature_activated". email string REQUIRED unless external_id is given. external_id string REQUIRED unless email is given (matched first). properties object optional. Arbitrary metadata about the event. Example: curl -X POST "https://api.meisa.io/api/v1/events/track/" \ -H "X-API-Key: meisa_live_your_key" \ -H "Content-Type: application/json" \ -d '{ "email": "user@example.com", "event_name": "feature_activated", "properties": {"feature": "export"} }' Response 200: { "status": "tracked", "contact_id": "...", "event_name": "...", "timestamp": "..." } 404 if no matching contact. A tracked event fires any automation with a matching "custom event" trigger. ================================================================================ ENDPOINT: POST /contacts/{id}/add_tag/ and /contacts/{id}/remove_tag/ (scope: contacts:write) ================================================================================ Most integrations do NOT call these directly — they let upsert + automations manage tags. But you can add/remove a tag explicitly. {id} is the contact's Meisa id (from an upsert response). add_tag body: { "tag_name": "churned" } (or { "tag_id": "" }) — creates the tag if absent. remove_tag body: { "tag_name": "churned" } (or { "tag_id": "" }). Note: tag mutation is authorized by the contacts:write scope, not tags:write. ================================================================================ ENDPOINT: GET /contacts/api/unsubscribed-emails/ (scope: contacts:read) ================================================================================ Returns every email address in your product that must NOT be emailed (unsubscribed, bounced, or complained). Poll this on a schedule (e.g. hourly), cache it, and suppress these addresses in your own system. No request body. Response 200: { "as_of": "", "count": 1234, "emails": ["a@example.com", ...] } ================================================================================ ENDPOINT: POST /emails/trigger/ (scope: emails:trigger) ★ TRANSACTIONAL EMAIL ================================================================================ Send a single triggered/transactional email. The email's template, subject, and sender are defined once in the Meisa dashboard as a "trigger" with a stable trigger_key. Your code references that key and supplies the recipient + variables. This is how you send OTP/verification codes, receipts, password resets, invites, dunning emails, etc., without hardcoding email content. Request body: trigger_key string REQUIRED. The key of a trigger configured in Meisa (must exist & be active). to_email string REQUIRED. The single recipient. variables object optional. Values merged into the template ({{name}}, {{code}}, ...). recipient object optional. Contact fields (e.g. {"first_name":"Sarah"}) used to auto-create/enrich the contact if it doesn't exist. idempotency_key string optional. Dedupes identical sends within 1 hour. Use for retry safety. Example: curl -X POST "https://api.meisa.io/api/v1/emails/trigger/" \ -H "X-API-Key: meisa_live_your_key" \ -H "Content-Type: application/json" \ -d '{ "trigger_key": "verification_code", "to_email": "user@example.com", "variables": { "name": "Sarah", "code": "294817" }, "recipient": { "first_name": "Sarah" }, "idempotency_key": "verify_usr_12345_294817" }' Responses: 200 { "status": "sent", "email_send_id", "contact_id", "contact_created", "trigger_key", "timestamp" } 202 { "status": "scheduled", ... } if the trigger has a configured delay 200 { "status": "skipped", "reason": "conditions_not_met" } if the trigger's conditions aren't met 422 recipient unsubscribed, or no sender configured 429 rate-limited 400 unknown/inactive trigger_key, or bad to_email Real trigger_keys in production look like: verification_code, payment_failure, agency_client_invite. You define your own in the dashboard. ================================================================================ SEQUENCES (scopes: sequences:read, sequences:enroll) ================================================================================ Prefer letting automations enroll users (upsert state, automation enrolls). Direct enrollment exists too: POST /sequences/enroll/ (scope: sequences:enroll) Body: email (REQUIRED; contact auto-created if absent), sequence_slug (REQUIRED; must be an ACTIVE sequence), metadata (optional object), trigger_source (optional), override_unsubscribe (optional bool). 201 -> { id, contact_email, sequence_slug, status, enrolled_at, next_step_at } Rejects if the contact is already active/paused in that sequence. POST /sequences/api/bulk-enroll/ (scope: sequences:enroll) Body: sequence_slug (REQUIRED, active), emails (REQUIRED string[], max 500), override_unsubscribe (opt). Skips already-enrolled and (unless overridden) unsubscribed/bounced/complained. GET /sequences/api/list/ (scope: sequences:read) Query: status (default "active"; "all" for everything), search. -> { results:[{id,slug,name,status,total_enrolled}] } GET /sequences/api/contact-enrollments/?email=user@example.com (scope: sequences:read) -> { email, contact_exists, enrollments:[{sequence_slug, status, enrolled_at, emails_sent, ...}] } ================================================================================ AUTOMATIONS — how "Meisa reacts" actually works (configured in the dashboard, not via API) ================================================================================ You do not create automations via this API; a human builds them in Meisa. But understanding them helps you decide WHAT to sync. An automation = a trigger + optional conditions + actions. Trigger types that YOUR api calls can fire: contact_created — first upsert of a new contact contact_updated — upsert that changes fields (the changed fields, incl. custom_fields., are available) tag_added — a tag is added (via tag_names on upsert, or add_tag) tag_removed — a tag is removed custom_event — POST /events/track/ with a matching event_name sequence_enrolled — a contact is enrolled in a sequence Actions an automation can take: add_tag, remove_tag, update a custom field, enroll in a sequence, remove from a sequence, send a notification. Example the marketing team would configure (no code needed from you): WHEN custom_fields.plan changes from "free" to any paid value THEN add tag "paying-customer", remove tag "free-plan", enroll in "welcome-pro" sequence So your code just upserts {custom_fields:{plan:"pro"}} and all of that happens automatically. ================================================================================ ERRORS ================================================================================ 200/201 success · 202 accepted (scheduled/async) · 400 bad request · 401 invalid/missing key 403 missing scope · 404 not found · 422 unprocessable (e.g. unsubscribed) · 429 rate limited Errors return JSON with an explanatory message. ================================================================================ NOT IN THIS API (do not attempt) ================================================================================ - Broadcasts / one-time campaigns: managed by humans in the dashboard (or via the Meisa MCP server for AI agents). There is no X-API-Key broadcasts endpoint. - Creating/editing templates, triggers, sequences, or automations: done in the dashboard, not via API. - The tags:write scope exists but enforces nothing; use contacts:write for tag changes.