MailcrateDocs

Syncing Contacts and Events

Keep Mailcrate's contacts in step with your own users, and record what they do as events that segments and journeys can act on.

Two parts of the API carry your product's data into Mailcrate. Contacts are the people you email, with properties such as their plan or country. Events are things a contact did, such as checkout_started or trial_ended, with a payload of their own. Segments filter on both, and journeys start, wait and branch on both.

This page covers the integration side. For what contacts, properties and events look like in the app, see Contacts, Custom Properties and Contact Events.

You want toUseScope
Create, read or update a person/public/contactcontact:write, contact:read
Record that someone did somethingPOST /public/eventevent:write
Read back what someone didGET /public/eventevent:read
Hear about changes made in MailcrateWebhookswebhook:write to set up

Identify contacts by your own id

Every contact has an email address, which is unique in the workspace (compared case-insensitively). It can also carry an external identifier: your own id for the person, such as a user id. Set one whenever you have it. Wherever a route takes a contact {id}, it accepts either Mailcrate's contact id or your external identifier, so you never have to store Mailcrate's ids:

Terminal
curl https://api.mailcrate.io/public/contact/user_8231 \
  -H "Authorization: Api-Key $MAILCRATE_API_KEY"
200 OK
{
  "id": "3f0d8a52-1c4e-4b8e-9a51-7f3c2b6d9e10",
  "email": "ada@example.com",
  "externalIdentifier": "user_8231",
  "status": "subscribed",
  "properties": { "first_name": "Ada", "plan": "pro" },
  "created": "2026-08-03T10:12:44.120Z",
  "updated": "2026-08-03T10:12:44.120Z"
}

External identifiers are unique per workspace too. The contact routes do not look contacts up by email; events and the journey endpoints do.

Create and update contacts

POST/public/contactscope · contact:write
Terminal
curl -X POST https://api.mailcrate.io/public/contact \
  -H "Authorization: Api-Key $MAILCRATE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "ada@example.com",
    "externalIdentifier": "user_8231",
    "properties": { "first_name": "Ada", "plan": "pro" }
  }'

The response is the contact, in the shape shown above. A new contact starts as subscribed.

PUT/public/contact/{id}scope · contact:write

Send only what changed. Every field is optional:

Terminal
curl -X PUT https://api.mailcrate.io/public/contact/user_8231 \
  -H "Authorization: Api-Key $MAILCRATE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "properties": { "plan": "enterprise" } }'
  • Properties are merged. Keys you send overwrite the stored value; keys you leave out keep theirs.
  • email can be changed but not cleared: "email": null is a 400.
  • externalIdentifier can be set or changed. Sending null leaves it as it is.

Property rules

  • A key the workspace has never seen creates a new text property on the fly. To get a typed property (number, date, checkbox and so on), create it in the app first, under Custom Properties.
  • A value written to a typed property must fit its type, or the whole request is rejected with a per-property message. See Contact property values.
  • One request may carry at most 100 property keys and 32 KB of serialized JSON.
  • timezone is reserved: its value must be an IANA zone name such as Europe/Amsterdam. Local-time campaigns schedule each contact by it.

Keep them in sync

There is no upsert, so a sync job checks first and then creates or updates. Look the person up by your own id:

sync-contact.ts
const API = 'https://api.mailcrate.io/public';
const headers = {
  Authorization: `Api-Key ${process.env.MAILCRATE_API_KEY}`,
  'Content-Type': 'application/json',
};

export async function syncContact(user: { id: string; email: string; plan: string }) {
  const body = JSON.stringify({
    email: user.email,
    externalIdentifier: user.id,
    properties: { plan: user.plan },
  });

  const existing = await fetch(`${API}/contact/${encodeURIComponent(user.id)}`, { headers });

  if (existing.status === 404) {
    const created = await fetch(`${API}/contact`, { method: 'POST', headers, body });
    if (!created.ok) throw new Error(`create failed: ${created.status} ${await created.text()}`);
    return created.json();
  }

  if (!existing.ok) throw new Error(`lookup failed: ${existing.status}`);

  const updated = await fetch(`${API}/contact/${encodeURIComponent(user.id)}`, { method: 'PUT', headers, body });
  if (!updated.ok) throw new Error(`update failed: ${updated.status} ${await updated.text()}`);
  return updated.json();
}

A create for an email address or external identifier that already belongs to a contact is refused with 400 Bad Request and a message naming which one (Contact with email ada@example.com already exists). That happens when the person exists in Mailcrate without your id on it, for example after a CSV import. Attach your id once with a PUT on Mailcrate's contact id (shown on the contact in the app, and carried by the contact.created webhook), and the lookup above finds them from then on.

For a large backfill, a CSV import is quicker than one request per person and does not spend your rate limit.

The first time a workspace adds contacts, someone has to confirm that these people agreed to receive email. Until that is done, every route that creates a contact (POST /public/contact, and the event and journey hook routes below when they create one) answers 403:

