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 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
purchasedactivity 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 leading0, add+255. A landline or a foreign number is refused with a problem onphone; 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, ornull. 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.
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, andattributes, when sent, replace all of theirs. To change one detail without losing the others, send the full set you know, or leaveattributesout. Without it, a phone or email someone already has is an error.segmentsbyidornameadds 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.activitiesare 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:
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 clearsphoneoremail;attributesreplace theirs. - Read:
GET /v1/customers/{id}, or list withq(searches name, phone, email and detail values; a local number like0712 345finds+255712345…),segment,sort(-createdAt,createdAt,name,-name), and pages of up to 200. Each customer comes with theirsegments, 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}, orPOST /v1/customers/deletewith up to 1,000 ids. Their activity and memberships go with them. This needs thecustomers.deletepermission, 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}/mergewith{ "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 needscustomers.deletetoo.
Activity
Activity is a customer's history. Record it as it happens: it's what makes segments like "regular buyers" or "gone quiet" work.
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,phoneoremail. No customer with it is404 not_foundand nothing is recorded: add the customer first (or use the upsert above withactivities, which does both). - Each entry is a
key(same rules as detail names) and onevalue: text up to 500 characters, a number, ortrue/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' }]), orPOST /v1/segments/{id}/customerswith up to 1,000 ids, and take them out withPOST /v1/segments/{id}/customers/remove. - To make a customer's hand-picked segments match the source system exactly, use
PUT /v1/customers/{id}/segmentswith{ "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.
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;
withinDaysis 1 to 3,650.is/isNotcompare exactly (text, number ortrue/false);containsis text, ignoring case;moreThan/lessThanonly 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'ssegmentsis an error on that field;PUT /v1/customers/{id}/segmentsignores them. - Change the rules with
PATCH /v1/segments/{id}and newrules. 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
- 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. - Send batches of up to 100 to
POST /v1/customerswithifExists: "update", an idempotency key per batch (backfill-<run id>-<batch number>), and their past events asactivitieswithoccurredAt. - On a
400, readfields, drop or fix the named customers, and resend that batch. On429, waitRetry-After. - Store each returned
customer.idagainst 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 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.deleteonly if needed). - Every request sends
Goomzo-Version, and every POST anIdempotency-Keyfrom 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
400is fixed fromfieldsrather than retried. -
429and5xxare 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.
Last updated