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,importanddeleteare 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 returns201both times and overwrites the supplied fields on the existing row — there is no409on 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
subscriptionsbeing 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
- Contact lists — static groupings of contacts.
- Audience segments — dynamic queries over contacts.
- API → Contacts — full HTTP reference.