Currency

Contacts

A contact is a person on the merchant's marketing list — the audience the marketing-campaign sends fan out to. A contact is identified by email or phone (at least one is required), and carries names, social handles, free-form attributes and per-channel subscription state. This page covers ripllo.contacts on the Node SDK. For the HTTP surface and field tables, see API → Contacts.

A few of this namespace's SDK return types are optimistic. list, import and delete are declared with shapes the server doesn't send. The runtime shapes documented below are what you actually get; the declared types are being corrected. Read the response, not the .d.ts.

Namespace

ripllo.contacts.list(params?)
ripllo.contacts.get(id)
ripllo.contacts.create(input)
ripllo.contacts.update(id, patch)
ripllo.contacts.delete(id)
ripllo.contacts.import(input)

Six methods: standard CRUD plus a bulk import. The contact namespace overlaps with checkout-time customer creation on Storlaunch — same human, different bounded context. Ripllo doesn't deduplicate against Storlaunch's Customer table; the partner stamps externalRef so you can correlate.

Methods

contacts.create

Signature. ripllo.contacts.create(input): Promise<Contact>

Creates a contact — or updates the matching one. The accepted fields are email, phone, firstName, lastName, socialHandles, subscriptions, attributes, source, externalRef. Any other key is silently dropped by the server's schema.

At least one of email or phone is required (400 VALIDATION otherwise).

const c = await ripllo.contacts.create({
  email: 'ada@example.com',
  firstName: 'Ada',
  lastName: 'Lovelace',
  phone: '+62812xxxxxxxx',
  socialHandles: { instagram: '@ada' },
  attributes: { signup_source: 'newsletter-modal', plan: 'pro' },
  subscriptions: { email: 'subscribed', sms: 'unsubscribed' },
  source: 'storlaunch_customer',
  externalRef: '<storlaunchCustomerId>',
});

Create is an upsert, not a strict insert. The server derives identityHash = sha256(lower(email) + "|" + digits(phone)) and upserts on (accountId, identityHash). Calling create twice for the same person returns 201 both times and overwrites the supplied fields on the existing row — there is no 409 on this path. Don't build "create, catch conflict" logic; it will never fire.

Note that source (not externalSource) is the provenance field, and consent lives in subscriptions, not a consent object.

contacts.get

Signature. ripllo.contacts.get(id): Promise<Contact>

Fetches by ID — an opaque cuid, no ct_ prefix. Throws NOT_FOUND for unknown or cross-workspace IDs. The response includes the contact's tags and lists relations.

contacts.list

Signature. ripllo.contacts.list(params?)

Cursor-paginated. Default page size 50, max 100 (larger values are clamped, not rejected). The free-text filter is q — a case-insensitive substring match against email, firstName, lastName and phone.

