Back to Arky

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.

POST /v1/storefront/account/identify
SDK: arky.identify()
POST /v1/storefront/account/code
SDK: arky.identify({ verify: true })
POST /v1/storefront/account/verify
SDK: arky.verify()
GET /v1/storefront/account/me
SDK: arky.me()
POST /v1/storefront/account/logout
SDK: 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)

POST /v1/stores/{storeId}/contacts
SDK: 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

GET /v1/stores/{storeId}/contacts/{id}
SDK: 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:

GET /v1/stores/{storeId}/contacts/channels
SDK: 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

GET /v1/stores/{storeId}/contacts
SDK: 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

PUT /v1/stores/{storeId}/contacts/{id}
SDK: 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

POST /v1/stores/{storeId}/contacts/{id}/merge
SDK: 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

GET /v1/stores/{storeId}/contacts/{contactId}/sessions
SDK: sdk.crm.contact.findSessions()
const sessions = await sdk.crm.contact.findSessions({
	contact_id: "contact-id",
	limit: 25,
});

Revoke Contact Session

DELETE /v1/stores/{storeId}/contacts/{contactId}/sessions/{sessionId}
SDK: sdk.crm.contact.revokeSession()

Revokes a single active session for a contact.

Revoke All Contact Sessions

DELETE /v1/stores/{storeId}/contacts/{contactId}/sessions
SDK: 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; or
  • paid: 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

POST /v1/stores/{storeId}/audiences
SDK: 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.

POST /v1/stores/{storeId}/audiences/{audienceId}/tiers
SDK: 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

GET /v1/stores/{storeId}/audiences/{id}
SDK: sdk.crm.audience.get()
GET /v1/stores/{storeId}/audiences
SDK: sdk.crm.audience.find()
PUT /v1/stores/{storeId}/audiences/{id}
SDK: 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.

GET /v1/storefront/audiences/{key}
SDK: arky.crm.audience.get()
POST /v1/storefront/audiences/{audienceId}/subscribe
SDK: 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

GET /v1/storefront/audiences/{audienceId}/access
SDK: arky.crm.audience.checkAccess()
GET /v1/storefront/audiences/members
SDK: 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.

POST /v1/customer/audiences/manage
SDK: sdk.customer.audience.manage()
POST /v1/customer/audiences/confirm
SDK: sdk.customer.audience.confirm()
POST /v1/customer/audiences/unsubscribe
SDK: sdk.customer.audience.unsubscribe()
POST /v1/customer/audiences/payment-method
SDK: sdk.customer.audience.createPaymentMethodSession()
POST /v1/customer/audiences/subscription/cancel
SDK: 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

GET /v1/stores/{storeId}/audiences/{audienceId}/members
SDK: sdk.crm.audience.members.find()
POST /v1/stores/{storeId}/audiences/{audienceId}/members
SDK: sdk.crm.audience.members.add()
PATCH /v1/stores/{storeId}/audiences/{audienceId}/members/{memberId}
SDK: sdk.crm.audience.members.update()
DELETE /v1/stores/{storeId}/audiences/{audienceId}/members/{memberId}
SDK: 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.

GET /v1/stores/{storeId}/audiences/{audienceId}/members/{memberId}/payments
SDK: sdk.crm.audience.members.payments.find()
POST /v1/stores/{storeId}/audiences/{audienceId}/members/{memberId}/payments/{paymentId}/refunds
SDK: sdk.crm.audience.members.refund()
GET /v1/stores/{storeId}/audiences/{audienceId}/members/{memberId}/refunds
SDK: 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

POST /v1/stores/{storeId}/audiences/{audienceId}/members/import
SDK: 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

POST /v1/stores/{storeId}/campaigns
SDK: sdk.outreach.campaign.create()

Import Campaign Enrollments

POST /v1/stores/{storeId}/campaigns/{id}/enrollments/import
SDK: sdk.outreach.campaign.importEnrollments()

Import from one audience, multiple audiences, individual contacts, or manual emails.

Launch Campaign

POST /v1/stores/{storeId}/campaigns/{id}/launch
SDK: 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

POST /v1/stores/{storeId}/campaigns/{id}/duplicate
SDK: 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

POST /v1/stores/{storeId}/campaign-enrollments/{id}/reply
SDK: 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

POST /v1/stores/{storeId}/lead-research/runs/{runId}/messages
SDK: 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();
Tip

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.