> ## Documentation Index
> Fetch the complete documentation index at: https://help.retainful.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Events API

> Track custom events that build timelines, power segments, and trigger automations.

Events are timestamped records of things contacts do — in your store, your app, or anywhere else. Each event lands on the contact's [timeline](/audience/contacts), becomes available to [segments](/audience/segments), and can [trigger automations](/automations/triggers#custom-events).

All requests require [authentication](/developers/authentication).

## Track an event

`POST /api/v1/events`

Records an event and attaches it to a contact, creating that contact if they don't exist yet. Returns `201 Created`.

### Request

<ParamField body="eventName" type="string" required>
  Human-readable event name, e.g. `Add to Cart`. Any non-empty string is accepted — see [How event names become types](#how-event-names-become-types).
</ParamField>

<ParamField body="uniqueIdentifier" type="string" required>
  Idempotency key for this occurrence — a cart ID, checkout ID, or order ID. See [Idempotency](#idempotency); getting this wrong is the most common integration bug.
</ParamField>

<ParamField body="contact" type="object" required>
  Who the event belongs to. Must contain either `contactUUID`, or at least one of `email` / `phone`.
</ParamField>

<ParamField body="eventData" type="object" required>
  Flat key-value object describing the event. Must not be empty, and must not contain nested objects.
</ParamField>

<ParamField body="description" type="string">
  Optional human-readable description, used when registering the event schema.
</ParamField>

#### The `contact` object

| Field                            | Notes                                                                                                                                          |
| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `contactUUID`                    | Looks up an existing contact directly. If no contact has this ID, the request fails with `400 CONTACT_NOT_FOUND` — it does **not** create one. |
| `email`                          | Used to match or create the contact.                                                                                                           |
| `phone`                          | Used to match or create the contact.                                                                                                           |
| `external_id`                    | Your own identifier for this person.                                                                                                           |
| `first_name`, `last_name`        | Stored on the contact.                                                                                                                         |
| `email_opt_in`, `phone_opt_in`   | `SUBSCRIBED`, `NON_SUBSCRIBED`, or `UNSUBSCRIBED`.                                                                                             |
| `is_suppressed`, `double_opt_in` | Booleans.                                                                                                                                      |
| `custom_properties`              | Key-value pairs stored as [custom fields](/audience/custom-fields).                                                                            |

Field names are snake\_case. Unrecognized fields are rejected with `400`, so `emailAddress` or `firstName` will fail — use `email` and `first_name`.

<Warning>
  When this endpoint creates a new contact, `email_opt_in` defaults to `NON_SUBSCRIBED`. A contact created this way will not receive marketing email until they opt in. Send `email_opt_in` explicitly if you have consent.
</Warning>

```bash Example request theme={null}
curl -X POST "https://api.retainful.net/api/v1/events" \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $RETAINFUL_API_KEY" \
  -d '{
    "eventName": "Add to Cart",
    "uniqueIdentifier": "cart_12345",
    "contact": {
      "email": "jane@example.com",
      "first_name": "Jane"
    },
    "eventData": {
      "product_id": "SKU-100",
      "product_name": "Blue T-Shirt",
      "quantity": "2",
      "price": "19.99",
      "currency": "USD"
    }
  }'
```

### Response

```json 201 Created theme={null}
{
  "data": {
    "type": "event",
    "id": "01K8J5M02QYPEJE5YN3V944ZY9",
    "attributes": {
      "event_type": "REST-API.ADD_TO_CART",
      "occurred_at": "2026-06-11T12:00:00.000Z",
      "profile_id": "01K8J5M02QYPEJE5YN3V944ZY9"
    }
  }
}
```

The event row is written immediately. Schema registration and automation delivery happen asynchronously a moment later.

## Idempotency

`uniqueIdentifier` is what stops a retried HTTP call from emailing your customer twice. Uniqueness is scoped to the combination of **your organization, the event type, and the identifier**.

| You send                                                     | What happens                                                              |
| ------------------------------------------------------------ | ------------------------------------------------------------------------- |
| A new `uniqueIdentifier`                                     | The event is stored and any matching automation triggers.                 |
| The **same**`uniqueIdentifier` again, same `eventName`       | The stored event's data is updated. Automations do **not** trigger again. |
| The same `uniqueIdentifier` under a **different**`eventName` | Treated as a distinct event. Automations **do** trigger.                  |

So: use a stable ID per real-world occurrence (`order_55501`), and a **new** ID when you want the flow to fire again.

<Note>
  Two things still happen on a resubmit: the contact is upserted (so profile fields update), and the event schema is re-registered. Only the automation trigger is suppressed.
</Note>

## How event names become types

You send a human-readable `eventName`. Retainful normalizes it and prefixes it with your integration's key to produce the internal event type you'll see in the dashboard:

| `eventName` you send | Internal event type           |
| -------------------- | ----------------------------- |
| `Add to Cart`        | `REST-API.ADD_TO_CART`        |
| `Checkout Started`   | `REST-API.CHECKOUT_STARTED`   |
| `Purchase Completed` | `REST-API.PURCHASE_COMPLETED` |
| `Placed Order`       | `REST-API.PLACED_ORDER`       |

Normalization uppercases the name and replaces every run of non-alphanumeric characters with a single underscore. The `REST-API.` prefix comes from the integration the API key belongs to.

**There is no fixed list of event names.** Any non-empty string works — `Quiz Completed` and `Subscription Renewed` are as valid as the commerce names above.

<Warning>
  Because names are normalized, `Add to Cart`, `add-to-cart`, and `ADD TO CART` all collapse to the same event type and share the same idempotency namespace. Pick one spelling and keep it stable — automations and segments are bound to the normalized type, so renaming an event orphans them.
</Warning>

<Note>
  Keep event names under about 50 characters. The internal type is stored in a 64-character column and includes the integration prefix; longer names fail on write.
</Note>

## eventData rules

* **Must not be empty.**
* **No nested objects.** A nested object is rejected with `400` and a message naming the offending field. Flatten it: `shipping_city` rather than `shipping: { city }`.
* **Values may be strings, numbers, or booleans.** Types are inferred and preserved for use in automation filters, so send `"amount": 19.99` if you want to compare it numerically.
* Useful keys: `product_id`, `product_name`, `quantity`, `price`, `currency`, `order_id`, `checkout_id`, `total`, `amount`.
* Every key becomes available in automation trigger filters and email personalization.

## Register an event schema

`POST /api/v1/events/register`

Registers an event type and its shape **without** attaching it to a contact. Use it so the event's fields appear in the automation builder's filter and personalization pickers before any real event has fired — letting marketing build the flow while engineering ships the integration.

<ParamField body="eventName" type="string" required>
  The event name to register.
</ParamField>

<ParamField body="eventData" type="object" required>
  An example payload. Retainful infers the field names and types from it.
</ParamField>

<ParamField body="description" type="string">
  Optional description of the event.
</ParamField>

```bash theme={null}
curl -X POST "https://api.retainful.net/api/v1/events/register" \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $RETAINFUL_API_KEY" \
  -d '{
    "eventName": "Appointment Booked",
    "description": "Fired when a customer books a consultation",
    "eventData": {
      "service": "Consultation",
      "staff": "Dr. Lee",
      "date": "2026-07-01",
      "value": "120.00"
    }
  }'
```

<Warning>
  This endpoint registers a schema only. It does **not** store an event, does not appear on any contact's timeline, and does not trigger automations. It accepts neither `contact` nor `uniqueIdentifier` — sending either returns `400`. Use `POST /api/v1/events` for everything tied to a real shopper.
</Warning>

## Designing good events

* **Name by fact, not intent**: `Subscription Renewed` (what happened), not `Send Renewal Email` (what you want done). Marketing decides the response in the automation builder.
* **Keep names stable.** Renaming orphans the automations and segments built on the old type.
* **Include the fields you'll filter on.** If a flow should only fire for high-value renewals, send `amount`.
* **Send one event per real occurrence**, with a `uniqueIdentifier` that matches that occurrence's own ID.

## Event-triggered automations end to end

1. `POST /api/v1/events/register` with your event shape.
2. In the dashboard, create an [automation](/automations/create-an-automation) triggered by your event, with filters like `plan is annual`.
3. `POST /api/v1/events` from production whenever it happens, with a fresh `uniqueIdentifier` each time.
4. Watch contacts flow through in the automation's [analytics](/automations/analytics).

## Errors

| Code                          | Meaning                                                |
| ----------------------------- | ------------------------------------------------------ |
| `VALIDATION_ERROR`            | A field is missing, the wrong type, or not recognized. |
| `EVENT_NAME_REQUIRED`         | `eventName` was empty.                                 |
| `CONTACT_REQUIRED`            | No `contact` object was sent.                          |
| `CONTACT_IDENTIFIER_REQUIRED` | `contact` had no `contactUUID`, `email`, or `phone`.   |
| `CONTACT_NOT_FOUND`           | The `contactUUID` you sent doesn't match a contact.    |

See [Errors](/developers/errors) for the response shape.
