API/Identify Email Contact API

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

EndpointCreates the contactTag behaviorCustom fields
POST /email/contacts/identifyYesAddedMerged
POST /email/contactsYesReplaces the whole setReplaces the whole record
POST /email/contacts/:email/tagsNo — 404 if unknownAddedNot supported
POST /email/workflows/triggerYesAddedMerged

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/identify

Body:

FieldTypeRequiredDescription
emailstringYesContact email.
firstNamestringNoSet when provided; an existing value is kept when omitted.
lastNamestringNoSame as firstName.
tagsstring[]NoTags to add. Never removes anything.
removeTagsstring[]NoTags to remove. Applied before additions.
customFieldsobjectNoMerged 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:

StatusMeaning
400Missing or invalid email.
401Missing or invalid API key.
422The customFields object alone exceeds 20 entries.

Limits

  • 50 tags per contact. Tags beyond the cap are reported in skippedTags instead 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_added workflow 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 identify can never resurrect an opt-out — and workflows only enroll subscribed contacts.
  • The contact's source is preserved for an existing contact, and set to api for a new one.