Core

Contacts & upsert

Upsert is the heart of the integration. You send a user's current state; Meisa creates or updates the contact and runs any automations that the change triggers.

Upsert a contact

POST/contacts/upsert/scope: contacts:write

Creates a contact, or updates an existing one. Meisa matches first by external_id (if you send it), then by email. email is always required.

Request body

FieldTypeDescription
emailrequiredstringThe contact's email. Also the upsert key.
external_idstringYour internal user ID. Recommended — lets Meisa re-key the contact even if their email changes.
first_namestringGiven name.
last_namestringFamily name.
display_namestringA display name, if you keep one separately.
sourcestringOne of api, import, manual, signup, initial_sync. Defaults to api. On update the original source is kept.
custom_fieldsobjectArbitrary key/value product data. Merged on update — keys you omit are preserved; keys you send overwrite. This is where most of your data goes.
tag_namesstring[]Tags to add (created if they don't exist). Only adds; never removes. Adding a tag can fire a "tag added" automation.

There is no status or tags field on upsert

Upsert intentionally won't set a contact's status (subscribed/unsubscribed) or replace its tag set. Status is managed by Meisa and the contact's own preferences; tags are added with tag_names or managed by automations.

Example

upsert
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": "[email protected]",
    "external_id": "usr_12345",
    "first_name": "Sarah",
    "custom_fields": {
      "plan": "pro",
      "billing_cycle": "annual",
      "signin_count": 27,
      "onboarding_completed": true,
      "signup_date": "2026-01-12T08:30:00Z"
    },
    "tag_names": ["paying-customer"]
  }'

The response is the full contact, including its tags, status, and engagement stats:

response
{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "email": "[email protected]",
  "external_id": "usr_12345",
  "first_name": "Sarah",
  "custom_fields": { "plan": "pro", "billing_cycle": "annual", "signin_count": 27, ... },
  "tags": [{ "id": "...", "name": "paying-customer", "slug": "paying-customer" }],
  "status": "active",
  "total_emails_sent": 4,
  "open_rate": 0.75,
  "created_at": "2026-01-12T08:30:00Z",
  "updated_at": "2026-06-02T10:00:00Z"
}

What to put in custom_fields

The keys are entirely yours — send whatever your automations need to make decisions. As a concrete reference, a production integration syncs fields like these on every signup, login, and plan change:

custom_fields (real-world example)
{
  "plan": "pro",
  "billing_cycle": "annual",
  "signup_date": "2026-01-12T08:30:00Z",
  "last_active": "2026-06-01T14:05:00Z",
  "signin_count": 27,
  "onboarding_completed": true,
  "country": "PK",
  "job_title": "Founder",
  "ideas_generated": 42,
  "posts_generated": 15,
  "referral_source": "twitter"
}

Sync on state changes, not on a timer

Call upsert on signup, on login (a cheap way to refresh last_active and signin_count), on plan upgrade/downgrade, and on churn. You don't need a nightly bulk re-sync — event-driven deltas keep Meisa current.

Reading suppression: who not to email

GET/contacts/api/unsubscribed-emails/scope: contacts:read

Returns every address you must not email (unsubscribed, bounced, or complained). Poll it on a schedule, cache it, and suppress those addresses in your own system so you never send to them from anywhere.

response
{
  "as_of": "2026-06-02T12:00:00Z",
  "count": 1234,
  "emails": ["[email protected]", "[email protected]", ...]
}