---
title: "Track customers"
description: "How to map a business's existing system onto Goomzo's customers, details, activity and segments, and keep them in step."
url: "https://docs.goomzo.africa/docs/developers/agents/customers"
docs_index: /llms.txt
lastUpdated: 2026-11-12
section: "AI agents"
related:
  - /docs/developers/agents
---

> For an index of all documentation, see [/llms.txt](https://docs.goomzo.africa/llms.txt). Any page is also available as Markdown by adding .md to its URL.

# Track customers

> How to map a business's existing system onto Goomzo's customers, details, activity and segments, and keep them in step.

Read [Goomzo for AI agents](https://docs.goomzo.africa/docs/developers/agents?utm_source=llms&utm_medium=ai) first: it has the rules every call follows (authentication, phones,
idempotency, errors). This page is about modelling: what in the business's system becomes what in Goomzo, and the
calls that keep the two in step.

## Map the system onto Goomzo

Before writing code, read the existing system and decide, for each thing it knows about a person, which of these it
is. Getting this right is most of the work.

| In the existing system                                                                            | In Goomzo                        | Why                                                                  |
| ------------------------------------------------------------------------------------------------- | -------------------------------- | -------------------------------------------------------------------- |
| A person who bought, booked, signed up or was entered by staff                                    | **Customer**                     | Anyone the business may want to reach or understand                  |
| Their phone and email                                                                             | `phone`, `email`                 | How Goomzo tells customers apart and reaches them                    |
| A fact that has one current value: plan, city, loyalty tier, date of birth, preferred branch      | **Detail** in `attributes`       | Replaced when it changes; filterable and usable in segment rules     |
| Something that happened: an order, a payment, a visit, a booking, a review, a login               | **Activity**                     | Kept as history with its time; counted and added up by segment rules |
| A label staff give people by hand: "VIP", "Wholesale", "Staff"                                    | **Segment picked by hand**       | Membership the business decides                                      |
| A group defined by behaviour or facts: "bought twice this month", "gold plan", "inactive 90 days" | **Automatic segment** with rules | Goomzo works out who is in it; nothing to sync                       |

Rules of thumb:

- **Fact or event?** If the system would overwrite it, it's a detail. If it would append it, it's activity. An order
  is activity (`purchased`); "lifetime orders: 12" is a number Goomzo can work out from activity, so don't also
  store it as a detail unless the system already has it and it's authoritative.
- **Don't encode groups as details** when an automatic segment can say it: record `purchased` activity and let a
  segment with "Purchased at least 2 times in the last 30 days" find the regulars.
- **Don't send what the business shouldn't keep**: no passwords, card numbers, national ID numbers or health data.

## Customers

A customer has a `name` (required, up to 200 characters) and at least one of `phone` or `email`.

- **Identity.** Goomzo finds an existing customer by **phone first, then email**. Two customers can't share a phone
  or an email. Goomzo ids look like `cus_4hq2v9n1c8x0t6pk`; store the one you get back next to your own customer
  record if you'll update or delete by id later.
- **Phones** must be mobile numbers in the business's country, with the country code (`+255712345678`). If the
  system stores local numbers (`0712 345 678`), convert them: drop the leading `0`, add `+255`. A landline or a
  foreign number is refused with a problem on `phone`; send that customer with only their email, or skip them.
- **Emails** are stored in lower case.
- **Details** (`attributes`) are up to 50 named values. Names are lower case letters, numbers and `_`, starting with
  a letter, up to 40 characters: `loyalty_tier`, `branch`, `date_of_birth`. Values are text (up to 500 characters),
  a number, `true`/`false`, or `null`. Use numbers for numbers (`25000`, not `"25,000"`) so segment rules can
  compare them, and ISO dates (`"1990-05-12"`) for dates.

### Add or update customers: the upsert

The one call most integrations need is `POST /v1/customers` with `ifExists: "update"`. It adds new people and
updates ones Goomzo already has, so you never need to look someone up first.

```ts
const { items } = await goomzo.customers.create(
  {
    ifExists: 'update',
    ifSegmentMissing: 'create',
    customers: [
      {
        name: 'Amina Juma',
        phone: '+255712345678',
        email: 'amina@example.com',
        attributes: { loyalty_tier: 'gold', branch: 'Kariakoo' },
        segments: [{ name: 'Online shoppers' }],
        activities: [{ key: 'signed_up', value: 'website', occurredAt: '2026-11-09T08:30:00Z' }],
      },
    ],
  },
  { idempotencyKey: `customer-${localId}-${updatedAt}` },
)
items[0].result // 'created' | 'updated' | 'unchanged'
items[0].customer.id // cus_…
```

What it does, exactly:

- **Up to 100 customers per call, all or none.** One bad phone fails the whole batch, with the error naming it
  (`customers[37].phone`). Validate before sending, or fix and resend the whole batch.
- **`ifExists: "update"`** updates the matched customer with what you sent: fields left out stay as they are, and
  `attributes`, when sent, **replace all of theirs**. To change one detail without losing the others, send the full
  set you know, or leave `attributes` out. Without it, a phone or email someone already has is an error.
- **`segments`** by `id` or `name` adds them to those segments. It never takes them out of others.
  `ifSegmentMissing: "create"` makes segments named by name that don't exist yet; names match whatever their case.
- **`activities`** are recorded for the customer in the same call, as below.
- If a phone belongs to one customer and the email to another, that's an error on `email`: the system has merged
  two people Goomzo keeps apart. Send one of them, or fix the data.

Over HTTP it's the same body:

```bash
curl https://api.goomzo.africa/v1/customers \
  -H "Authorization: Bearer $GOOMZO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Goomzo-Version: 2026-11-08" \
  -H "Idempotency-Key: customer-1042-2026-11-09T08:30:00Z" \
  -d '{ "ifExists": "update", "customers": [{ "name": "Amina Juma", "phone": "+255712345678" }] }'
```

### Change, read and delete

- **Change one customer** by id: `PATCH /v1/customers/{id}` (`goomzo.customers.update(id, {...})`). Fields left
  out stay; an empty string clears `phone` or `email`; `attributes` replace theirs.
- **Read**: `GET /v1/customers/{id}`, or list with `q` (searches name, phone, email and detail values; a local
  number like `0712 345` finds `+255712345…`), `segment`, `sort` (`-createdAt`, `createdAt`, `name`, `-name`), and
  pages of up to 200. Each customer comes with their `segments`, including automatic ones they match now.
- **Delete** when the person is deleted in the source system, or asks to be forgotten:
  `DELETE /v1/customers/{id}`, or `POST /v1/customers/delete` with up to 1,000 ids. Their activity and memberships
  go with them. This needs the `customers.delete` permission, which **Customer sync** doesn't have on purpose; ask
  for it only when the integration really deletes.
- **Merge** when one person is kept as two customers, say one reached by email and one by WhatsApp:
  `POST /v1/customers/{id}/merge` with `{ "customer": "<the other id>" }` (`goomzo.customers.merge(id, { customer })`).
  The customer in the path stays: they keep every field they have and take the other's where they have none, join the
  other's segments, and get their messages, activity and conversation. The other customer is deleted; this can't be
  undone, and it needs `customers.delete` too.

## Activity

Activity is a customer's history. Record it as it happens: it's what makes segments like "regular
buyers" or "gone quiet" work.

```ts
await goomzo.activity.record(
  {
    phone: '+255712345678', // or email, or customerId: exactly one
    activities: [
      { key: 'purchased', value: 'Shoes' },
      { key: 'spent', value: 45000 },
    ],
  },
  { idempotencyKey: `order-${order.id}-paid` },
)
```

- **Find the customer** by exactly one of `customerId`, `phone` or `email`. No customer with it is `404 not_found`
  and nothing is recorded: add the customer first (or use the upsert above with `activities`, which does both).
- **Each entry** is a `key` (same rules as detail names) and one `value`: text up to 500 characters, a number, or
  `true`/`false`. It can't be empty. Up to 100 entries per call, all or none.
- **`occurredAt`** (ISO 8601) is when it happened; leave it out for now. It can't be in the future. Set it when
  backfilling, so history lands at the right time.
- **Entries are only added.** There's no edit or delete; a refund is its own entry (`refunded: 45000`).

### Name activity well

Pick a small, stable vocabulary, past tense, one meaning per key. Put the amount in its own entry when it's money,
so rules can add it up. Reuse these where they fit:

| Key                  | Value                                 | When                                              |
| -------------------- | ------------------------------------- | ------------------------------------------------- |
| `signed_up`          | where: `"website"`, `"app"`, `"shop"` | The person created an account                     |
| `purchased`          | what: `"Shoes"`, or an order number   | An order is paid                                  |
| `spent`              | amount as a number: `45000`           | Alongside `purchased`, in the business's currency |
| `refunded`           | amount as a number                    | Money went back                                   |
| `booked_appointment` | the service: `"Haircut"`              | A booking is made                                 |
| `visited_store`      | the branch: `"Kariakoo"`              | A check-in or visit                               |
| `viewed_offer`       | the offer: `"Weekend 20% off"`        | They opened or claimed an offer                   |
| `redeemed_offer`     | the offer                             | They used it                                      |
| `left_review`        | the rating as a number: `5`           | A review was posted                               |

Don't put ids that are unique per event in the key (`purchased_order_1042`): that makes thousands of keys and
nothing groups. The key says what happened; the value says which.

## Segments

A segment is a named group: up to 500 per business, with a `name` (up to 50 characters, unique ignoring case), a
`color` (`green`, `blue`, `orange`, `red`, `gray`) and a `description` (up to 200 characters). There are two kinds,
fixed when the segment is made (`kind` in the answer).

### Picked by hand (`kind: "manual"`)

For labels the business decides. A customer is in up to 20 of them.

- Add people in the upsert (`segments: [{ name: 'VIP' }]`), or `POST /v1/segments/{id}/customers` with up to
  1,000 ids, and take them out with `POST /v1/segments/{id}/customers/remove`.
- To make a customer's hand-picked segments match the source system exactly, use
  `PUT /v1/customers/{id}/segments` with `{ "names": [...] }`: they join the ones named (missing ones are made) and
  leave the others.

### Automatic (`kind: "rules"`)

For groups defined by facts or behaviour. You give rules; Goomzo works out who matches every time the segment is
used: counted, listed, filtered on, or sent a broadcast. **Prefer these over syncing group membership yourself**:
you only keep details and activity current, and the groups follow.

```ts
import type { SegmentRules } from '@goomzo/sdk'

const rules: SegmentRules = {
  match: 'all', // or 'any'
  conditions: [
    { type: 'activity', key: 'purchased', op: 'atLeast', count: 2, withinDays: 30 },
    { type: 'contact', op: 'hasPhone' },
  ],
}

const { count, customers } = await goomzo.segments.preview(rules) // who matches now, before saving
await goomzo.segments.create({ name: 'Regular buyers', color: 'green', rules })
```

| `type`          | Matches customers who                                                                    | Fields                                                                                       |
| --------------- | ---------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| `detail`        | Have a detail that is, isn't, contains, is more or less than a value; or has one or none | `key`; `op`: `is`, `isNot`, `contains`, `moreThan`, `lessThan`, `isSet`, `isNotSet`; `value` |
| `activity`      | Did something at least `count` times (1 if left out), or never                           | `key`; `op`: `atLeast`, `none`; `count`; `withinDays` optional                               |
| `activityTotal` | Have an activity's number values adding up to more or less than `value`                  | `key`; `op`: `moreThan`, `lessThan`; `value`; `withinDays` optional                          |
| `anyActivity`   | Did anything, or nothing, in the last `withinDays` days                                  | `op`: `some`, `none`; `withinDays`                                                           |
| `added`         | Became a customer in the last `withinDays` days                                          | `withinDays`                                                                                 |
| `segment`       | Are or aren't in a segment picked by hand                                                | `op`: `in`, `notIn`; `segmentId`                                                             |
| `contact`       | Have a phone, or an email                                                                | `op`: `hasPhone`, `hasEmail`                                                                 |

- Up to 10 conditions; `withinDays` is 1 to 3,650. `is`/`isNot` compare exactly (text, number or `true`/`false`);
  `contains` is text, ignoring case; `moreThan`/`lessThan` only match number values.
- Up to 50 automatic segments per business. Rules can only use segments picked by hand, and such a segment can't
  be deleted while rules use it (`409 segment_in_use`).
- **No one is added or removed by hand.** Adding to one is `409 segment_fills_itself`; naming one in the upsert's
  `segments` is an error on that field; `PUT /v1/customers/{id}/segments` ignores them.
- Change the rules with `PATCH /v1/segments/{id}` and new `rules`. A segment picked by hand can't be given rules:
  make a new one.
- Rules that take longer than 5 seconds to work out are refused (`422 rules_too_slow`); use fewer conditions.

Useful automatic segments to suggest:

| Segment          | Rules                                                                |
| ---------------- | -------------------------------------------------------------------- |
| New this week    | `added`, `withinDays: 7`                                             |
| Regular buyers   | `activity` `purchased` `atLeast` `count: 2`, `withinDays: 30`        |
| Big spenders     | `activityTotal` `spent` `moreThan` `value: 500000`, `withinDays: 90` |
| Gone quiet       | `anyActivity` `none`, `withinDays: 90`                               |
| Gold members     | `detail` `loyalty_tier` `is` `"gold"`                                |
| Reachable by SMS | `contact` `hasPhone`                                                 |

## Recipes

### Backfill: bring existing customers in

1. Read the source in pages. Map each person to `{ name, phone, email, attributes }`, normalizing phones to
   `+255…`. Skip people with neither a valid phone nor an email, and log them.
2. Send batches of up to 100 to `POST /v1/customers` with `ifExists: "update"`, an idempotency key per batch
   (`backfill-<run id>-<batch number>`), and their past events as `activities` with `occurredAt`.
3. On a `400`, read `fields`, drop or fix the named customers, and resend that batch. On `429`, wait `Retry-After`.
4. Store each returned `customer.id` against the source record.

Running it again is safe: matched customers come back `unchanged`. For a one-off spreadsheet the business has,
`POST /v1/customers/import` takes up to 5,000 rows, accepts local phone numbers, skips bad rows instead of failing,
and has `dryRun`. Prefer the upsert for code.

### Keep in step as things happen

Hook the source system's own events, and call Goomzo from its server, after its own write succeeds:

| Source event                        | Call                                                                                  |
| ----------------------------------- | ------------------------------------------------------------------------------------- |
| Sign-up, profile change, staff edit | Upsert the customer (`ifExists: "update"`)                                            |
| Order paid                          | `POST /v1/activity` with `purchased` and `spent`, key `order-<id>-paid`               |
| Booking, visit, review              | `POST /v1/activity` with its entry                                                    |
| Tag added or removed by staff       | `POST /v1/segments/{id}/customers` or `/remove`, or `PUT /v1/customers/{id}/segments` |
| Person deleted                      | `DELETE /v1/customers/{id}`                                                           |

Don't make the user wait on Goomzo: send from a background job or queue, retry on `429` and `5xx` with the same
idempotency key, and alert on anything else. Because every call is idempotent and the upsert matches by phone and
email, replaying a queue is harmless.

### Hear back from Goomzo

Register a [webhook](https://docs.goomzo.africa/docs/developers/webhooks?utm_source=llms&utm_medium=ai) for `customer.created` to learn about customers added in Goomzo itself (by
staff, or an import), for example to create them in the source system. Check every delivery's signature
(`verifyWebhook` in the SDK) and use the event id to skip repeats.

## Before you finish

- [ ] The API key is on the server only, with **Customer sync** permissions (plus `customers.delete` only if needed).
- [ ] Every request sends `Goomzo-Version`, and every POST an `Idempotency-Key` from the source's own ids.
- [ ] Phones are converted to `+255…`; people without a usable phone or email are skipped and logged.
- [ ] Facts are details, events are activity, and groups are automatic segments wherever rules can say them.
- [ ] Batches are at most 100, and a `400` is fixed from `fields` rather than retried.
- [ ] `429` and `5xx` are retried with backoff; nothing blocks the source system's own users.
- [ ] Activity keys are a small, past-tense vocabulary; money is a number in its own entry.

---

For an index of all available documentation, see [/llms.txt](https://docs.goomzo.africa/llms.txt)