403 Forbidden
{
  "statusCode": 403,
  "error": "ContactConsentRequired",
  "code": "WORKSPACE_CONTACT_CONSENT_REQUIRED",
  "message": "This workspace must confirm the \"contact_consent\" statement before adding contacts. Accept the current version (\"2026-07\") at POST /workspace/attestations, then retry.",
  "requiresAttestation": true,
  "policy": { "key": "contact_consent", "version": "2026-07", "statement": "I confirm I will only send email to recipients who have given consent …" }
}

Branch on code. POST /workspace/attestations in the message is the app's own endpoint, not one a workspace API key can call: a person confirms the statement in the app, the first time they add or import a contact. Platforms that provision workspaces with an organization key can confirm it with POST /public/organization/workspaces/{id}/contact-consent.

Unsubscribe

DELETE/public/contact/{id}scope · contact:write

On the public API, DELETE unsubscribes the contact rather than deleting it. The record and its identifiers stay, its status becomes unsubscribed, and the address is added to the suppression list so marketing mail stops even if the contact is later deleted and imported again. The response is { "success": true }, also for a contact that was already unsubscribed. The API cannot subscribe a contact again; that is done in the app. For opting out of one kind of mail only, use subscription groups instead.

Record an event

POST/public/eventscope · event:write
Terminal
curl -X POST https://api.mailcrate.io/public/event \
  -H "Authorization: Api-Key $MAILCRATE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "checkout_started",
    "externalIdentifier": "user_8231",
    "email": "ada@example.com",
    "properties": { "cartValue": 129.5, "items": 3, "currency": "EUR" },
    "idempotencyKey": "checkout-5512"
  }'
200 OK
{
  "id": "9b2f6d1e-0a7c-4e33-8f6a-51c2d9e0b4a7",
  "name": "checkout_started",
  "contactId": "3f0d8a52-1c4e-4b8e-9a51-7f3c2b6d9e10",
  "occurredAt": "2026-08-03T11:02:10.004Z",
  "deduplicated": false
}
FieldRequiredWhat it is
nameYes1 to 100 characters. Starts with a letter or digit, then letters, digits, spaces, ., _ and -. Leading and trailing spaces are trimmed.
contactId, externalIdentifier, emailAt least oneWho did it. See below.
propertiesNoAny JSON object, up to 32 KB. Stored as sent, with no property types applied.
occurredAtNoISO 8601 time it happened. Defaults to now. May be in the past, but not more than 5 minutes in the future.
idempotencyKeyNoUp to 255 characters. Makes a retry safe, see below.

Who did it

Mailcrate looks for the contact by contactId (a contact id or external identifier), then by externalIdentifier, then by email, and uses the first match. If none matches:

  • with an email, it creates the contact, with your externalIdentifier on it if you sent one. That makes the first event for a new user enough on its own; no separate create call is needed. The consent gate applies.
  • without one, it answers 404 Contact not found.

Retries and duplicates

Send an idempotencyKey derived from the thing that happened, such as your order id, and a repeated request returns the original event with "deduplicated": true instead of recording it again. Nothing downstream runs a second time: no second journey enrollment, no second segment change. A key is scoped to the contact and the event name, so checkout-5512 on checkout_started and on checkout_completed are two different events.

Without a key, every request records a new event.

Names are a schema, not data

Each distinct name becomes an event definition in the workspace, which the app offers in segment rules and journey triggers. A workspace can hold 500 names; past that, a new name is refused with 400. Put what varies into properties: plan_upgraded with { "to": "pro" }, not plan_upgraded_to_pro.

What an event sets off

Recording an event is how your product drives automation:

  • A journey whose trigger is Event received with this name enrolls the contact, if the trigger's filters match the event's properties. The properties are then available to the journey as {{ trigger.<key> }}, for example {{ trigger.cartValue }}.
  • A journey step waiting for this event resumes, and a journey goal with this event name is marked reached.
  • Segments with a rule on this event are re-evaluated for the contact.

The journey engine picks events up within a few seconds, not inside the request. Events with an occurredAt in the past trigger journeys exactly like new ones, so backfilling history into a workspace with live event-triggered journeys enrolls those contacts now. Pause those journeys first if that is not what you want.

Read events back

GET/public/eventscope · event:read

Events are read per contact. contact is required and takes a contact id, external identifier or email address; name narrows to one event name. The list is paginated:

Terminal
curl "https://api.mailcrate.io/public/event?contact=user_8231&name=checkout_started&limit=20" \
  -H "Authorization: Api-Key $MAILCRATE_API_KEY"

Each item carries id, name, contactId, properties, occurredAt and created.

Hear about changes made in Mailcrate

Some changes happen on Mailcrate's side: a contact unsubscribes from an email, an import creates contacts, a journey step updates a property. Subscribe a webhook to contact.created, contact.updated, contact.deleted and contact.unsubscribed to copy them back into your system. A contact.unsubscribed in particular is worth honouring in your own product too.

Next steps

On this page