Knowing your audience
Audience holds the people your organization knows. One individual is one person across every place they meet you: a form they filled in, an order in your shop, a row imported from a spreadsheet. The Audience Management API is how your own server reads and writes those people. Everything it does is also visible to your team in the Audience app.
The API lives at https://api.ebitex.io/audience/management/v1 and takes an
Audience management key. Audience has to be enabled for your organization.
The model
Section titled “The model”| Thing | What it is |
|---|---|
| Individual | A person. It needs a name, an identity, or both. Its name is the display name, else the primary email, else the primary phone number, else the first external key, else “Unnamed”. |
| Identity | An email, a phone number or an external key (an identifier from another system, such as a billing customer id). An individual holds any number of them. |
| Channel permission | Whether the person may be contacted on email, sms, phone or post. Separate from everything else, with its own source and time. |
| Interaction | One recorded fact about a person: an identity added, a form submitted, a note, an event from your system. |
| Custom field | A value your organization defines for every person, set under Settings in the Audience app and read from GET /schema. |
| Linked browser | A visitor’s browser tied to a person, only after the visitor agreed to be recognised. See Identify a visitor. |
Identities are matched, never guessed
Section titled “Identities are matched, never guessed”An email is trimmed and lower-cased. A phone number must be international: + and 8 to 15 digits,
with spaces, dashes, dots and brackets allowed and removed. Audience never infers a country, so a
national number such as 07946 958 is refused. An external key names its system (stripe, crm)
and keeps its case.
An external key is always verified. An email or phone number is verified only when you say how,
with verified: { "method": "verified-email" } (or form-submission, external-system,
attested). Nothing is ever verified by inference, and nothing is ever matched by anything other
than a normalised identity: not a name, not a device.
One verified identity belongs to one person
Section titled “One verified identity belongs to one person”A verified identity is unique in an organization. When you add one that another person already holds
verified, the call answers 409:
{ "error": "identity_verified_elsewhere", "holder": { "id": "6f1c…", "name": "Ada Lovelace" } }Send the same request again with "mergeOnConflict": true and the person you were writing to is
merged into the holder, who survives. The merge moves identities, linked browsers and channel
permissions, fills the survivor’s empty fields, and is recorded so that a person in the Audience app
can revert it. A merged person’s id keeps working: GET /individuals/{id} answers 200 with
mergedInto set. Interactions stay where they were written and are read through the survivor.
An unverified identity can be held by several people at once, and never merges anything.
Create a key in the Audience app under Settings, then API keys. A key holds up to three scopes, each of which includes the ones before it:
| Scope | Allows |
|---|---|
audience.management.read |
Read people, their interactions and the custom fields. |
audience.management.write |
Create and change people, identities, permissions and interactions, and call identify. |
audience.management.erase |
Forget a person. |
A key is for your servers only. It never takes browser origins, so it can’t be shipped in a page, and
you can limit it to the IP ranges your servers call from. Each key has its own requests-per-minute
limit, between 10 and 6,000, and answers 429 with Retry-After when it spends it. A key created
anywhere other than the Audience settings page can’t call this API.
curl https://api.ebitex.io/audience/management/v1/whoami \ -H "Authorization: Bearer $EBITEX_AUDIENCE_KEY"Identify a visitor
Section titled “Identify a visitor”POST /identify is the call for the moment you learn who someone is: they sign in, submit a form on
your site, or complete a purchase. In one transaction it finds the person by the identity you send
(creating them if there is none), adds the identity, records an interaction, and, when you ask,
links the visitor’s browser to the person.
curl https://api.ebitex.io/audience/management/v1/identify \ -H "Authorization: Bearer $EBITEX_AUDIENCE_KEY" \ -H "Content-Type: application/json" \ -d '{ "identity": { "type": "email", "value": "Ada@Example.com", "verified": { "method": "verified-email" } }, "visitor": "v1.CfDJ8…", "consent": { "cmp": "granted", "gpc": false, "dnt": false }, "name": "Ada Lovelace", "attributes": { "company": "Analytical Engines Ltd" }, "interaction": { "kind": "crm.signup", "sourceEventId": "evt_8831", "occurredAt": "2026-10-12T10:00:00Z", "data": { "plan": "trial" } } }'{ "individual": { "id": "6f1c…", "name": "Ada Lovelace" }, "created": false, "identityAdded": false, "merged": null, "linked": true, "linkReason": null}The matching rules:
- A verified match wins.
- Otherwise an unverified match on exactly one person is used.
- With two or more unverified matches,
identifycreates a new person and answers"ambiguous": 2(the number of people it could have been). It never guesses. - A verified identity held by another person merges, as above. An identification event always merges: it is the automatic consolidation.
An event never overwrites. name, locale and attributes fill values that are empty and leave
anything a person typed alone. locale is a language code such as fr or en-GB; it records the
language a person is written to, and a person without one is written to in the organization’s default.
Consent is yours to forward
Section titled “Consent is yours to forward”A browser is linked to a person only if its owner agreed to be recognised on that device. Audience
can’t see the browser, so you must forward what the device said, in consent: the choice from your
consent-management platform (cmp, with cmpString if it gave one), whether the browser sent the
Global Privacy Control header (gpc) and whether it sent Do Not Track (dnt). Send them as the
browser sent them. Sending nothing counts as no consent.
linked is true only when all of these hold: the visitor token reads as valid and unrevoked, it
is a recognition token (not a short-lived session one), the signals resolve to granted, and the
browser isn’t already linked to a different person. Otherwise linked is false and linkReason
says why:
linkReason |
Meaning |
|---|---|
no_visitor |
You sent no visitor. |
token_invalid |
The visitor value isn’t a valid visitor token. |
session_token |
The token is a session token, which can’t be linked. |
recognition_not_granted |
The device’s signals don’t grant recognition. |
visitor_linked_elsewhere |
The browser is linked to another person, and the identity you sent isn’t verified on the person you identified. |
revoked |
The browser was forgotten or unlinked. |
The identification still happens when linking doesn’t: only the link is withheld.
Unlinking a browser from the Audience app also revokes its visitor id, so the next visit starts as
a new visitor. A token you still hold for it will answer revoked.
Interactions
Section titled “Interactions”POST /individuals/{id}/interactions appends a fact. Choose your own kind, lower-case and at most
64 characters, prefixed with your system’s name (crm.signup). Audience never interprets it. data
is JSON of at most 16 KiB and 8 levels deep.
Send a sourceEventId and the call is idempotent: the same event sent again answers 200 with the
interaction already stored and writes nothing, so a retry after a timeout is safe. Without one, every
call appends.
Page views aren’t stored, and page-view is refused with kind_not_accepted. Interactions are
listed newest first, and only listed: nothing counts, groups or sums them.
Channel permissions
Section titled “Channel permissions”PUT /individuals/{id}/channel-permissions/{channel} records granted or denied with a lawful
basis (consent, contract or legitimate-interest) and an optional note.
A channel with no record is unknown, and unknown means do not send. There is no default-granted
channel, and post exists so a letter can be refused too.
The lifecycle
Section titled “The lifecycle”Every person can be somewhere in five stages: awareness, acquisition, conversion, retention and advocacy. A person holds at most one open pass in a stage and any number of closed ones, so a customer who lapses and returns has two passes in retention and the history of both. Each stage has its own states and transitions, which your team edits in the app. The API moves a person along them; it never edits them.
GET /individuals/{id}/stagesreturns a person’s journey.POST /individuals/{id}/stages/{stage}opens a pass, with the stage’s own facts such ascampaignandmediumfor awareness,scorefor acquisition orkindandvalueMinorfor conversion. A fact the stage doesn’t have is refused asunknown_fact.PUT /individuals/{id}/stages/{stage}/rows/{rowId}changes an open pass’s facts. It needs therowVersionyou read.POST /individuals/{id}/stages/{stage}/rows/{rowId}/transitionsfires one of the pass’savailableTransitions.
Entering a terminal state closes the pass. A transition can have a condition, which can veto it
(409 transition_vetoed), and actions such as a webhook, which run as part of the move.
A key holds no role. It can fire a transition only when the transition has no role allow-list;
one that has one answers 403 transition_not_allowed. The move is recorded against the key’s name,
and the person’s history says a key made it.
Reading a stage at scale
Section titled “Reading a stage at scale”GET /stages/{stage}/summary returns the true count of open passes in each state. GET /stages/{stage}/rows walks the open passes, longest-waiting first, with a cursor: pass nextCursor
back as cursor until it is absent. Each call costs the same however deep you are, and a pass that
moves while you walk is seen at most once. stateId, search, ownerExternalUserId and
olderThanDays narrow the walk.
The review queue
Section titled “The review queue”Some things only a person can decide. The review queue holds two kinds: suggested duplicates, when two people share a normalised name of two words or more, and tasks. Audience never merges on a suggestion: it only proposes, and a pair a person has kept separate is never proposed again.
GET /review lists open items (or status=resolved or status=dismissed), POST /individuals/{id}/tasks creates a task, POST /review/{id}/resolve closes an item and POST /review/{id}/dismiss sets it aside. Resolving a suggestion as merged needs a survivorId, one of
the two people, and runs the same merge a person would. A merge carries the person’s closed passes
and tasks with it, and an open pass too unless the survivor already has one open in that stage.
Forgetting a person
Section titled “Forgetting a person”POST /individuals/{id}/forget erases a person and the personal data held about them across your
ebitex account: identities, linked browsers, interactions and permissions, and the form submissions
from those browsers and identities, which are emptied of their answers. It can’t be undone, and it
needs the audience.management.erase scope.
Browsers are revoked first, then each part of the suite erases in turn. The first failure stops the run,
keeps the person, and answers 500 with <part>_erasure_failed, so you can retry. A receipt with no
personal data is kept.
Audiences
Section titled “Audiences”An audience is a named rule that decides who sees personalized content. Define one with
POST /audiences (and change it with PUT /audiences/{id}) using a key, a name and a predicate.
The id it returns is what personalized content stores, so renaming an audience never breaks it.
A predicate reads a flat set of properties: the context your site sends (a locale, a campaign, a buyer
type), and, for a person Audience recognises on that device, members under individual:
individual.recognised, individual.stages.open, individual.stages.reached,
individual.stage.<stage>.state, individual.stage.acquisition.score, individual.attribute.<field> and
individual.permission.<channel>. A name Audience can’t read is refused when you save, not ignored.
An audience that reads individual.* is never true for a visitor Audience can’t recognise, even when
its rule is a negation such as “not a customer”. An audience that reads only the person has stored
members: Audience keeps who is in it up to date as people change, counts them, and writes an
audience.entered or audience.exited interaction when someone enters or leaves. When you change
such a rule, membershipCurrent is false until Audience has caught up, and resolving evaluates it
live in the meantime. An audience that reads the request is decided per request and stores nothing.
An audience can name another: a predicate that reads audience.member.<id> is true when the person is in
that audience. The audience takes on the scope of everything it names, so one that names a person-only
audience still has stored members, and one that names an audience about the request is decided per
request. Audiences cannot name each other in a loop, even through others, and an audience another names
cannot be deleted until the naming stops.
An audience can also count what a person has done. Send behaviours with the audience, each
{ "id", "kind", "filter", "minCount", "days" }, and read it in the predicate as
individual.did.<id>. The kinds are form.submitted, audience.entered, stage.moved and content.viewed; filter
narrows by the form name, the audience id or the stage; days limits the count to a window (0 is any
time). An audience can declare five. Membership follows new activity as it is recorded and is re-checked
every hour, because a window moves on without anything changing about the person.
Audiences that read a person’s accounts
Section titled “Audiences that read a person’s accounts”For an organization that uses accounts, a predicate can also read the person’s accounts:
individual.account.member (the person belongs to at least one account) and
individual.account.attribute.<field> (an account’s custom field). Because a person can belong to
several accounts, the field is a list of their accounts’ values, and equals or in match when any
of their accounts matches. A person is in the audience because of their account, so everything that uses
an audience (gated content, scoring, a stage trigger, a broadcast, resolve) works as it does for any
other.
A behaviour can count what the people in an account did instead of the person alone. Add
"scope": "account" and "minMembers": 3 to the behaviour: it holds for a person when, in any of their
accounts, at least three members each did it at least minCount times in the window. minMembers: 1
is “any member did it”. A member who joins an account brings what they did, and one who leaves takes it
with them. The rule still reads it as individual.did.<id>.
Saving a predicate or behaviour that reads accounts answers 409 accounts_not_enabled while the
organization has accounts off. An audience saved earlier is kept and has no members until accounts are
turned on again. A very large account is re-checked within the hour rather than at once.
Resolve a visitor’s audiences
Section titled “Resolve a visitor’s audiences”Call POST /audience/delivery/v1/resolve from your presentation app with a delivery key. Send ctx (the context, a flat object), the visitor’s
visitor token and consent signals if you have them, and optionally audiences, the ids your content uses (Content lists them at GET /content/delivery/v1/audiences). The answer
holds the ids of the audiences they are in and a signed token proving it:
{ "audiences": ["5b8e…"], "recognised": false, "reason": null, "access": { "token": "a1.…", "expiresAtUtc": "…", "grants": [{ "audienceId": "5b8e…", "assurance": "declared" }] } }Send the token in the X-Ebitex-Audiences header on your content requests; Content verifies it and trusts nothing else about who the visitor is. Audiences are never sent in ctx. The
token lasts 15 minutes, carries no identity, and is tagged per audience as verified (the visitor proved an email), declared (a stored member) or context (worked out from the request, so public copy only).
The Content SDKs do all of this for you: with the audience option the client fetches, keeps, renews and sends the token. Your content requests never call Audience themselves.
A delivery key is its own kind, created in the app, and carries the audience.delivery.read scope that resolve and sign-in both require. With no origin or IP restriction it is a server key.
With one it can be used from a browser, and the restriction is checked on every request. Audience uses
what it knows about a person only when recognition is granted on that device, so the same call without
consent resolves by context alone and says why in reason. Responses are private, no-store.
Verified access
Section titled “Verified access”An audience whose members are stored can be flagged verified access in the Audience app, under the audience’s Visitor access section. Content that is restricted to such an audience is served only to a member who has also proved they hold their email address, by following a one-time link. Without the flag, being a member is enough, and the audience can name a form that makes a visitor a member. Turning the flag on locks out members who have not yet proved an address, so the app shows how many before you confirm; members who hold no email address can never prove one. The flag is for audiences with stored members: one worked out from a request’s own context cannot grant access.
To restrict pages to an audience, send the token resolve returns, and let a visitor request a sign-in link with POST /audience/delivery/v1/sign-in. See
Serve pages only to members.
Custom fields
Section titled “Custom fields”GET /schema lists the fields your organization defined. Send values as attributes on create,
update and identify; an invalid value answers 400 keyed attributes.<key>. A field with
a value can’t change type, and removing one in the app first asks you to confirm how many people
hold a value.
Accounts
Section titled “Accounts”An account is a group of people you track as one thing: a company, a household, a school. Accounts are
optional. An organization turns them on in Audience settings, and until then every account route
answers 409 accounts_not_enabled and nothing account-shaped appears anywhere. The organization also
chooses the word for it (“Company”, “Household”) for the people who use the app; the API always says
account.
An account is identified by an external id you supply, such as your own customer id. It is case-sensitive and unique in the organization. Audience never matches accounts by name or anything else, so two accounts are the same account only when the external id is the same.
Create or update an account with PUT /accounts/{externalId}. A name replaces the name, and
attributes merge key by key over the existing values: a key you leave out stays, and a key sent as
null is cleared. Sending the same body twice changes nothing. Account fields are defined in the app,
apart from a person’s fields, and checked the same way.
A person can belong to any number of accounts, with an optional role in each (a label you choose, which
Audience does not read). PUT /accounts/{externalId}/members/{individualId} adds a person or changes
their role, and DELETE on the same path removes the membership. The person and the account are untouched.
identify can name the account in one call:
{ "identity": { "type": "email", "value": "dana@northwind.example" }, "name": "Dana Whitfield", "account": { "externalId": "org_8f31", "name": "Northwind Traders", "role": "Owner" }}The account is found by its external id and created if it does not exist, and the person is added to
it. The answer carries account with its id, whether it was created and whether the person was newly
memberAdded. When the plan’s account limit is reached, the person is still identified and the answer
says accountSkipped: "account_quota_exceeded".
Forgetting a person removes their memberships and never deletes an account. Deleting an account needs
the audience.management.erase scope and removes the account and its memberships, never a person.
Merging two people moves their memberships to the one that survives, and reverting the merge puts them back.
First touch and the account lifecycle
Section titled “First touch and the account lifecycle”An account’s first touch is where it came from, such as the UTM values of the visit that started a
trial. Send it as attribution on PUT /accounts/{externalId} or on the account of identify:
{ "source": "linkedin", "medium": "social", "campaign": "launch" }. It is recorded once, on an account
that has none, and never changed afterwards, so a different one sent later is ignored. Each member is at
most 100 characters, and a member you leave out is recorded as (none). The account carries it back as
firstTouch.
An account moves through the same five lifecycle stages a person does, with the states and moves the
organization defined in Audience settings. GET /accounts/{externalId}/stages returns its journey,
POST /accounts/{externalId}/stages/{stage} opens a pass, and
POST /accounts/{externalId}/stages/{stage}/rows/{rowId}/transitions fires a move. When you report an
event after it happened, send openedAt (an ISO 8601 time) with the open: the pass starts then, not
now. It cannot be in the future or more than a year before the account was created. The first touch is
copied onto the pass, so reports group an account the same way however long ago it started.
Plan limits
Section titled “Plan limits”An organization’s plan sets how many people it can hold, counted however each arrived: created by hand,
through this API, by identify, an import or a connector sync. A person merged into another is not
counted. At the limit, POST /individuals and an identify that would create someone answer
403 quota_exceeded, with quotaKey naming the limit, and write nothing. An identify that matches
someone who already exists still finds and links them. Nothing already held is removed or locked: reading,
updating, merging and forgetting people carry on over the limit.
Changing a person safely
Section titled “Changing a person safely”PUT /individuals/{id} replaces name and attributes and needs the rowVersion you read. If
someone else changed the person since, it answers 409 concurrency_conflict and writes nothing, so
read, change, and retry on a conflict.