Identify Email Contact API
Create or update a contact with additive tags and merged custom fields.
Create a contact if it does not exist, update it if it does — without ever destroying data another integration wrote. Tags are added, custom fields are merged.
This is the endpoint to call from your app on "something happened to this user": signup, purchase, profile update, attribution capture.
Why not the other endpoints
| Endpoint | Creates the contact | Tag behavior | Custom fields |
|---|---|---|---|
POST /email/contacts/identify | Yes | Added | Merged |
POST /email/contacts | Yes | Replaces the whole set | Replaces the whole record |
POST /email/contacts/:email/tags | No — 404 if unknown | Added | Not supported |
POST /email/workflows/trigger | Yes | Added | Merged |
POST /email/workflows/trigger merges the same way, but only as a side effect of enrolling: it needs a live api_request workflow with at least one step. Reach for it when you want that specific workflow to run, not as a way to write a contact.
POST /email/contacts is destructive on purpose: it is a sync endpoint that mirrors an external list. If you call it on signup with {"tags":["signup"]}, a contact that already carried freebie and product-created loses both. Use identify unless you really mean "this array is now the complete truth".
Request
POST /api/v1/email/contacts/identifyBody:
| Field | Type | Required | Description |
|---|---|---|---|
email | string | Yes | Contact email. |
firstName | string | No | Set when provided; an existing value is kept when omitted. |
lastName | string | No | Same as firstName. |
tags | string[] | No | Tags to add. Never removes anything. |
removeTags | string[] | No | Tags to remove. Applied before additions. |
customFields | object | No | Merged key by key into the stored record. Numbers and booleans are coerced to strings. |
curl -X POST https://growth.example.com/api/v1/email/contacts/identify \
-H "x-api-key: org_api_key" \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"firstName": "Jane",
"tags": ["signup"],
"customFields": { "utm_source": "google", "ref": "pierre" }
}'Response
{
"contact": {
"email": "[email protected]",
"firstName": "Jane",
"tags": ["freebie", "signup"],
"customFields": { "ref": "pierre", "utm_source": "google" }
},
"created": false,
"addedTags": ["signup"],
"removedTags": [],
"skippedTags": [],
"skippedCustomFields": []
}addedTags lists only what actually changed, so replaying the same call returns an empty array — the endpoint is idempotent.
Error responses:
| Status | Meaning |
|---|---|
400 | Missing or invalid email. |
401 | Missing or invalid API key. |
422 | The customFields object alone exceeds 20 entries. |
Limits
- 50 tags per contact. Tags beyond the cap are reported in
skippedTagsinstead of failing the call. - 20 custom fields per contact. An existing key can always be updated; a new key past the cap lands in
skippedCustomFields. - Tags are lowercased, trimmed, deduplicated, and truncated to 48 characters. Field keys cap at 48 characters, values at 200.
Notes
- Adding a tag runs the same trigger path as the UI, so an active
tag_addedworkflow fires. You do not need a placeholder workflow to carry the payload. - Status is never changed. An unsubscribed, bounced, or complained contact keeps that status, so
identifycan never resurrect an opt-out — and workflows only enroll subscribed contacts. - The contact's
sourceis preserved for an existing contact, and set toapifor a new one.