Guides

Sending contact events

Tell automations what happened in your app: batching, retries and backfill.

An automation reacts to things. Some of them Mailyte sees for itself — a contact joins a list, clicks a link, replies. The rest happen in your system: a checkout started, an order completed, an application submitted. A contact event is how you tell us about those, and it is what lets a journey start on one, wait for one, or stop the moment one arrives.

This guide is for the developer wiring that up. The person building the journey sees your event names in a dropdown; everything below is what has to be true for them to appear there and behave.

What an event is, and is not

An event is a fact: this person did this thing, at this time, with these details. It is recorded against a contact and kept for 90 days.

Three things it is not, and each one is a mistake someone makes on day one:

It is not consent. Sending an event for an address we have never seen creates a contact with the status unconfirmed. That contact can enter a journey — it can be tagged, added to a list, passed to your CRM — but no automated email reaches it until it is subscribed. This is deliberate: without it, an events endpoint would be a way to mail anyone whose address passed through your checkout.

When a person does opt in, record it through the contacts endpoints, with the evidence:

curl -X PATCH "$MAILYTE_BASE/api/v1/contacts/{contact_id}" \
  -H "Authorization: Bearer $MAILYTE_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "status": "subscribed",
    "consented_at": "2026-09-23T10:02:00+01:00",
    "consent_source": "checkout: newsletter box ticked"
  }'

Or create the contact that way in the first place, before its first event, with POST /api/v1/contacts. See Running a campaign for contacts.

It is not a profile update. properties describe the occurrence — this order's total, this cart's items — and never change the contact's own details. To change those, use the contacts endpoints, or have the journey do it with its "Change their details" step.

It is not a delivery event. Opens, clicks, bounces and replies come from our mail server and are already available to every journey. You cannot send them, and there is no need to.

1. A key that can send events

In the dashboard, open Developer → API Keys and create a key for the system that will send events. Under Automations, tick Record on the Contact events row. That is the contact_events:write scope, and it is the only one this endpoint needs.

If the same system should also read the list of event names, add Read on the Automations row (automations:read). Nothing else.

2. Send one

curl -X POST "$MAILYTE_BASE/api/v1/contact-events" \
  -H "Authorization: Bearer $MAILYTE_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "event": "checkout_started",
    "email": "ada@example.com",
    "occurred_at": "2026-09-23T10:04:11+01:00",
    "unique_id": "checkout-81234",
    "properties": { "cart_value": 45000, "currency": "NGN", "items": 3 }
  }'

The answer is 202 Accepted:

{
  "success": true,
  "message": "Events accepted",
  "data": { "accepted": 1, "duplicates": 0, "rejected": [] },
  "code": 200
}

The envelope's code says 200 whatever the status line says; go by the HTTP status.

A 202 does not mean the event was recorded. Read rejected. Each event is checked on its own, so a request can be accepted with every event in it refused.

Field Required Notes
event Yes Lowercase snake_case, starting with a letter, up to 64 characters
email or contact_id One of them contact_id wins if you send both. An unknown email creates an unconfirmed contact
name No Used only when this event creates the contact
occurred_at No ISO 8601 with an offset. Defaults to now. No more than five minutes in the future
unique_id No, but send it Your id for this occurrence. See retries, below
properties No A JSON object, up to 16 KB encoded

Naming

The name is what the person building the journey picks from a list, so name the thing that happened, in the past tense, and never change it: order_completed, not order, purchase_v2 or OrderCompleted. A renamed event is a new event; journeys waiting for the old name keep waiting.

The ready-made journeys in the builder expect these names. Using them means a recipe works without anyone retyping anything:

Recipe Starts on Stops on
Abandoned checkout checkout_started order_completed
Post-purchase order_completed —
Time to restock order_completed with properties.category of consumable order_completed
Assessment reminder application_submitted assessment_completed

Properties

Everything in properties reaches the journey. In an email it is {{ event.cart_value }}; in a check it is the field event.cart_value; nested objects are reached with dots, {{ event.shipping.city }}.

Send numbers as numbers and times as ISO 8601. The builder's "greater than" and "before" compare values of those types and treat anything unparseable as a non-match — a total sent as "₦45,000" is never greater than anything. Text comparisons ignore case.

Batching

Put up to 500 events in events:

curl -X POST "$MAILYTE_BASE/api/v1/contact-events" \
  -H "Authorization: Bearer $MAILYTE_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "events": [
      { "event": "order_completed", "email": "ada@example.com",   "unique_id": "order-50917" },
      { "event": "order_completed", "email": "not-an-address",    "unique_id": "order-50918" },
      { "event": "Order Completed", "email": "tunde@example.com", "unique_id": "order-50919" }
    ]
  }'
{
  "accepted": 1,
  "duplicates": 0,
  "rejected": [
    { "index": 1, "reason": "email or contact_id is required and must belong to this organization." },
    { "index": 2, "reason": "event must be lowercase snake_case, up to 64 characters, starting with a letter." }
  ]
}

