CRM
Contacts, audiences, campaigns, and lead research
The CRM module manages contact records and audiences for your store. Contacts are store-scoped. A contact can be linked to orders and audiences, and can authenticate via email magic codes.
Key Concepts
Contact vs Account
- Account — platform-level auth identity. One person, one Account across all stores.
- Contact — lean store-level CRM record with status and taxonomies. Email, phone, social handles, provider subjects, verification, and consent live in separate ContactChannel records.
Taxonomies
Contact classification and searchable custom fields are stored as taxonomies — the same content model used across the platform. There is no direct contact tags field; tags and segments are taxonomy entries.
Email And Channels
Contacts can exist without email. Email, phone, and social identities live on contact channels, and verified email/phone/provider IDs can resolve back to an existing contact.
Storefront Account Flow
Buyer-facing flows use Account language. Under the hood, account sessions attach to contacts.
/v1/storefront/account/identify arky.identify() /v1/storefront/account/code arky.identify({ verify: true }) /v1/storefront/account/verify arky.verify() /v1/storefront/account/me arky.me() /v1/storefront/account/logout arky.logout() The SDK sends the publishable key as X-Arky-Publishable-Key. On the first
stateful call it receives and stores an arky_vst_... visitor token; later
account, cart, action, and support requests reuse that visitor session.
Admin Contact Endpoints
Create Contact (Admin)
/v1/stores/{storeId}/contacts sdk.crm.contact.create() Creates a new contact. If a contact with the same email already exists, returns the existing one (idempotent).
Parameters
| Name | Type | Description |
|---|---|---|
email required | string | Primary email for the contact |
taxonomies optional | TaxonomyEntry[] | Taxonomy entries for segments, lifecycle stage, or other custom classification |
Get Contact
/v1/stores/{storeId}/contacts/{id} sdk.crm.contact.get() The Contact response is intentionally lean. Load channels only when the view needs addresses, handles, consent, or verification:
const [contact, channels] = await Promise.all([
sdk.crm.contact.get({ id: "contact-id" }),
sdk.crm.contact.getChannels({ id: "contact-id" }),
]);
For a list, avoid one request per Contact by using the bounded batch endpoint:
/v1/stores/{storeId}/contacts/channels sdk.crm.contact.findChannels() const contacts = await sdk.crm.contact.find({ limit: 20 });
const channels = await sdk.crm.contact.findChannels({
contact_ids: contacts.items.map((contact) => contact.id),
});
Find Contacts
/v1/stores/{storeId}/contacts sdk.crm.contact.find() Search and list contacts.
Parameters
| Name | Type | Description |
|---|---|---|
query optional | string | Search across contact email, ID, and indexed taxonomy values |
taxonomy_query optional | TaxonomyQuery[] | Filter by taxonomy entries |
status optional | 'active' | 'archived' | Filter by contact status |
limit optional | number | Items per page |
cursor optional | string | Pagination cursor |
sort_field optional | string | Field to sort by (e.g. created_at) |
sort_direction optional | string | asc or desc |
Update Contact
/v1/stores/{storeId}/contacts/{id} sdk.crm.contact.update() Parameters
| Name | Type | Description |
|---|---|---|
id required | string | Contact ID |
email optional | string | Replace the contact's email |
taxonomies optional | TaxonomyEntry[] | Replace taxonomy entries |
status optional | 'active' | 'archived' | Contact status |
Merge Contacts
/v1/stores/{storeId}/contacts/{id}/merge sdk.crm.contact.merge() Merges a source contact into the target. The source is permanently deleted (GDPR-compliant). Orders and audience entries are moved onto the target contact.
List Contact Sessions
/v1/stores/{storeId}/contacts/{contactId}/sessions sdk.crm.contact.findSessions() const sessions = await sdk.crm.contact.findSessions({
contact_id: "contact-id",
limit: 25,
});
Revoke Contact Session
/v1/stores/{storeId}/contacts/{contactId}/sessions/{sessionId} sdk.crm.contact.revokeSession() Revokes a single active session for a contact.
Revoke All Contact Sessions
/v1/stores/{storeId}/contacts/{contactId}/sessions sdk.crm.contact.revokeAllSessions() Revokes every active session for a contact (logout everywhere).
Contact Object
{
"id": "uuid",
"store_id": "uuid",
"status": "active",
"taxonomies": [],
"created_at": 1234567890,
"updated_at": 1234567890
}
Audiences
An Audience groups CRM Contacts for operator segmentation, storefront signup, double opt-in,
paid membership, newsletters, and Campaign targeting. Contact is the person. AudienceMember
is the separate relationship between one Contact and one Audience.
An Audience has exactly one enrollment type:
private: only an operator, import, or lead-research workflow may add members;open: a storefront signup activates membership immediately;confirmation: signup remains pending until the Contact confirms through the configured email template; orpaid: one-time or recurring Stripe payment is required.
Audience membership does not grant CMS, Media, file, or digital-product access. It can qualify a Contact for Audience-restricted pricing and Campaign delivery, but protected digital delivery is a separate product boundary.
Create An Audience
/v1/stores/{storeId}/audiences sdk.crm.audience.create() const updates = await sdk.crm.audience.create({
key: "product-updates",
name: "Product Updates",
type: {
type: "confirmation",
template_id: "template_abc123",
confirmation_url: "https://merchant.example/newsletter/confirm",
},
});
const premium = await sdk.crm.audience.create({
key: "premium",
name: "Premium",
type: { type: "paid" },
});
New Audiences are Draft. A confirmation Audience requires a same-Store active
subscription_confirmation template and an operator-configured absolute confirmation page URL.
The public signup request cannot replace that destination. A paid Audience cannot become Active
until it has an active, provider-ready Tier with at least one active Price.
Configure A Paid Tier
Tiers and Prices are bounded children of the Audience, not separate plans. Arky provisions their Stripe Product and Price objects in the selected connected account.
/v1/stores/{storeId}/audiences/{audienceId}/tiers sdk.crm.audience.tiers.create() const tier = await sdk.crm.audience.tiers.create({
audience_id: premium.id,
key: "supporter",
name: "Supporter",
description: "Support the newsletter and receive member pricing.",
benefits: ["Member pricing"],
status: "active",
provider: {
type: "stripe",
payment_provider_id: "payment_provider_abc123",
},
prices: [
{
currency: "eur",
amount: 999,
interval: { period: "month", count: 1 },
status: "active",
},
],
});
const localPriceId = tier.prices[0].id;
Use the local Price ID in storefront checkout. Never send a Stripe Price ID from a browser. A provisioned Price’s amount, currency, and interval are immutable; archive it and add a replacement when repricing.
Read And Update Audiences
/v1/stores/{storeId}/audiences/{id} sdk.crm.audience.get() /v1/stores/{storeId}/audiences sdk.crm.audience.find() /v1/stores/{storeId}/audiences/{id} sdk.crm.audience.update() const audience = await sdk.crm.audience.get({ id: premium.id });
const page = await sdk.crm.audience.find({
query: "premium",
status: "active",
limit: 20,
});
await sdk.crm.audience.update({
id: premium.id,
status: "active",
});
status is draft | active | archived; Archived is terminal. The enrollment type may change only
while the resulting Audience is an empty Draft with no Member or Tier history. Once it has history,
create a new Audience for a different enrollment model. A confirmation template may be replaced
without changing the enrollment model.
Storefront Discovery And Signup
There is intentionally no public Audience directory. A storefront retrieves one configured active Audience by key. Private Audiences return Not Found.
/v1/storefront/audiences/{key} arky.crm.audience.get() /v1/storefront/audiences/{audienceId}/subscribe arky.crm.audience.subscribe() import { mountCheckoutAction } from "arky-sdk/storefront";
const offer = await arky.crm.audience.get({ key: "premium" });
const tiers = await arky.crm.audience.tiers.find({ audience_id: offer.id });
const result = await arky.crm.audience.subscribe({
audience_id: offer.id,
price_id: tiers.items[0].prices[0].id,
promo_code: "WELCOME10",
return_url: "https://merchant.example/membership/complete",
});
if (result.payment_action.type === "stripe_embedded_checkout") {
const checkout = await mountCheckoutAction(
result.payment_action,
"#stripe-checkout",
);
// Later, when the checkout container is removed: checkout.destroy();
}
Open signup returns an active member. Confirmation signup returns a pending Member and sends the
opaque token only to the Audience’s operator-configured confirmation page. Open and Confirmation
signup do not require a Market because they do not collect money. Paid signup requires a local
price_id and returns the exact payment, Member, and a closed
stripe_embedded_checkout | none action. Stripe Checkout stays embedded in the merchant page.
Browser completion never grants access; authoritative Server state does.
Checkout return_url values must be absolute HTTPS URLs outside local development. HTTP is
accepted only for localhost names and literal loopback addresses; credentials are rejected and
fragments are removed.
Check Access And List The Current Contact’s Memberships
/v1/storefront/audiences/{audienceId}/access arky.crm.audience.checkAccess() /v1/storefront/audiences/members arky.crm.audience.members.find() const access = await arky.crm.audience.checkAccess({
audience_id: offer.id,
});
if (access.has_access) {
console.log(access.member);
}
const memberships = await arky.crm.audience.members.find({ limit: 20 });
These responses expose only safe Audience, Member, current access, and payment-summary fields. They do not expose connected-account, Stripe Customer, Stripe Subscription, or provider diagnostic state. Inactive and private Audiences are omitted from the storefront membership list.
Customer Management, Email Unsubscribe, And Billing
Audience emails link to /customer/audiences/manage?token={token}. The opaque credential resolves
one Member without putting Store, Contact, Stripe, or other member IDs in the URL.
/v1/customer/audiences/manage sdk.customer.audience.manage() /v1/customer/audiences/confirm sdk.customer.audience.confirm() /v1/customer/audiences/unsubscribe sdk.customer.audience.unsubscribe() /v1/customer/audiences/payment-method sdk.customer.audience.createPaymentMethodSession() /v1/customer/audiences/subscription/cancel sdk.customer.audience.cancelSubscription() const state = await sdk.customer.audience.manage({ token });
await sdk.customer.audience.unsubscribe({ token }); // email delivery only
if (state.payment_method_update_available) {
const { portal_url } = await sdk.customer.audience.createPaymentMethodSession({
token,
return_url: "https://merchant.example/account",
});
window.location.assign(portal_url);
}
if (state.subscription_cancellation_available) {
await sdk.customer.audience.cancelSubscription({ token });
}
Email delivery and paid access are independent. Unsubscribe changes only delivery_status; it
does not cancel membership or billing. Payment-method collection uses one exact Stripe-hosted
direct flow, while cancellation is Arky’s typed cancel-at-period-end lifecycle; customers never
enter an unrestricted portal that could switch the provider Price behind Arky’s Tier snapshot.
Audience-backed newsletter messages include RFC 8058 one-click unsubscribe headers, and the
one-click URL accepts POST only.
For recurring access, only an exactly correlated signed invoice.paid event advances the
Member’s paid-through period. Subscription events update status and cancellation state but never
create paid time. Invoice failures are not fake Payment rows: the resulting Subscription lifecycle
state preserves at most an already-paid period and missing period boundaries fail closed. A new
Checkout also cannot overwrite a nonterminal Stripe Subscription; update its payment method or
complete its cancellation first.
Administer Members
/v1/stores/{storeId}/audiences/{audienceId}/members sdk.crm.audience.members.find() /v1/stores/{storeId}/audiences/{audienceId}/members sdk.crm.audience.members.add() /v1/stores/{storeId}/audiences/{audienceId}/members/{memberId} sdk.crm.audience.members.update() /v1/stores/{storeId}/audiences/{audienceId}/members/{memberId} sdk.crm.audience.members.remove() const added = await sdk.crm.audience.members.add({
audience_id: updates.id,
contact_id: "contact_abc123",
});
await sdk.crm.audience.members.update({
audience_id: updates.id,
member_id: added.id,
lead_description: "Requested the enterprise guide.",
});
const leads = await sdk.crm.audience.leads.find({
member_ids: [added.id],
});
const outcome = await sdk.crm.audience.members.remove({
audience_id: updates.id,
member_id: added.id,
});
if (outcome.type === "subscription_cancellation_requested") {
// The recurring Member stays active through the paid period. Reload it for current state.
}
AudienceMember responses stay lean: lead insight is returned only by the explicit bounded
GET /v1/stores/{storeId}/audiences/leads batch read shown above. Direct add, import,
enrollment-status edits, and lead research are allowed only for active Private
or Open Audiences. Confirmation and Paid membership must pass through their configured storefront
flow. Member field and lead-metadata edits remain available without bypassing those lifecycle
rules. Removal returns removed | subscription_cancellation_requested | already_removed. A paid
recurring Member is not falsely reported as removed: the first request schedules cancellation at
period end, and the Member remains active until authoritative provider state ends access.
Payments And Refunds
AudiencePayment is the exact initial, renewal, or one-time collection record. One Member may have
many Payments. AudienceRefund is separate because one Payment may have several partial refunds.
The Audience model has no generic payment transaction, checkout-attempt, or provider-event record.
The Payment’s payer_contact_id is an immutable payer snapshot; a verified same-email Contact merge
can move the Member to the surviving Contact without rewriting payment history or breaking later
Stripe event correlation.
/v1/stores/{storeId}/audiences/{audienceId}/members/{memberId}/payments sdk.crm.audience.members.payments.find() /v1/stores/{storeId}/audiences/{audienceId}/members/{memberId}/payments/{paymentId}/refunds sdk.crm.audience.members.refund() /v1/stores/{storeId}/audiences/{audienceId}/members/{memberId}/refunds sdk.crm.audience.members.refunds.find() Generate one durable refund ID for one intended refund and reuse it with the same amount if the
request is interrupted. Omit amount to request the full remaining refundable amount.
import {
clearDurableRequest,
getOrCreateDurableRequest,
withDurableRequestLock,
} from "arky-sdk/utils";
const request = {
audience_id: premium.id,
member_id: "member_abc123",
payment_id: "payment_abc123",
amount: 999,
};
const storageKey = `arky:audience-refund:${premium.store_id}:${premium.id}:${request.payment_id}`;
const refund = await withDurableRequestLock(storageKey, "Audience refund", async () => {
const durable = getOrCreateDurableRequest(storageKey, request, "Audience refund");
const response = await sdk.crm.audience.members.refund({
...request,
refund_id: durable.id,
});
if (response.refund_id !== durable.id) throw new Error("Refund ID mismatch");
clearDurableRequest(durable, "Audience refund");
return response;
});
Refund states are requested | processing | succeeded | failed | rejected | unknown. An Unknown
outcome is never silently repeated. Automatic retry is allowed only when Arky proved that the
provider call never started; otherwise inspect and reconcile the exact provider object.
Import Members
/v1/stores/{storeId}/audiences/{audienceId}/members/import sdk.crm.audience.importMembers() The import upserts Contact profiles and creates or updates AudienceMember relationships in one transient operation; it does not create a stored import job.
const result = await sdk.crm.audience.importMembers({
audience_id: updates.id,
rows: [
{
email: "user@example.com",
fields: { source: "landing-page" },
},
],
});
console.log(result.members_added, result.members_updated, result.rows_failed);
Campaigns
Campaigns are one-off audience email sends or sequences. Create a campaign, choose mailbox sender(s), choose email templates for each step, import enrollments from audiences, contacts, or manual emails, then launch it.
Campaign enrollments are fixed for that campaign once imported. Replies and sent messages stay attached to that campaign conversation.
Create Campaign
/v1/stores/{storeId}/campaigns sdk.outreach.campaign.create() Import Campaign Enrollments
/v1/stores/{storeId}/campaigns/{id}/enrollments/import sdk.outreach.campaign.importEnrollments() Import from one audience, multiple audiences, individual contacts, or manual emails.
Launch Campaign
/v1/stores/{storeId}/campaigns/{id}/launch sdk.outreach.campaign.launch() await sdk.outreach.campaign.launch({ id: campaign.id });
Campaign messages keep the template ID, template variables, rendered subject, rendered HTML, rendered text, attachments, and mailbox ID. Editing the template later does not mutate sent campaign messages.
Duplicate Campaign
/v1/stores/{storeId}/campaigns/{id}/duplicate sdk.outreach.campaign.duplicate() Duplicate a campaign when you want to reuse the setup for a new one-off send.
const draft = await sdk.outreach.campaign.duplicate({
id: campaign.id,
name: "Weekly Newsletter - July",
copy_enrollments: true,
});
Duplicating copies campaign setup and the current enrollment set into a new draft. It does not copy sent messages, replies, or conversation history.
Reply To A Campaign Enrollment
/v1/stores/{storeId}/campaign-enrollments/{id}/reply sdk.outreach.campaignEnrollment.reply() const reply = await sdk.outreach.campaignEnrollment.reply({
message_id: "018f477d-1cae-7c12-bf12-123456789abd",
store_id: "store_abc123",
id: "enrollment_abc123",
subject: "Re: Your question",
body: "Here is the information you requested.",
attachments: [],
});
message_id is a required UUID persisted with the exact subject, body, and attachments. An exact
redelivery returns the same durable manual-reply message without enqueueing another delivery; reuse
with a changed request is rejected. Browser clients should follow the
durable-request pattern.
Lead Research
Send A Lead-Research Message
/v1/stores/{storeId}/lead-research/runs/{runId}/messages sdk.outreach.leadResearch.sendMessage() const result = await sdk.outreach.leadResearch.sendMessage({
message_id: "018f477d-1cae-7c12-bf12-123456789abe",
store_id: "store_abc123",
run_id: "research_run_abc123",
message: "Find independent bicycle shops in Sarajevo.",
});
Persist message_id with the exact run and message before sending. An exact redelivery reuses the
saved user turn without consuming the run quota or starting the AI work again; reuse with a changed
message is rejected. Use the same
durable-request pattern in browser clients.
Typical Flows
E-commerce Checkout
import { initialize } from "arky-sdk/storefront";
const arky = initialize("arky_pk_...");
// 1. Attach the shopper's account/contact identity
await arky.identify({ email: shippingAddress.email });
// 2. Checkout the cart; the backend resolves the contact from the session token
await arky.eshop.cart.checkout({
shipping_address: shippingAddress,
clear_after_checkout: true,
});
Confirmation Audience Signup
const arky = initialize("arky_pk_...");
await arky.identify({ email: "user@example.com" });
// Subscribe to the audience. The Audience's configured confirmation_url receives the email token.
await arky.crm.audience.subscribe({
audience_id: audienceId,
});
// On your confirmation page, consume the opaque token from the email link.
const token = new URLSearchParams(window.location.search).get("token");
if (!token) throw new Error("Missing confirmation token");
await arky.crm.audience.confirm({ token });
Account Login (Magic Code)
const arky = initialize("arky_pk_...");
// 1. Ask for a code and retain the returned challenge ID
const identification = await arky.identify({
email: "user@example.com",
verify: true,
});
const challenge = identification.verification_challenge;
if (!challenge) throw new Error("Verification challenge was not issued");
// 2. User enters the code from their email
await arky.verify({ challenge_id: challenge.challenge_id, code: "123456" });
// 3. The storefront SDK persists the contact session
const account = await arky.me();
An active confirmation template cannot be deleted while an Audience references it. Replace the Audience’s template first, so double opt-in can never silently degrade to single opt-in.