Resolves to { data: Contact[]; cursor: string | null; hasMore: boolean }. (The SDK's declared type still says { contacts, nextCursor }; that shape is never sent — page.contacts is undefined at runtime.)

const page = await ripllo.contacts.list({ limit: 100, q: 'gmail.com' });
for (const c of page.data) console.log(c.email);

let cursor = page.cursor;
while (cursor) {
  const next = await ripllo.contacts.list({ limit: 100, cursor });
  for (const c of next.data) console.log(c.email);
  cursor = next.hasMore ? next.cursor : null;
}

contacts.update

Signature. ripllo.contacts.update(id, patch): Promise<{ id: string }>

PATCH semantics over the same field set as create. Resolves to { id } — not the updated contact; re-get if you need it back.

attributes, subscriptions and socialHandles are replaced wholesale, not merged: send the complete object you want stored, including the keys you're keeping.

Changing email or phone re-derives identityHash. If that lands on another contact's identity in the same workspace, the call fails with 409 IDENTITY_TAKEN.

await ripllo.contacts.update('clx3k9v0000...', {
  attributes: { signup_source: 'newsletter-modal', plan: 'free', churn_reason: 'price' },
});

contacts.delete

Signature. ripllo.contacts.delete(id)

This is a soft unsubscribe, not a delete. The row stays, with every PII field intact; the only change is subscriptions being set to { email: 'unsubscribed', sms: 'unsubscribed', whatsapp: 'unsubscribed' }. The response is { id, status: 'unsubscribed' } (despite the SDK's declared { deleted: boolean }).

It does not satisfy GDPR right-to-erasure. There is no self-serve hard-delete endpoint — erasure requests have to go through Forjio support, and you must not report an erasure as completed on the strength of this call.

contacts.import

Signature. ripllo.contacts.import(input)

Bulk upsert, one row at a time server-side, deduped on the same (accountId, identityHash) key as create. The input is { rows, listName?, skipExisting? }:

Field Notes
rows 1–5000 contact shapes, same fields as create.
listName Optional. Every imported contact is added to this list, which is created on the fly if it doesn't exist.
skipExisting Optional. true leaves already-known identities untouched instead of updating them (append-only import).

Resolves to { created, updated, skipped, errors, listId }, where errors is { row, reason }[] — rows with neither email nor phone land there and count as skipped, they don't fail the batch. There is no imported count and no onConflict option. Rows default to source: 'csv_import' when you don't set source.

const { created, updated, skipped, errors } = await ripllo.contacts.import({
  rows: csvRows.map((r) => ({
    email: r.email,
    firstName: r.first_name,
    attributes: { source: r.utm_source },
  })),
  listName: 'imported-2026-05',
});

For files larger than 5000 rows, chunk client-side — the server returns VALIDATION on oversize batches.

Types

interface Contact {
  id: string;                  // opaque cuid
  accountId: string;
  identityHash: string;        // sha256 of lower(email)|digits(phone)
  email: string | null;
  phone: string | null;        // E.164
  firstName: string | null;
  lastName: string | null;
  socialHandles: Record<string, string>;
  // Per-channel consent: 'subscribed' | 'unsubscribed' | 'pending' | 'bounced'
  subscriptions: Record<string, string>;
  attributes: Record<string, unknown>;
  source: string | null;       // provenance, e.g. 'storlaunch_customer', 'csv_import'
  externalRef: string | null;
  createdAt: string;
  updatedAt: string;
}

There is no tags: string[] field on the contact you send. Tags are a separate ContactTag relation, returned (currently always empty) by contacts.get — no endpoint writes them yet.

Common patterns

Upsert by email

No conflict-handling needed — create already upserts on the email/phone identity:

async function upsertContact(email: string, attrs: Record<string, unknown>) {
  return ripllo.contacts.create({ email, ...attrs });
}

Keep a local index of email → id anyway if you need the contact ID without a round-trip: create returns the full row, so stamp it on the first call.

Honor an opt-out

await ripllo.contacts.update(contactId, {
  subscriptions: { email: 'unsubscribed', sms: 'unsubscribed', whatsapp: 'unsubscribed' },
});

Consent lives entirely in subscriptions, and the object is replaced wholesale — send every channel you want recorded, not just the one being revoked. A consent: { marketing: false } patch is silently dropped by the schema and the contact keeps receiving mail.

Don't reach for delete to stop sending; it does exactly this update, and nothing more.

CSV import

import { parse } from 'csv-parse/sync';

const rows = parse(fs.readFileSync('list.csv'), { columns: true });
const chunks = [];
for (let i = 0; i < rows.length; i += 5000) chunks.push(rows.slice(i, i + 5000));

for (const chunk of chunks) {
  const res = await ripllo.contacts.import({
    rows: chunk.map((r) => ({ email: r.email, firstName: r.first_name })),
    listName: 'newsletter',
  });
  console.log(res.created, res.updated, res.skipped, res.errors);
}

Errors

Code Status Cause
VALIDATION 400 Bad email shape, neither email nor phone, oversized import batch (>5000 rows).
IDENTITY_TAKEN 409 A PATCH changed email/phone onto another contact's identity in this workspace.
NOT_FOUND 404 Contact ID doesn't exist in this workspace.
NO_ACCOUNT 403 The principal carries no accountId.
INSUFFICIENT_SCOPE 403 API key lacks the read (GET) or write (mutation) scope.

See Errors for handling.

Next

CurrencyRupiah is paid by transfer or QRIS; US dollars settle through PayPal.