Notideus
Tutorials

Syncing contacts

Sync contacts from your database: bulk upsert, updates and unsubscribe flows.

The contact model

FieldTypeNotes
iduuidStable contact identifier
emailstringUnique per team; the upsert key
statusstringOne of subscribed, unsubscribed, bounced, complained — see Contact statuses
propertiesmapstringstringFree-form key/value data; empty as {}, never null
subscribed_topic_idsuuidTopics the contact opted into
created_attimestampRFC 3339
updated_attimestampRFC 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: {} }
]);

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.com already existed, so the response carries her existing id: upsert semantics, not create-or-fail.
  • Item 1 — john@example.com was created; id is his new contact.
  • Item 2 — not-an-email failed validation with 400 bad_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 });

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"

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

Resubscription is blocked for contacts whose status is 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.
Copyright © 2026