index is the position in the array you sent. Log the rejects and fix them at the source; do not resend the batch, because the accepted ones are already recorded.

The request fails as a whole only when its shape is wrong — no events, more than 500, an item that is not an object — or when the key lacks the scope.

Retries and unique_id

unique_id is your id for the occurrence: the order number, the checkout id. It is unique within your organization. An event that repeats one is counted under duplicates, recorded nowhere and triggers nothing — which is what makes this endpoint safe to retry.

So retry freely on a timeout, a 5xx or a 429, with the same body. The ones that got through the first time come back as duplicates.

Without a unique_id we derive one from the name, the person, occurred_at and the properties. That catches an exact repeat and nothing else: a retry without occurred_at gets a new timestamp, a new key, and a second event. If you cannot send a unique_id, always send occurred_at.

A unique_id is remembered for as long as its event is kept, which is 90 days.

Backfill and history

An event whose occurred_at is more than 7 days before we receive it is stored as historical. It is shown on the contact and it makes its name appear in the builder, but it never starts a journey, moves anyone along one, or takes anyone out.

That is the rule that makes backfill safe. Loading two years of orders the day you connect does not put every past buyer into "Post-purchase" at once.

Two things to know before you run one:

  • Events are kept for 90 days from occurred_at. Older ones are accepted — they count in accepted — and removed by the next nightly clean-up. There is no point sending them.
  • Journeys cannot pick an audience from past events. If history should decide who enters, put it on the contact as a detail — last_order_at, lifetime_orders — through the contacts endpoints. A journey can start on a date detail ("A date comes round") or on a segment built from details.

Rate limit

600 requests a minute per organization, shared by every key it has. With 500 events a request, that is far more than most systems produce; a system that hits it is almost always sending one request per event.

Over the limit you get 429 with Retry-After in seconds. Wait that long and resend the same body. Sending at volume has a retry loop you can reuse.

From your first event to the builder's dropdown

The person building a journey chooses an event from a list. That list is:

  1. Things Mailyte sees — our own events, always there.
  2. Things your app has told us about — every name received in the last 90 days, most recent first, with how many so far.

A name appears there the moment the first event carrying it is received. Nothing to register, nothing to deploy. The quickest way to hand a name to the builder is to send one real event, or one test event for your own address.

The builder can also take a name that has not arrived yet ("Something else, that my developer will send…"), so a journey can be finished before your integration ships. It has to match exactly what you then send.

Your code can read the same list:

curl "$MAILYTE_BASE/api/v1/contact-events/names" \
  -H "Authorization: Bearer $MAILYTE_API_KEY"

Checking it worked

Open the contact in the dashboard: its Events tab lists what was recorded, newest first, historical ones marked. Or, with a key that has contacts:read:

curl "$MAILYTE_BASE/api/v1/contacts/{contact_id}/events" \
  -H "Authorization: Bearer $MAILYTE_API_KEY"

If the event is there and a journey did not react, the journey is the place to look: its Who's in it tab says, per person, what happened at every step and why.

The other direction: the "Send to another system" step

A journey can also call you. Its "Send to another system" step POSTs JSON to an https:// address you give the builder:

{
  "event": "automation.webhook",
  "sent_at": "2026-09-23T10:05:00+00:00",
  "automation": { "id": "01JBT8XQ2M9WYC3K4F6R7S8T9V", "name": "Abandoned checkout" },
  "contact": {
    "id": "01JBT8Y0Q3V5N8K2M4R6T8W0XZ",
    "email": "ada@example.com",
    "name": "Ada Okoro",
    "attributes": { "plan": "pro" }
  },
  "run": {
    "id": "01JBT9A7C2K4M6P8R0T2V4X6Z8",
    "version_id": "01JBT8XR5N7Q9S1U3W5Y7A9C1E",
    "node_id": "n7",
    "entered_at": "2026-09-23T09:04:12+00:00"
  },
  "trigger": {
    "event_name": "checkout_started",
    "event": { "cart_value": 45000, "currency": "NGN", "items": 3 },
    "occurred_at": "2026-09-23T09:04:11+00:00"
  }
}

contact, run and trigger are each sent unless the step is set to leave them out. trigger is what the journey knows about how this person entered, so its shape follows the start: for an event, its name, properties and time, as above; for a list, tag or segment, which one.

  • Headers: User-Agent: Mailyte-Automations/1.0, X-Mailyte-Event: automation.webhook, plus up to ten of your own set on the step.
  • Signature: when the step has a secret, X-Webhook-Signature: sha256=<hex HMAC-SHA256 of the raw body>. The same scheme as delivery webhooks, so the verifier in Receiving webhooks works unchanged.
  • Delivery: 10-second timeout, any 2xx is success, up to three attempts a minute apart. The journey does not wait for you and does not stop if you fail; the outcome is recorded on the person's step.
  • Retries can repeat. Make the handler idempotent on run.id plus run.node_id.

Next

Receiving webhooks — verifying the signature on the requests a journey sends you.