Skip to content

Documentation

Build with Engage

Connect customer data, business events, and communication channels to build automated journeys with workflows and AI.

Customers

A customer is a person you can message. Getting their identity right is the difference between one record and four.

You rarely need to create one

An event carrying a phone number enrols the customer if they are new and matches them if they are not. For most integrations that is the whole of it — you send events, and the people look after themselves.

Create one explicitly when you have someone before you have anything to say about them: importing an existing customer list, or enrolling somebody at signup so a welcome workflow has a person to run against.

request

curl -X POST https://api.engentra.io/v1/customers \
  -H "X-API-Key: $ENGAGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Ada Obi",
    "phone": "+2348012345678",
    "email": "ada@example.com",
    "externalId": "cust_8821"
  }'
FieldTypeNotes
phonerequiredstringE.164 — a +, the country code, then the number. Validated as ^\+[1-9]\d{7,14}$.
namestringShown in the dashboard and usable in template variables. Worth sending.
emailstringOptional. Not used for messaging today.
externalIdstringYour own id for this person. Send it if you have one — it is how you find them again without depending on a phone number that may change.

Phone numbers are the whole game

A Nigerian mobile can be written at least four ways, and all four are the same person:

08012345678        how a customer writes it
2348012345678      how WhatsApp delivers it
+234 801 234 5678  how a form might store it
+2348012345678     E.164 — the only one this API accepts

Engage API stores every customer in E.164, whichever path created them. That was not always true: for a while, a customer who messaged first was stored as 2348012345678 and one who was messaged first as +2348012345678, so the same person could exist twice and a dashboard listing showed both. One format now wins everywhere.

Convert before you send

If your system stores local format, convert at the boundary. Sending 08012345678 is rejected outright, which is the good case — but sending a number that differs from the stored one by a space or a missing + creates a second customer, silently, and the follow-up goes to a record with no conversation history.

Watch customerCreated in the event response. If it comes back true for somebody you know exists, this is why.

Updating

curl -X PATCH https://api.engentra.io/v1/customers/{id} \
  -H "X-API-Key: $ENGAGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Ada Obi-Nwosu" }'

Only the fields you send are changed. Omitting a field leaves it alone; it does not clear it.

Tags

Labels for what you know about a customer - vip, lagos, interested: bags. Set them on the customer's page in the dashboard, or from your own system:

curl -X PUT https://api.engentra.io/v1/customers/{id}/tags   -H "X-API-Key: $ENGAGE_API_KEY"   -H "Content-Type: application/json"   -d '{ "tags": ["vip", "interested: bags"] }'

The call replaces the whole set, so send every tag the customer should keep - removing one is sending the rest. Tags are stored lower case and trimmed, and duplicates are folded, so VIP and vip are one tag. Letters, numbers, spaces and - _ : only, up to 40 characters each and 20 per customer.

GET /v1/customers/tags lists the tags actually in use with a count for each - which is how you pick one rather than remember it. A tag typed from memory that nobody carries matches nobody, and the only thing a count can say about that is zero.

List everyone with a tag with GET /v1/customers?tag=vip. It matches whole tags: ?tag=interested does not find interested: bags. Erasing a customer's data removes their tags too.

What comes back with a customer

Reading a customer - one, or a page of them - returns more than what you sent us. Each carries a summary read across their conversations, events, sales and identities:

{
  "id": "...", "name": "Ada Obi", "phone": "+2348030000001",
  "channels": ["whatsapp", "email"],
  "status": "reachable",
  "lastActivityAt": "2026-09-24T09:12:03Z",
  "lastMessage": { "at": "...", "fromCustomer": true, "preview": "Is the blue one still available?" },
  "orders": 2, "totalSpentMinor": 2500000, "currency": "NGN"
}

status answers whether you may message them: reachable, not_opted_in, suppressed or no_channel. An opt-out beats everything - somebody both opted in and suppressed is suppressed, because that is the answer to “why did my campaign skip them?”.

orders and totalSpentMinor count sales marked against their conversations, which is what the Results page and the trigger conditions orders and total spentalready mean. Money is in the currency's smallest unit - kobo for NGN. These fields are on reads only; the answer to a PATCHreports what changed and leaves them null.

Finding people

GET /v1/customers?q=ada&channel=whatsapp&hasOrders=true&quietForDays=14

