Skip to content

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.

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.

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.

Terminal window
curl https://api.ebitex.io/audience/management/v1/whoami \
-H "Authorization: Bearer $EBITEX_AUDIENCE_KEY"

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.

Terminal window
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:

  1. A verified match wins.
  2. Otherwise an unverified match on exactly one person is used.
  3. With two or more unverified matches, identify creates a new person and answers "ambiguous": 2 (the number of people it could have been). It never guesses.
  4. 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.

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.

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.

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.

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}/stages returns a person’s journey.
  • POST /individuals/{id}/stages/{stage} opens a pass, with the stage’s own facts such as campaign and medium for awareness, score for acquisition or kind and valueMinor for conversion. A fact the stage doesn’t have is refused as unknown_fact.
  • PUT /individuals/{id}/stages/{stage}/rows/{rowId} changes an open pass’s facts. It needs the rowVersion you read.
  • POST /individuals/{id}/stages/{stage}/rows/{rowId}/transitions fires one of the pass’s availableTransitions.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.