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"
}| Field | Type | Description |
|---|---|---|
id | string | Unique contact identifier. |
email | string | Email address as provided. |
email_normalized | string | Normalized address used for de-duplication and suppression cross-checks. |
first_name | string | null | First name. |
last_name | string | null | Last name. |
attributes | object | Your 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 {}. |
status | string | One of subscribed, unsubscribed, bounced, complained, suppressed, pending. |
eligibility | string | eligible or ineligible. An ineligible contact can never receive a campaign, regardless of segments or list membership. See Eligibility. |
eligibility_reason | string | null | Why, if ineligible. |
consent_source | string | null | How consent was captured: api (created via this API), csv_import, form, or manual (added in the dashboard). |
consent_at | string | null | ISO 8601 timestamp consent was recorded. |
unsubscribed_at | string | null | ISO 8601 timestamp of global opt-out, if any. |
created_at | string | ISO 8601 creation timestamp. |
updated_at | string | ISO 8601 last-update timestamp. |
List contacts
GET /v1/contactsReturns a paginated page of contacts.
Query parameters
| Parameter | Type | Description |
|---|---|---|
page | number | 1-based page number (default 1). |
limit | number | Page size, 1–100 (default 50). |
status | string | Filter by lifecycle status. |
list_id | string | Restrict to members of a given list. |
search | string | Case-insensitive email substring match. |
since | string | ISO 8601. Return only contacts updated at or after this time, ordered by (updated_at, id). See Polling for changes. |
cursor | string | Keyset 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/contactsCreates 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
| Parameter | Type | Required | Description |
|---|---|---|---|
email | string | Yes | Email address. |
first_name | string | No | First name. |
last_name | string | No | Last name. |
attributes | object | No | Attribute 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_ids | string[] | No | Lists 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
| Status | error | Meaning |
|---|---|---|
400 | invalid_email | The email failed validation. |
409 | duplicate_contact | A contact with this normalized email already exists. |
422 | unknown_field | An attributes key has no registered contact field. The offending keys are listed in unknown_keys. |
422 | invalid_attribute | An 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/batchUpsert 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
| Parameter | Type | Required | Description |
|---|---|---|---|
contacts | array | Yes | Up to 1000 contact objects (below). |
attributes_mode | string | No | merge (default) or replace, applied to every row. |
Each entry in contacts:
| Parameter | Type | Required | Description |
|---|---|---|---|
email | string | Yes | Email address. |
member_status | string | Yes | approved, 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_name | string | No | |
attributes | object | No | Validated against your contact fields. |
list_ids | string[] | No | Lists 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"] }
]
}result | Meaning |
|---|---|
created | New contact. |
updated | Existing contact updated. |
rejected_ineligible | member_status was not approved; nothing was created. |
invalid | This 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
unsubscribedcontact 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_statusis notapprovednever creates a contact and comes backrejected_ineligiblewith HTTP200— so you can fire your entire members table at this endpoint without branching. - An existing contact that flips from approved to
rejected,pendingorremovedis set toeligibility: "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
ineligibleand 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/:idReturns 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/:idUpdates mutable fields. Only supplied fields change. Setting status to
unsubscribed stamps unsubscribed_at; moving to subscribed/pending clears
it.
Request body
| Parameter | Type | Description |
|---|---|---|
first_name | string | null | New first name (null clears it). |
last_name | string | null | New last name (null clears it). |
attributes | object | Attribute values, validated against your contact fields. |
attributes_mode | string | merge (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. |
status | string | New 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/:idHard 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/exportStreams 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.csvSDKs & 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