Every filter is optional and they are ANDed, so that one reads “people called Ada on WhatsApp who have bought and have said nothing for a fortnight”. q matches their name, number, email, your own reference or a channel handle, anywhere in the value and ignoring case. quietForDays is about them, not about you: somebody you messaged yesterday who has not answered is still quiet.

Sort by createdAt, name, email, phone, lastActivityAt, orders or totalSpent. Anything else is a 400 naming the set. Erased customers are not listed.

Tagging a selection

curl -X POST https://api.engentra.io/v1/customers/tags \
  -H "X-API-Key: $ENGAGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "customerIds": ["...", "..."], "add": ["vip"], "remove": ["lead"] }'

Adds and removes in one call, because moving a group from one label to another as two calls leaves it in neither state if the second fails. One transaction, at most 100 customers, and ids that are not customers here are skipped rather than failing the batch. The response says how many changed.

Their history

GET /v1/customers/{id}/timeline?page=0&size=30

Messages both ways, events, sales, workflow runs and notes, merged and newest first. Each entry says what kind it is - message_in, message_out, event, sale, workflow_run, note, joined - and carries only the values that belong to that kind. The wording is yours: the API does not decide how “they replied” should read.

Notes

curl -X POST https://api.engentra.io/v1/customers/{id}/notes \
  -H "X-API-Key: $ENGAGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "body": "Only wants deliveries after 5pm." }'

For what no system records - how they like to be delivered to, who actually pays, what went wrong last time. Any member can write one and every member can read them; only the author or an owner can delete one. Up to 2000 characters. Notes are never sent anywhere and never shown to the customer, and erasing a customer deletes them outright.

One person, two channels

Somebody messages you from a WhatsApp number on Monday and DMs you from an Instagram username on Friday. That is two customers here and one person to the business, and nothing can tell them apart automatically: a number and a username have nothing in common.

So merging is a decision somebody makes. Engage offers the people who might be the same, with the reason showing, and an owner confirms:

GET /v1/customers/{id}/merge-candidates

response

[
  {
    "customerId": "...",
    "name": "Ada",
    "channel": "instagram",
    "handle": "4455",
    "reason": "same email address"
  }
]

Only customers reachable on a different channel are offered, and only ones sharing this customer's email address or name. Both of those are suggestions rather than proof — an email belongs to a family or an office as often as to one person, and a name is shared by thousands — which is why nothing here merges on its own.

curl -X POST https://api.engentra.io/v1/customers/{id}/merge \
  -H "X-API-Key: $ENGAGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "mergeCustomerId": "...",
    "evidence": "She gave the number in the DM"
  }'
FieldTypeNotes
mergeCustomerIdrequireduuidThe customer to fold in. Everything they had moves to the customer in the path, and their record is removed.
evidencestringWhat the decision rested on, kept with the merge for whoever reads it later. Optional, and worth sending.

The response says what moved, per table — identities, conversations, events, notes, consent, sales, workflow runs — so a merge is auditable rather than a thing that happened:

response

{
  "survivingId": "...",
  "mergedId": "...",
  "moved": { "customer_identities": 1, "conversations": 1, "customer_events": 4 },
  "total": 6
}

Owners only. Three things are refused rather than guessed at:

  • 409 — both already hold a handle on the same channel. Two WhatsApp numbers are two people, or at least not something to decide on their behalf.
  • 400 — either one has been erased. Erasure is a promise that the data is gone, and moving an erased customer's rows onto a live one would quietly undo it.
  • 400 — the two ids are the same customer.

What a merge does not do

Threads are not fused. After the merge the customer has two conversations, shown together under one person. They stay separate because replying means choosing a channel, and each channel has its own reply window — one thread carrying both would make “reply” ambiguous at the moment it matters.

Consent does not merge. It is recorded per customer and channel, so the surviving customer ends up holding the WhatsApp opt-in and no Instagram one. Agreeing to be messaged on WhatsApp is not permission to DM somebody on Instagram.

There is no unmerge

The merge is recorded with what moved, who decided and on what evidence, and that record cannot be edited or deleted — a correction is a new row. But there is no call that puts the two customers back.

A wrong merge shows one person's conversations to another, so read both names before you confirm.

Consent lives elsewhere

Creating a customer is not permission to message them. Opt-in is recorded per channel and checked on every send. A customer who has unsubscribed is unreachable through every path, including a workflow that would otherwise message them, and you cannot override it through this API.

Next

Events →

The call your system actually makes, and why the idempotency key matters more than it looks.