Audience
Manage people, companies, and lists using the Quotient SDK
Overview
The audience API lets you manage people, companies, lists, and custom properties.
Both the client and server SDKs expose the audience API under audience.*. The
client SDK includes audience.people only. The server SDK adds
audience.companies, audience.lists, and audience.properties.
| Endpoint | API Key | Server SDK | Client SDK |
|---|---|---|---|
| Upsert person | public or private | audience.people.upsert() | audience.people.upsert() |
| Identify a person | public or private | audience.people.identify() | audience.people.identify() |
| Register for an event | public or private | audience.people.registerForEvent() | audience.people.registerForEvent() |
| Check event registration | public or private | audience.people.getEventRegistrationStatus() | audience.people.getEventRegistrationStatus() |
| Subscribe to email | public or private | audience.people.subscribeToEmail() | audience.people.subscribeToEmail() |
| Upsert company | public or private | audience.companies.upsert() | — |
| Upsert list | private only | audience.lists.upsert() | — |
| List all lists | private only | audience.lists.list() | — |
| Get a list | private only | audience.lists.get() | — |
| List people in a list | private only | audience.lists.listPeople() | — |
| Add people to a list | private only | audience.lists.addPeople() | — |
| Remove people from a list | private only | audience.lists.removePeople() | — |
| List custom properties | private only | audience.properties.list() | — |
| Create custom property | private only | audience.properties.create() | — |
| Update custom property | private only | audience.properties.update() | — |
To manage list membership from the browser, use audience.people.upsert() with
the lists parameter.
People
Upsert a Person
When durable events are requested, the response also includes the original batch
requestId and registrations, an array of
{ eventId, participantStatus, acceptance }. Each acceptance has the same shape
described below. Calls without events retain the ordinary person upsert
behavior; their request ID does not make unrelated person updates durable.
audience.people.upsert(params)
POST /api/v0/audience/peopleAuth: public or private key · scope:
AUDIENCE_PERSON_WRITE
Creates or updates a person record based on email address.
Saving a person this way only changes their email subscription when you pass
emailSubscriptionStatus, so syncing contacts from another system doesn't
subscribe them. Pass SUBSCRIBED when the person asked to join: someone new or
never subscribed becomes SUBSCRIBED, or gets a confirmation email with double
opt-in on. It never re-subscribes or emails someone who unsubscribed. To let
someone who unsubscribed sign up again, use subscribeToEmail().
| Param | Type | Required | Description |
|---|---|---|---|
requestId | string | No in the SDK | Generated once per request. Reuse with the unchanged profile, consent and event batch for retries. |
events | string[] | No | Event slugs to register for, at most 25. Durable event batches are atomic. |
emailAddress | string | Yes | Primary identifier |
emailSubscriptionStatus | "SUBSCRIBED" | "UNSUBSCRIBED" | "NOT_SUBSCRIBED" | No | SUBSCRIBED subscribes; UNSUBSCRIBED opts out; leave out or NOT_SUBSCRIBED to leave it alone |
firstName | string | No | First name |
lastName | string | No | Last name |
jobTitle | string | No | Job title |
leadScore | number | No | Integer lead score (default 0) |
lists | string[] | No | List slugs to add the person to |
properties | Record<string, string | number | boolean | Date> | No | Custom properties (must be pre-defined) |
const { personId } = await client.audience.people.upsert({
emailAddress: "user@example.com",
firstName: "Jane",
lastName: "Doe",
emailSubscriptionStatus: "SUBSCRIBED",
leadScore: 50,
lists: ["newsletter", "customers"],
properties: {
plan: "pro",
signupSource: "landing-page",
},
});
Email Subscription Statuses:
SUBSCRIBED- Opted in to marketing emailsUNSUBSCRIBED- Opted out of marketing emails- If not specified, or
NOT_SUBSCRIBED:- Existing people keep their current state
- New people start as
NOT_SUBSCRIBED
Returns:
{
personId: string;
registrations?: {
eventId: string;
participantStatus: EventParticipantStatus;
acceptance: EventRegistrationAcceptance; // Shape documented under registerForEvent below
}[];
}
Identify a Person
audience.people.identify(params)
POST /api/v0/audience/people/identifyAuth: public or private key · scope:
AUDIENCE_PERSON_WRITE
Identifies a person by email, creating them if they don't already exist. It
saves the same details as upsert() but never changes the person's email
subscription, and it carries lead attribution, so it's the call to use for new
integrations. Creating a new person fires a personCreated analytics event with
whatever attribution context is available. If a list slug doesn't exist, the
person is still saved and the response is an error naming the list.
The client SDK attaches the visitor's current browser context (device, session,
referrer, and attribution) automatically. On the server, forward it yourself,
typically from client.getBrowserContext() on your frontend.
| Param | Type | Required | Description |
|---|---|---|---|
emailAddress | string | Yes | Primary identifier |
firstName | string | No | First name |
lastName | string | No | Last name |
jobTitle | string | No | Job title |
leadScore | number | No | Integer lead score |
lists | string[] | No | List slugs to add the person to |
properties | Record<string, string | number | boolean | Date> | No | Custom properties (must be pre-defined) |
browserContext | BrowserContext | Server SDK only | Attribution to attach if this creates a new person |
// Browser
const { personId } = await client.audience.people.identify({
emailAddress: "user@example.com",
firstName: "Jane",
});
// Server
const { personId } = await client.audience.people.identify({
emailAddress: "user@example.com",
firstName: "Jane",
browserContext: clientBrowserContext, // forwarded from the browser client
});
Returns:
{
personId: string;
}
Register for an Event
audience.people.registerForEvent(options)
POST /api/v0/audience/people/register-for-eventAuth: public or private key · scope:
AUDIENCE_PERSON_WRITE
Accepts the person's profile and local event registration together. For events using durable registration, analytics and eligible flows are recorded in the same transaction and delivered in the background. A provider outage can leave the registration pending after local acceptance; the response does not promise a provider join link.
| Param | Type | Required | Description |
|---|---|---|---|
requestId | string | No in the SDK | Generated once per request. Direct HTTP callers must supply a UUID for durable events. |
emailAddress | string | Yes | Primary identifier |
eventSlug | string | Yes | Slug of the marketing event to register for |
firstName | string | No | First name |
lastName | string | No | Last name |
jobTitle | string | No | Job title |
leadScore | number | No | Integer lead score |
lists | string[] | No | List slugs to add the person to |
properties | Record<string, string | number | boolean | Date> | No | Custom properties (must be pre-defined) |
browserContext | BrowserContext | No | Attribution to attach if this creates a new person |
// Keep this ID and the profile/event input when retrying a submission.
const requestId = crypto.randomUUID();
const { personId, eventId, participantStatus, acceptance } =
await client.audience.people.registerForEvent({
requestId,
emailAddress: "user@example.com",
eventSlug: "spring-webinar",
});
Returns:
{
personId: string;
eventId: string;
participantStatus: "INVITED" | "REGISTERED" | "ATTENDED" | "NO_SHOW" | "CANCELLED";
acceptance?: {
schemaVersion: 1;
requestId: string;
local: {
kind: "accepted";
acceptanceId: string;
participantId: string;
revision: number;
outcome: "created" | "changed" | "unchanged";
};
provider:
| { kind: "pending"; operationId: string }
| { kind: "no-operation-accepted" }
| { kind: "deferred" };
};
}
A deferred provider result means the participant is saved locally but the meeting
connection needs repair before provider registration can proceed. Connection
failures do not reject an otherwise valid signup. Status remains pending until
provider work can resume; deferred receipts never expose a personal join URL.
Fails with 404 when eventSlug doesn't match an event for the business, and
400 when registration isn't allowed in the event's current state (e.g. an
ended event that doesn't accept the requested status).
acceptance is present for events using durable registration. A retry returns
the original response even if the stored profile, event slug, or participation
later changes. The first accepted browser attribution is frozen; refreshed
ambient browser context does not change a retry’s identity. It does not reapply
old consent or overwrite a manual correction. The API revalidates the calling
key before returning that response.
Reusing a request ID with different details returns 409. A paused event or
unresolved provider binding returns 503; a revoked credential returns 403.
Archived or redacted people require explicit administrative review. Event signup
does not itself grant email consent. The existing upsert() consent option
keeps its established meaning when used with an event batch.
Legacy events retain their previous response shape during rollout. Before an
event switches to durable registration, upgrade SDK callers or add requestId
to direct HTTP submissions. A newly constructed SDK request gets a new ID unless
you explicitly provide the original one.
Check Event Registration
audience.people.getEventRegistrationStatus(options)
POST /api/v0/audience/people/event-registration-statusAuth: public or private key · scope:
AUDIENCE_PERSON_WRITE
Use the same API key and original submission ID that accepted the signup. For
registerForEvent, read it from response.acceptance.requestId. A
person-upsert batch uses the response's outer requestId, not an individual
event's child acceptance ID. This also works when the SDK generated the ID. Keep
request IDs private. This read uses POST so the submission ID stays out of URLs
and access-log query strings.
| Param | Type | Required | Description |
|---|---|---|---|
requestId | string | Yes | Original signup or person-upsert UUID |
eventId | string | Yes | An event included in that accepted submission |
const status = await client.audience.people.getEventRegistrationStatus({
requestId: originalRequestId,
eventId: registration.eventId,
});
if (status.provider.kind === "confirmed" && status.provider.joinUrl) {
// Offer this registrant their provider-confirmed join URL.
}
The response contains requestId, eventId, and a provider union:
provider.kind | Meaning | Additional fields |
|---|---|---|
not-requested | The original acceptance did not request provider registration | None |
pending | Provider confirmation is incomplete or participation is temporarily ineligible | reason: Queued, Processing, ProviderApproval, ParticipationIneligible, or UnknownRemoteOutcome |
confirmed | The original registration is still current and provider-approved | joinUrl: string | null |
superseded | The original registration is no longer current | None |
unavailable | Registration needs attention or is unsupported | reason: UnsupportedOccurrence, ProfileRequired, ProviderRejected, BindingChanged, StoredStateRepairRequired, or ReconciliationRequired |
ParticipationIneligible means the original registration still belongs to the
same local intent and provider binding, but current participation prevents
provider work or link disclosure. Clearing a manual correction can resume the
same operation. This also applies to an already approved registration while a
correction hides its link. A changed registration epoch or archived participant
still returns superseded; a replaced provider grant remains unavailable.
Only a private-key confirmed response can contain a join URL. Public-key
responses always return joinUrl: null: a submission receipt does not prove
control of the submitted email address. If your application must display a
personal link, use a trusted server signup/status flow with the same private
credential, after establishing the registrant's identity. A reconnect, archival,
or later registration epoch can invalidate the original operation. An unknown
provider outcome stays pending until reconciled; retrying this read does not
issue another provider registration. Unknown submissions and submissions owned
by a different key return 404. Revoked credentials cannot read previous results.
Subscribe to Email
audience.people.subscribeToEmail(options)
POST /api/v0/audience/people/subscribe-to-emailAuth: public or private key · scope:
AUDIENCE_PERSON_WRITE
Identifies a person, then subscribes them to email, honoring the business's
double opt-in setting. If double opt-in is enabled, a new subscriber (or one who
was never subscribed) lands on PENDING rather than SUBSCRIBED. Any other
existing status is left exactly as it is; this call never overrides an explicit
unsubscribe or a compliance-managed status.
| Param | Type | Required | Description |
|---|---|---|---|
emailAddress | string | Yes | Primary identifier |
firstName | string | No | First name |
lastName | string | No | Last name |
jobTitle | string | No | Job title |
leadScore | number | No | Integer lead score |
lists | string[] | No | List slugs to add the person to |
topics | string[] | No | Topic slugs to opt the person in to |
properties | Record<string, string | number | boolean | Date> | No | Custom properties (must be pre-defined) |
browserContext | BrowserContext | No | Attribution to attach if this creates a new person |
const { personId, emailSubscriptionStatus } =
await client.audience.people.subscribeToEmail({
emailAddress: "user@example.com",
});
Topics. When a signup form is for something specific, like a newsletter or
webinar invites, pass the slugs of the topics it's for. The person is opted in
to each one, and their other topics stay as they were. Without topics, no
topic choices change.
const { topics } = await client.audience.people.subscribeToEmail({
emailAddress: "user@example.com",
topics: ["webinar-invites"],
});
If the person opted out of a listed topic before, they're opted back in to it. This works the same with your public key and your private key, since asking for a topic counts as consent to it. The exception is someone who unsubscribed from all email: they stay unsubscribed, and topics they opted out of stay opted out.
Anyone can copy a public key from your website, so anyone who knows a person's email address can opt them back in to a topic. The response doesn't say which topics the person is opted in to or out of, so a public key can't be used to look up someone's choices.
A slug that doesn't match a live topic (a typo, or a topic that was archived)
doesn't fail the call: the person is still subscribed, and the slug comes back
in topics.unmatched. Topic choices are saved even while the person is
PENDING double opt-in or UNSUBSCRIBED, but no email is sent to them until
they're SUBSCRIBED.
Returns:
{
personId: string;
emailSubscriptionStatus:
| "SUBSCRIBED"
| "UNSUBSCRIBED"
| "PENDING"
| "NOT_SUBSCRIBED"
| "REDACTED"
| "INVALID";
// Only when the request had `topics`
topics?: {
unmatched: string[]; // slugs that don't match a live topic
};
}
Companies
Upsert a Company
audience.companies.upsert(params)
POST /api/v0/audience/companiesAuth: public or private key · scope:
AUDIENCE_COMPANY_WRITE
Creates or updates a company record keyed on domain.
| Param | Type | Required | Description |
|---|---|---|---|
domain | string | Yes | Company domain (upsert key) |
name | string | No | Company name |
description | string | No | Description |
industries | string[] | No | Industry tags |
totalEmployees | number | No | Employee count |
address1 | string | No | Street address |
city | string | No | City |
regionCode | string | No | State/region code |
country | string | No | Country code |
zip | string | No | Postal code |
socialLinkLinkedIn | string | No | LinkedIn URL |
properties | Record<string, string | number | boolean | Date> | No | Custom properties (must be pre-defined) |
const { companyId } = await client.audience.companies.upsert({
domain: "acme.com",
name: "Acme Corp",
description: "Makes everything",
industries: ["manufacturing"],
totalEmployees: 500,
properties: {
arr: 1200000,
fundingStage: "Series B",
},
});
Returns:
{
companyId: string; // Unique identifier (CUID format)
}
Domain is not required to be unique. If exactly one company matches domain,
it's updated; if none match, one is created. If more than one company shares the
same domain, the upsert fails with a 409:
{
domain: string; // The domain that matched more than one company
matchCount: number; // How many companies matched
matchingCompanyIds: string[]; // Ids of the matching companies
}
Lists
The Lists API lets you create and manage audience lists programmatically. Lists are identified by a unique slug and can contain people identified by either their person ID or email address.
All list operations require a private API key. List membership changes
(addPeople, removePeople) are not safe to expose on a public key, so they're
server-side only.
import { QuotientServer } from "@quotientjs/server";
const client = new QuotientServer({
privateKey: "sk_your_private_api_key",
});
Create or Update a List
audience.lists.upsert(options)
POST /api/v0/audience/listsAuth: private key only · scope:
AUDIENCE_LIST_WRITE
The slug is always the upsert key — you either provide it directly, or it gets derived from the name.
| Param | Type | Required | Description |
|---|---|---|---|
name | string | One of name or slug | List name (slug auto-derived if slug omitted) |
slug | string | One of name or slug | Explicit slug to target |
description | string | No | List description |
// By name — slug is auto-generated ("newsletter-subscribers")
await client.audience.lists.upsert({
name: "Newsletter Subscribers",
description: "People who opted into our weekly newsletter",
});
// By slug — target an existing list directly
await client.audience.lists.upsert({
slug: "newsletter-subscribers",
description: "Updated description",
});
// By slug + name — target by slug, rename the list
await client.audience.lists.upsert({
slug: "newsletter-subscribers",
name: "Weekly Newsletter",
});
Upsert behavior:
| Input | Lookup key | Not found | Found |
|---|---|---|---|
{ name } | generateSlug(name) | Create with name + generated slug | Update name |
{ name, description } | generateSlug(name) | Create with both | Update name + description |
{ slug } | slug | Create (name defaults to slug) | No-op |
{ slug, name } | slug | Create with name + slug | Update name |
{ slug, description } | slug | Create (name defaults to slug) | Update description |
{ slug, name, description } | slug | Create with all fields | Update name + description |
Returns:
{
listId: string;
name: string;
slug: string;
}
List All Lists
audience.lists.list(options?)
GET /api/v0/audience/listsAuth: private key only · scope:
AUDIENCE_LIST_READ
| Param | Type | Required | Description |
|---|---|---|---|
search | string | No | Filter by name |
page | number | No | Page number (default 1) |
limit | number | No | Results per page (default 20) |
const { lists, pageData } = await client.audience.lists.list({
search: "newsletter",
page: 1,
limit: 20,
});
Returns:
{
lists: {
id: string;
name: string;
slug: string;
description: string | null;
peopleCount: number;
createdAt: string;
updatedAt: string;
}
[];
pageData: {
page: number;
limit: number;
total: number;
isNextPageAvailable: boolean;
}
}
Get a Single List
audience.lists.get(options)
GET /api/v0/audience/lists/{slug}Auth: private key only · scope:
AUDIENCE_LIST_READ
| Param | Type | Required | Description |
|---|---|---|---|
slug | string | Yes | List slug |
const { list } = await client.audience.lists.get({
slug: "newsletter-subscribers",
});
Returns:
{
list: {
id: string;
name: string;
slug: string;
description: string | null;
peopleCount: number;
createdAt: string;
updatedAt: string;
}
}
List People in a List
audience.lists.listPeople(options)
GET /api/v0/audience/lists/{slug}/peopleAuth: private key only · scope:
AUDIENCE_LIST_READ
| Param | Type | Required | Description |
|---|---|---|---|
listSlug | string | Yes | List slug |
search | string | No | Filter by email |
page | number | No | Page number (default 1) |
limit | number | No | Results per page (default 20) |
const { people, pageData } = await client.audience.lists.listPeople({
listSlug: "newsletter-subscribers",
search: "jane",
page: 1,
limit: 20,
});
Returns:
{
people: {
personId: string;
emailAddress: string;
firstName: string | null;
lastName: string | null;
}
[];
pageData: {
page: number;
limit: number;
total: number;
isNextPageAvailable: boolean;
}
}
Add People to a List
audience.lists.addPeople(options)
POST /api/v0/audience/lists/{slug}/peopleAuth: private key only · scope:
AUDIENCE_LIST_WRITE
Add up to 100 people per request. Each entry can reference an existing person by ID, or provide an email address to upsert a person and add them in one call.
| Param | Type | Required | Description |
|---|---|---|---|
listSlug | string | Yes | List slug |
people | AddPersonEntry[] | Yes | Array of people to add (max 100) |
Each entry in people is one of:
| Param | Type | Required | Description |
|---|---|---|---|
personId | string | Yes (if no email) | Existing person ID |
emailAddress | string | Yes (if no ID) | Email to upsert |
firstName | string | No | First name (with email only) |
lastName | string | No | Last name (with email only) |
jobTitle | string | No | Job title (with email only) |
leadScore | number | No | Lead score (with email only) |
properties | Record<string, ...> | No | Custom properties (with email only) |
await client.audience.lists.addPeople({
listSlug: "newsletter-subscribers",
people: [
{ personId: "clx..." },
{
emailAddress: "jane@example.com",
firstName: "Jane",
lastName: "Doe",
properties: { plan: "pro" },
},
],
});
Returns:
{
added: number;
listSlug: string;
listId: string;
}
Remove People from a List
audience.lists.removePeople(options)
DELETE /api/v0/audience/lists/{slug}/peopleAuth: private key only · scope:
AUDIENCE_LIST_WRITE
Remove up to 100 people per request. Each entry can reference a person by ID or by email address. This operation is idempotent — removing a person who is not in the list (or referencing an email that doesn't exist) is a no-op.
| Param | Type | Required | Description |
|---|---|---|---|
listSlug | string | Yes | List slug |
people | RemovePersonEntry[] | Yes | Array of people to remove (max 100) |
Each entry in people is one of:
| Param | Type | Description |
|---|---|---|
personId | string | Existing person ID |
emailAddress | string | Person's email address |
await client.audience.lists.removePeople({
listSlug: "newsletter-subscribers",
people: [{ personId: "clx..." }, { emailAddress: "jane@example.com" }],
});
Returns:
{
removed: number;
listSlug: string;
listId: string;
}
Custom Properties
Custom properties are the fields you define on people, companies, and deals
beyond the built-in ones. These endpoints manage the property definitions —
the values themselves are written through audience.people.upsert() /
audience.companies.upsert() properties payloads, which validate against the
definitions.
Every definition has an entityType ("person", "company", or "deal"), an
immutable alphanumeric id (the key used in properties payloads), an
immutable datatype, and a readOnly flag. SINGLE_SELECT and MULTI_SELECT
datatypes carry allowedValues — the closed set of allowed options, always at
least 2.
List Custom Properties
audience.properties.list(options)
GET /api/v0/audience/propertiesAuth: private key only · scope:
AUDIENCE_PROPERTY_READ
| Param | Type | Required | Description |
|---|---|---|---|
entityType | "person" | "company" | "deal" | Yes | Which entity's definitions to list |
const { properties } = await client.audience.properties.list({
entityType: "person",
});
Returns:
{
properties: Array<{
id: string;
businessId: string;
entityType: "person" | "company" | "deal";
displayName: string;
description: string | null;
datatype: string; // e.g. "STRING", "NUMBER", "SINGLE_SELECT", ...
allowedValues?: string[]; // present only on select datatypes
readOnly: boolean;
}>;
}
Create a Custom Property
audience.properties.create(options)
POST /api/v0/audience/propertiesAuth: private key only · scope:
AUDIENCE_PROPERTY_WRITE
| Param | Type | Required | Description |
|---|---|---|---|
entityType | "person" | "company" | "deal" | Yes | Which entity the property is defined for |
id | string | Yes | API name — alphanumeric, unique per entity type, immutable |
displayName | string | Yes | Human-readable name shown in the UI |
datatype | string | Yes | Value type (STRING, NUMBER, BOOLEAN, DATE, SINGLE_SELECT, ...) — immutable |
allowedValues | string[] | Yes (select datatypes) | Allowed options — at least 2; forbidden for other datatypes |
readOnly | boolean | No | Read-only properties reject value writes (default false) |
const { property } = await client.audience.properties.create({
entityType: "person",
id: "plan",
displayName: "Plan",
datatype: "SINGLE_SELECT",
allowedValues: ["free", "pro", "enterprise"],
});
Returns:
{
property: CustomPropertyDefinition; // same shape as the list items
}
Fails with 409 when the id already exists for the entity type or collides with
a built-in field, and 422 when the id isn't alphanumeric or a select datatype
has fewer than 2 options.
Update a Custom Property
audience.properties.update(options)
PATCH /api/v0/audience/properties/:propertyIdAuth: private key only · scope:
AUDIENCE_PROPERTY_WRITE
Only displayName, readOnly, and (for select datatypes) appending new options
are supported — id and datatype are immutable, and existing options can't be
renamed or removed.
| Param | Type | Required | Description |
|---|---|---|---|
propertyId | string | Yes | The property's id (API name) |
entityType | "person" | "company" | "deal" | Yes | Which entity the property is defined for |
displayName | string | No | New display name |
readOnly | boolean | No | New read-only flag |
addAllowedValues | string[] | No | NEW options to append (select datatypes only; an exact re-add is a no-op, a case-variant of an existing option is rejected) |
const { property } = await client.audience.properties.update({
propertyId: "plan",
entityType: "person",
addAllowedValues: ["enterprise-plus"],
});
Returns:
{
property: CustomPropertyDefinition; // same shape as the list items
}
Fails with 404 when the property doesn't exist for the entity type, and 422
when addAllowedValues targets a non-select datatype.
Common Patterns
User Identification on Signup
async function identifyUser(user) {
const { personId } = await client.audience.people.upsert({
emailAddress: user.email,
firstName: user.firstName,
lastName: user.lastName,
emailSubscriptionStatus: "SUBSCRIBED",
leadScore: user.leadScore ?? 0,
lists: ["customers", "newsletter"],
properties: {
plan: user.subscription,
signupDate: new Date(),
lastLogin: new Date(),
totalPurchases: user.purchaseCount,
},
});
console.log(`User identified: ${personId}`);
}
Contact Form Capture
async function handleContactForm(formData) {
try {
await client.audience.people.upsert({
emailAddress: formData.email,
firstName: formData.firstName,
lastName: formData.lastName,
lists: ["leads"],
properties: {
message: formData.message,
source: "contact-form",
submittedAt: new Date(),
},
});
void client.analytics.event({
eventType: "custom",
customEventId: "contact_form_submitted",
});
} catch (error) {
console.error("Failed to save lead:", error);
}
}
Create contact_form_submitted in Quotient before sending it. See
Custom Events for the complete browser
workflow.
Syncing a List from an External Source
const client = new QuotientServer({ privateKey: "sk_..." });
// Ensure the list exists
await client.audience.lists.upsert({
name: "Active Customers",
description: "Synced from billing system",
});
// Add people in batches of 100
const customers = getActiveCustomers(); // your data source
for (let i = 0; i < customers.length; i += 100) {
const batch = customers.slice(i, i + 100);
await client.audience.lists.addPeople({
listSlug: "active-customers",
people: batch.map((c) => ({
emailAddress: c.email,
firstName: c.firstName,
lastName: c.lastName,
properties: {
plan: c.plan,
mrr: c.mrr,
},
})),
});
}