EnvlopedDocs
Contacts

Contacts

API reference for managing marketing contacts (subscribers) with Envloped

Contacts

Contacts are the subscribers in your marketing audience. Every contact belongs to your account, carries a consent record, and has a lifecycle status. Contacts can be grouped into Lists and targeted with Segments.

All endpoints require an Authorization: Bearer YOUR_API_KEY header.

The contact object

{
  "id": "ct_abc123",
  "email": "jane@example.com",
  "email_normalized": "jane@example.com",
  "first_name": "Jane",
  "last_name": "Doe",
  "attributes": { "plan": "pro" },
  "status": "subscribed",
  "eligibility": "eligible",
  "eligibility_reason": null,
  "consent_source": "api",
  "consent_at": "2026-01-15T10:30:00Z",
  "unsubscribed_at": null,
  "created_at": "2026-01-15T10:30:00Z",
  "updated_at": "2026-01-15T10:30:00Z"
}
FieldTypeDescription
idstringUnique contact identifier.
emailstringEmail address as provided.
email_normalizedstringNormalized address used for de-duplication and suppression cross-checks.
first_namestring | nullFirst name.
last_namestring | nullLast name.
attributesobjectYour own key/value pairs (e.g. { "plan": "pro" }). Each key becomes a merge tag ({{ plan }}) and a segmentation field. Keys are validated against your registered contact fields — an unregistered key is rejected, not silently stored. Defaults to {}.
statusstringOne of subscribed, unsubscribed, bounced, complained, suppressed, pending.
eligibilitystringeligible or ineligible. An ineligible contact can never receive a campaign, regardless of segments or list membership. See Eligibility.
eligibility_reasonstring | nullWhy, if ineligible.
consent_sourcestring | nullHow consent was captured: api (created via this API), csv_import, form, or manual (added in the dashboard).
consent_atstring | nullISO 8601 timestamp consent was recorded.
unsubscribed_atstring | nullISO 8601 timestamp of global opt-out, if any.
created_atstringISO 8601 creation timestamp.
updated_atstringISO 8601 last-update timestamp.

List contacts

GET /v1/contacts

Returns a paginated page of contacts.

Query parameters

ParameterTypeDescription
pagenumber1-based page number (default 1).
limitnumberPage size, 1–100 (default 50).
statusstringFilter by lifecycle status.
list_idstringRestrict to members of a given list.
searchstringCase-insensitive email substring match.
sincestringISO 8601. Return only contacts updated at or after this time, ordered by (updated_at, id). See Polling for changes.
cursorstringKeyset cursor from the previous page's next_cursor. Use with since.

Response

{
  "contacts": [ /* contact objects */ ],
  "total": 1280,
  "page": 1,
  "totalPages": 26
}
curl "https://api.envloped.com/v1/contacts?status=subscribed&limit=50" \
  -H "Authorization: Bearer en_abc123..."

Create a contact

POST /v1/contacts

Creates a single contact and, optionally, adds it to lists. This is a strict create — a contact with the same normalized email already existing returns 409.

Request body

ParameterTypeRequiredDescription
emailstringYesEmail address.
first_namestringNoFirst name.
last_namestringNoLast name.
attributesobjectNoAttribute values, validated against your registered contact fields. Each key becomes a campaign merge tag ({{ plan }}) and a segmentation field. An unregistered key returns 422. Defaults to {}.
list_idsstring[]NoLists to add the new contact to. Unknown ids are reported in invalid_lists.

Consent is stamped at creation time (consent_at = now()), and the caller IP is recorded on the consent record. Contacts created through this API always have consent_source: "api" — it reflects the channel consent came through, so it is set automatically and cannot be overridden in the request.

Response — 201 Created

{
  "contact": { /* contact object */ },
  "added_to_lists": ["ls_newsletter"],
  "invalid_lists": []
}
curl -X POST https://api.envloped.com/v1/contacts \
  -H "Authorization: Bearer en_abc123..." \
  -H "Content-Type: application/json" \
  -d '{
    "email": "jane@example.com",
    "first_name": "Jane",
    "attributes": { "plan": "pro" },
    "list_ids": ["ls_newsletter"]
  }'

Errors

StatuserrorMeaning
400invalid_emailThe email failed validation.
409duplicate_contactA contact with this normalized email already exists.
422unknown_fieldAn attributes key has no registered contact field. The offending keys are listed in unknown_keys.
422invalid_attributeAn attributes value does not match its field's type. The offending keys are listed in invalid.

Create or update contacts in bulk

POST /v1/contacts/batch

Upsert up to 1000 contacts per request, keyed on the normalized email. This is the endpoint to sync an approvals table or a members table from your own backend — no CSV, no nightly job.

Request body

ParameterTypeRequiredDescription
contactsarrayYesUp to 1000 contact objects (below).
attributes_modestringNomerge (default) or replace, applied to every row.

Each entry in contacts:

ParameterTypeRequiredDescription
emailstringYesEmail address.
member_statusstringYesapproved, rejected, pending, or removed. Drives eligibility. Omitting it is an invalid row, never an implicit approval — that is the one mistake this endpoint exists to make impossible.
first_name / last_namestringNo
attributesobjectNoValidated against your contact fields.
list_idsstring[]NoLists to add the contact to.

Response — always 200

Partial success is the contract. The request only fails as a whole if it is malformed.

