Tags and attributes

The two places a fact about a person can live, and which one segments read.

There are two stores of facts about a person here and they are not the same store. Putting a value in the wrong one is the mistake this page exists to prevent, because the symptom is a segment that matches nobody or a broadcast that greets everybody as blank.

StoreWhat it is for
Contact attributes, at /v1/contacts/:id/attributesWorkspace-level facts about a person: their plan, their signup date, their company size. One set per contact, whatever lists they are on. This is what segments and automations read.
Membership attributes, on an audience memberPer-list merge fields, substituted into {{ first_name }} when a broadcast renders. One set per membership, so the same person can carry different values on two lists.
Tags, at /v1/contacts/:id/tagsLabels rather than values. Workspace level, like contact attributes. Adding or removing one fires an automation event.

The short version: if a segment or an automation has to read it, it is a contact attribute or a tag. If a broadcast has to print it, it is a membership attribute. A first name that a broadcast greets people by and a segment never filters on belongs only on the membership, and a plan tier that a segment filters on and no email ever prints belongs only on the contact.

Contact attributes

A merge patch: the names you send are written, the names you leave out are untouched, and null clears one. Between 1 and 100 names per call. Values are stored as text, which is why a segment comparing them numerically checks that both sides parse first.

PATCH /v1/contacts/:id/attributes
curl -X PATCH https://emails.sh/v1/contacts/b4c0e21f-7d69-4a55-8e13-2f9a6c081d77/attributes \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "attributes": {
      "plan": "pro",
      "seats": "12",
      "renewal_date": "2027-03-01",
      "trial_source": null
    }
  }'
200 OK
{
  "attributes": {
    "plan": "pro",
    "seats": "12",
    "renewal_date": "2027-03-01"
  },
  "changed": ["plan", "seats", "renewal_date", "trial_source"]
}

changed lists every name the call actually altered, cleared names included, which is what an automation with an attribute.changed trigger watches. A value written identically to what was already there is not a change and does not appear.

A date attribute in YYYY-MM-DD form is what a date.attribute automation trigger reads, which is how a renewal reminder gets sent a week before renewal_date without a cron job of your own. See /docs/automations.

Tags

A tag is a label with no value. Between 1 and 64 characters after trimming, no comma and no line break, compared without case, and stored lowercased, so "VIP" and "vip" are one tag and adding the second does nothing.

Add and remove
# Add. The body is either a "tags" array or a single "tag" string.
curl -X POST https://emails.sh/v1/contacts/b4c0e21f-7d69-4a55-8e13-2f9a6c081d77/tags \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"tags": ["vip", "beta"]}'

# Remove one. 404 if the contact does not hold it.
curl -X DELETE "https://emails.sh/v1/contacts/b4c0e21f-7d69-4a55-8e13-2f9a6c081d77/tags?tag=beta" \
  -H "Authorization: Bearer $EMAILSSH_API_KEY"

Every real change queues an automation event, tag.added or tag.removed. Adding a tag the contact already holds is not a real change and queues nothing, so a nightly sync that reasserts the same tags does not fire a welcome sequence every night. The queue is swept every five minutes, so a tag-triggered automation starts within that window rather than instantly.

Membership attributes

These arrive with an import, or one at a time with a PATCH on the membership. They are what a broadcast substitutes, and they are keyed by the membership id rather than the contact id.

PATCH the membership
curl -X PATCH https://emails.sh/v1/audiences/2a7f4b19-3c56-4e08-9d21-6b8e0f4a7c33/contacts/6f1d2c33-8a4e-4b17-9f02-51c7d9a3e480 \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"attributes": {"first_name": "Ada", "city": "London"}}'

A merge field the audience does not supply renders as nothing, so a body reading "Hello {{ first_name }}," greets somebody as "Hello ,". Check with the broadcast preview before you send: merge_fields tells you which fields are supplied and which are not. See /docs/broadcasts.

Will this person receive anything

One call answers it, across every address the contact holds: whether each is suppressed, which audiences it is on, and which topics it has answered. This is the call to put behind a support screen, so somebody asking "why did Ada not get the email" gets an answer rather than a database session.

GET /v1/contacts/:id/subscriptions
curl https://emails.sh/v1/contacts/b4c0e21f-7d69-4a55-8e13-2f9a6c081d77/subscriptions \
  -H "Authorization: Bearer $EMAILSSH_API_KEY"
200 OK
{
  "contact_id": "b4c0e21f-7d69-4a55-8e13-2f9a6c081d77",
  "subscriptions": [
    {
      "email": "ada@example.com",
      "suppressed": false,
      "audiences": [
        { "id": "2a7f4b19-3c56-4e08-9d21-6b8e0f4a7c33", "name": "Product updates", "status": "subscribed" }
      ],
      "topics": [
        { "key": "product-updates", "subscribed": true, "stated": true },
        { "key": "billing", "subscribed": true, "stated": false }
      ]
    }
  ]
}

stated says whether the person answered that topic themselves. false means nobody has said anything and the topic default is deciding, which is the distinction that matters when somebody claims they never signed up.

GET /v1/contacts

?q=&lookup=&limit=. Accept: text/vcard returns a .vcf instead of JSON.

POST /v1/contacts

Field mode, or { vcard } for up to 1000 cards at once.

GET /v1/contacts/:id

One contact.

PATCH /v1/contacts/:id

Change a contact.

DELETE /v1/contacts/:id

Remove a contact.

GET /v1/contacts/duplicates

Likely duplicate pairs. POST merges { survivor_id, loser_id }.

GET /v1/contacts/:id/tags

Tags on a contact.

POST /v1/contacts/:id/tags

{ tags } or { tag }. Each real change queues a tag.added automation event.

DELETE /v1/contacts/:id/tags

?tag=vip removes one, and queues tag.removed.

GET /v1/contacts/:id/attributes

Workspace-level attributes on a contact.

PATCH /v1/contacts/:id/attributes

Merge patch. null clears one name.

GET /v1/contacts/:id/subscriptions

Every address, whether it is suppressed, and what it is subscribed to.

contacts.tags

Read or add tags on a contact. Tags are 1 to 64 characters, hold no comma or newline, are compared without case, and are stored lowercased.

Arguments

id string required
The contact id.
tags string[]
On the POST. A single tag may be given as tag instead.

Returns{ tags, added }

contacts.attributes

Workspace-level facts about a contact, readable by every segment and automation. These are not the per-list merge fields a broadcast substitutes.

Arguments

id string required
attributes Record<string, string | null>
A merge patch: names you send are written, names you leave out are untouched, and null clears one. 1 to 100 names per call.

Returns{ attributes, changed[] }

contacts.subscriptions

Every address on a contact, whether it is suppressed, and the audiences and topics it is subscribed to. The one call that answers "will this person receive anything".

Arguments

id string required

Returns{ contact_id, subscriptions[] }