Core
Contacts & upsert
Upsert a contact
/contacts/upsert/scope: contacts:writeCreates 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
| Field | Type | Description |
|---|---|---|
emailrequired | string | The contact's email. Also the upsert key. |
external_id | string | Your internal user ID. Recommended — lets Meisa re-key the contact even if their email changes. |
first_name | string | Given name. |
last_name | string | Family name. |
display_name | string | A display name, if you keep one separately. |
source | string | One of api, import, manual, signup, initial_sync. Defaults to api. On update the original source is kept. |
custom_fields | object | Arbitrary 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_names | string[] | 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
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:
{
"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:
{
"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
/contacts/api/unsubscribed-emails/scope: contacts:readReturns 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.
{
"as_of": "2026-06-02T12:00:00Z",
"count": 1234,
"emails": ["[email protected]", "[email protected]", ...]
}