{
  "processed": 1000,
  "created": 812,
  "updated": 176,
  "rejected_ineligible": 11,
  "invalid": 1,
  "results": [
    { "email": "jane@example.com", "result": "created", "id": "ct_abc123" },
    { "email": "rejected@example.com", "result": "rejected_ineligible" },
    { "email": "typo@example.com", "result": "invalid",
      "reason": "invalid_attributes", "unknown_fields": ["citty"] }
  ]
}
resultMeaning
createdNew contact.
updatedExisting contact updated.
rejected_ineligiblemember_status was not approved; nothing was created.
invalidThis row failed validation; see reason, plus unknown_fields / invalid_fields / invalid_lists where relevant. The rest of the batch still applied — one typo'd attribute key must not discard 999 good rows.
curl -X POST https://api.envloped.com/v1/contacts/batch \
  -H "Authorization: Bearer en_abc123..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: members-backfill-batch-17" \
  -d '{
    "attributes_mode": "merge",
    "contacts": [{
      "email": "jane@example.com",
      "member_status": "approved",
      "first_name": "Jane",
      "attributes": { "city": "Singapore", "date_of_birth": "1996-04-12" },
      "list_ids": ["ls_members"]
    }]
  }'

Send an Idempotency-Key and a replay returns the original receipt verbatim rather than re-running — which is what makes a 35-request backfill reconcilable if one response goes missing.

Two behaviours worth knowing:

  • Never resurrect. An existing unsubscribed contact stays unsubscribed even if the sync says approved. An unsubscribe is the contact's decision, not your system's.
  • Contacts and list membership commit together. The receipt certifies a committed state, so it cannot report a contact that landed without its lists.

Eligibility

Eligibility is a separate gate from subscription status and from deliverability suppression. It answers is this person allowed to be mailed at all — the case where your own system says someone is not a member, was rejected, or was removed.

  • A batch row whose member_status is not approved never creates a contact and comes back rejected_ineligible with HTTP 200 — so you can fire your entire members table at this endpoint without branching.
  • An existing contact that flips from approved to rejected, pending or removed is set to eligibility: "ineligible" and suppressed. It is not deleted: deleting loses the memory of why they must not be mailed.
  • At send time, an ineligible contact is recorded as skipped with the reason ineligible and is never mailed. No segment, list, or campaign can override this.

Re-approving a contact restores eligibility but does not lift the suppression that was written on downgrade — clear it deliberately if that is what you want.

Polling for changes

To mirror unsubscribes back into your own database, poll with since:

curl "https://api.envloped.com/v1/contacts?status=unsubscribed&since=2026-08-09T00:00:00Z" \
  -H "Authorization: Bearer en_abc123..."
{
  "contacts": [ /* contact objects */ ],
  "next_cursor": "eyJ1IjoiMjAyNi0wOC0wOVQxMDozMDowMFoiLCJpIjoiY3RfYWJjIn0"
}

Follow next_cursor until it comes back null; since only needs to be sent on the first request. This is a keyset poll over (updated_at, id), not offset paging — under an active writer, offset paging silently skips rows, which for an unsubscribe feed means missing an opt-out.

Retrieve a contact

GET /v1/contacts/:id

Returns the contact plus its live list memberships.

{
  "contact": { /* contact object */ },
  "lists": [
    { "id": "ls_newsletter", "name": "Newsletter", "unsubscribed_at": null }
  ]
}

Returns 404 with { "error": "Contact not found" } if the id is unknown.

Update a contact

PATCH /v1/contacts/:id

Updates mutable fields. Only supplied fields change. Setting status to unsubscribed stamps unsubscribed_at; moving to subscribed/pending clears it.

Request body

ParameterTypeDescription
first_namestring | nullNew first name (null clears it).
last_namestring | nullNew last name (null clears it).
attributesobjectAttribute values, validated against your contact fields.
attributes_modestringmerge (default) shallow-merges the supplied keys over the existing object; replace swaps it wholesale. Use merge when sending partial payloads, or you will wipe fields you did not include.
statusstringNew lifecycle status.

An attribute that fails validation returns 422 naming the offending keys.

curl -X PATCH https://api.envloped.com/v1/contacts/ct_abc123 \
  -H "Authorization: Bearer en_abc123..." \
  -H "Content-Type: application/json" \
  -d '{ "status": "unsubscribed" }'

Response: { "contact": { /* updated contact */ } }.

Delete a contact (GDPR erasure)

DELETE /v1/contacts/:id

Hard delete. The contact row is removed permanently and its list memberships are cascaded away. Campaign history is preserved with the contact reference nulled, so aggregate reports stay intact while the PII is gone. This is the endpoint to satisfy a right-to-erasure request.

{ "deleted": true, "id": "ct_abc123" }

Export contacts

GET /v1/contacts/export

Streams the full contact list as a CSV file (Content-Type: text/csv) with the columns email, first_name, last_name, status, consent_source, consent_at, unsubscribed_at, created_at. The export is keyset-paginated server-side so it stays memory-flat for very large audiences.

curl "https://api.envloped.com/v1/contacts/export" \
  -H "Authorization: Bearer en_abc123..." \
  -o contacts.csv

SDKs & CLI

The same operations are available in the official SDKs and CLI:

// JavaScript / TypeScript
await client.contacts.create({ email: 'jane@example.com', list_ids: ['ls_newsletter'] });
const { contacts } = await client.contacts.list({ status: 'subscribed' });
// Go
client.Contacts.Create(&envloped.CreateContactRequest{Email: "jane@example.com"})
# CLI
envloped contacts create jane@example.com --list ls_newsletter
envloped contacts list --status subscribed

On this page