Syncing contacts
The contact model
| Field | Type | Notes |
|---|---|---|
id | uuid | Stable contact identifier |
email | string | Unique per team; the upsert key |
status | string | One of subscribed, unsubscribed, bounced, complained — see Contact statuses |
properties | mapstringstring | Free-form key/value data; empty as {}, never null |
subscribed_topic_ids | uuid | Topics the contact opted into |
created_at | timestamp | RFC 3339 |
updated_at | timestamp | RFC 3339 |
Bulk upsert
POST /v1/contacts/bulk accepts up to 1000 contacts per call (400 batch_too_large beyond that). Each item needs at least an email:
const results = await notideus.contacts.bulk([
{ email: 'jane@example.com', properties: { plan: 'pro' } },
{ email: 'john@example.com', properties: { plan: 'free' } },
{ email: 'not-an-email', properties: {} }
]);
$results = $notideus->contacts()->bulk([
['email' => 'jane@example.com', 'properties' => ['plan' => 'pro']],
['email' => 'john@example.com', 'properties' => ['plan' => 'free']],
['email' => 'not-an-email', 'properties' => []]
]);
{
"contacts": [
{ "email": "jane@example.com", "properties": { "plan": "pro" } },
{ "email": "john@example.com", "properties": { "plan": "free" } },
{ "email": "not-an-email", "properties": {} }
]
}
Each item result is a success (id) or an error — narrow with 'error' in item (isset($item['error']) in PHP) as in Sending
batches.
Upsert is keyed on the email address: an existing contact is updated in place, a new one is created. Upserting never changes status — re-running a sync cannot accidentally resubscribe anyone.
A real response (200 OK):
{
"data": [
{
"index": 0,
"id": "01a0a11c-81b6-7531-961e-c5e5bdacf7a6"
},
{
"index": 1,
"id": "01a0a11c-c703-7e67-ae8c-ecb86e191395"
},
{
"index": 2,
"error": {
"code": "bad_request",
"message": "invalid email address"
}
}
]
}
Decode:
- Item 0 —
jane@example.comalready existed, so the response carries her existingid: upsert semantics, not create-or-fail. - Item 1 —
john@example.comwas created;idis his new contact. - Item 2 —
not-an-emailfailed validation with 400bad_request(invalid email address); nothing was stored for it.
Updating properties
PATCH /v1/contacts/:email replaces the whole properties map — omitting a key deletes it. Merge in your code first:
const current = await notideus.contacts.get('jane@example.com');
const properties = { ...current.properties, plan: 'pro' }; // merge, don't replace
await notideus.contacts.update('jane@example.com', { properties });
$current = $notideus->contacts()->get('jane@example.com');
$properties = array_merge($current['properties'], ['plan' => 'pro']); // merge, don't replace
$notideus->contacts()->update('jane@example.com', ['properties' => $properties]);
curl -X PATCH https://api.notideus.io/v1/contacts/jane@example.com \
-H "Authorization: Bearer $NT_KEY" \
-H "Content-Type: application/json" \
-d '{"properties":{"plan":"pro","signup":"2026-09-14"}}'
Unsubscribe flows
User-initiated — from your own unsubscribe UI, keyed by email:
const contact = await notideus.contacts.unsubscribe('jane@example.com');
console.log(contact.status); // "unsubscribed"
$contact = $notideus->contacts()->unsubscribe('jane@example.com');
echo $contact['status'] . PHP_EOL; // "unsubscribed"
curl -X POST https://api.notideus.io/v1/contacts/jane@example.com/unsubscribe \
-H "Authorization: Bearer $NT_KEY"
Returns the updated contact (200 OK):
{
"id": "01a0a11c-81b6-7531-961e-c5e5bdacf7a6",
"email": "jane@example.com",
"status": "unsubscribed",
"properties": {
"plan": "pro"
},
"subscribed_topic_ids": [],
"created_at": "2026-09-14T18:09:55.124431Z",
"updated_at": "2026-09-14T18:10:12.884793Z"
}
Calling it again on an already-unsubscribed contact is a no-op — the endpoint is idempotent.
Programmatic one-shot — for a support tool acting on an arbitrary address: POST /v1/marketing/unsubscribe accepts an email and returns 204 No Content with no response body:
curl -X POST https://api.notideus.io/v1/marketing/unsubscribe \
-H "Authorization: Bearer $NT_KEY" \
-H "Content-Type: application/json" \
-d '{"email":"jane@example.com"}'
List hygiene
bounced or complained: the resubscribe endpoint returns 409 contact_suppressed. Suppression is deliverability-driven — it comes from SES bounce and complaint events, not from your API calls — and overrides marketing intent. See Email lifecycle.