Contacts
A contact is a person the merchant can reach out to — an email address, a phone number, a social handle, or any combination. Contacts are the audience layer underneath every marketing campaign, funnel, and segment. They're stored per-merchant; there is no cross-merchant contact graph.
Contacts dedupe on identityHash, a SHA-256 of normalised email plus normalised phone. So two rows with the same (accountId, email, phone) always converge to one record — idempotent upsert is the default.
Endpoints
| Method | Path | Purpose |
|---|---|---|
GET |
/api/v1/contacts |
List contacts |
POST |
/api/v1/contacts |
Create or upsert a contact |
POST |
/api/v1/contacts/import |
Bulk upsert |
GET |
/api/v1/contacts/:id |
Retrieve a contact |
PATCH |
/api/v1/contacts/:id |
Update a contact |
DELETE |
/api/v1/contacts/:id |
Soft-delete a contact |
All endpoints require the calling key to have the merchant role (via the requireRole('merchant') middleware). Platform-admin keys with X-Ripllo-On-Behalf-Of are treated as that merchant for these operations.
List contacts
GET /api/v1/contacts
Cursor-paginated. Supports a free-form q parameter that runs a case-insensitive contains-match across email, first name, last name, and phone.
Query parameters
| Param | Default | Notes |
|---|---|---|
limit |
50 |
Clamped to [1, 100]. |
cursor |
— | Opaque cursor returned in the previous response. |
q |
— | Free-form search. Empty string = no filter. |
Response
{
"data": {
"data": [
{
"id": "clw3k9y8p0001v8f4d2rj5nq0",
"accountId": "acc_01HX...",
"email": "alice@example.com",
"phone": "+62811234567",
"firstName": "Alice",
"lastName": "Tan",
"socialHandles": { "telegram": "@alicetan", "instagram": "alice.tan" },
"subscriptions": { "email": "subscribed", "sms": "subscribed" },
"attributes": { "segment": "vip" },
"source": "csv_import",
"externalRef": "crm_user_19823",
"createdAt": "2026-05-01T10:42:00.000Z",
"updatedAt": "2026-05-13T08:11:00.000Z"
}
],
"cursor": "clw3k9y8p0001v8f4d2rj5nq0",
"hasMore": true
},
"error": null,
"meta": { "requestId": "...", "timestamp": "..." }
}
(The inner data array inside data is intentional — the resource wraps its own paged collection envelope on top of the standard response envelope. Yes, it's slightly awkward; it predates the conventions doc.)
Create or upsert a contact
POST /api/v1/contacts
Idempotent upsert keyed on (accountId, identityHash). If a contact with the same identity hash already exists it is updated in place; otherwise a new row is created. Both cases answer 201 Created with the same shape — there is no created flag, so insert and update are indistinguishable from the response alone. If you need to know which happened, GET first.
At least one of email or phone is required. A body with neither is rejected with 400 VALIDATION (email or phone required), even though both fields are individually optional.
Request body
| Field | Type | Notes |
|---|---|---|
email |
string | null | RFC-valid. Lowercased before hashing. Required unless phone is set. |
phone |
string (4–40) | null | Normalised: [^+0-9] stripped before hashing. So (+62) 811 234 567 and +62811234567 produce the same identity. Required unless email is set. |
firstName, lastName |
string (≤120) | null | |
socialHandles |
object | undefined | String-to-string map. Common keys: telegram, instagram, line, whatsapp, discord. Unknown keys are preserved. |
subscriptions |
object | undefined | String-to-string map. Suggested values: subscribed, unsubscribed, pending. Per-channel preference; default policy is "subscribed unless otherwise stated". |
attributes |
object | undefined | Free-form. The dashboard surfaces it as a key-value list; segments can filter on it. |
source |
string (≤120) | null | Free-form tag for where the contact came from (csv_import, signup_form, storlaunch_checkout, etc.). |
externalRef |
string (≤200) | null | Your own CRM ID. |
Response — 201 Created
data is the contact row. There is no contact wrapper.
{
"data": { /* full Contact object */ },
"error": null,
"meta": { "requestId": "...", "timestamp": "..." }
}
Errors
| Status | error.code |
When |
|---|---|---|
400 |
VALIDATION |
Shape wrong, or neither email nor phone was supplied. |
403 |
NO_ACCOUNT |
Caller's token has no accountId. |
Examples
const contact = await ripllo.contacts.create({
email: 'alice@example.com',
phone: '+62811234567',
firstName: 'Alice',
attributes: { segment: 'vip' },
source: 'crm_sync',
externalRef: 'crm_user_19823',
});
Bulk upsert
POST /api/v1/contacts/import
Same dedupe semantics as the single-row upsert, but bulk. Rows are processed sequentially; a row that can't be imported doesn't roll back the rest of the batch.
The whole body is schema-validated before any row is written, so a single malformed value (a non-RFC email, a phone under 4 chars, a firstName over 120) rejects the entire request with 400 VALIDATION and imports zero rows. Validate your CSV client-side first.
Request body
| Field | Type | Notes |
|---|---|---|
rows |
Contact[] (1–5000) | Array of contact shapes (same fields as POST /). |
listName |
string (1–120) | Optional. The name of a list (not an id). Every imported contact is added to it, and the list is created on the fly if no list with that name exists in the workspace. |
skipExisting |
boolean | Optional, defaults to false. When true, a row whose identityHash already exists is left completely untouched and counted in skipped instead of being updated — an append-only import. |
Rows imported without an explicit source are stamped source: "csv_import".
Response
{
"data": {
"created": 412,
"updated": 87,
"skipped": 3,
"errors": [
{ "row": 17, "reason": "email or phone required" }
],
"listId": "cl9x2k7t40000..."
},
"error": null,
"meta": { "requestId": "...", "timestamp": "..." }
}
skipped counts rows that had neither email nor phone, plus rows whose identity already existed when skipExisting was set. Only the first case adds an entry to errors[], which carries the zero-based row index and a reason. listId is the id of the resolved-or-created list, or null when you didn't pass listName.
Retrieve a contact
GET /api/v1/contacts/:id
Returns the full contact by con_… ID.
Update a contact
PATCH /api/v1/contacts/:id
Partial update. Note: changing email or phone changes the identity hash, which means the contact effectively becomes a different identity. The row's id is preserved (so existing list memberships and campaign histories stay attached), but a future upsert with the old email won't find this row anymore.
Because (accountId, identityHash) is unique, re-keying onto an identity another contact already holds fails with 409 IDENTITY_TAKEN. Merge the two rows yourself first.
Returns only { "id": "…" } — the updated row is not echoed back. Follow with GET /api/v1/contacts/:id if you need to re-render from the stored state.
If you intend to merge two contacts, use a manual transaction at the application layer — Ripllo doesn't expose a merge endpoint.
Soft-delete a contact
DELETE /api/v1/contacts/:id
Sets every channel subscription (email, sms, whatsapp) to unsubscribed and responds { "id": "…", "status": "unsubscribed" }. The contact stays queryable for historical analytics (campaign open rates, redemption counts) but no future marketing campaign will target it.
There is no delete marker on the row: the soft delete is the all-channel unsubscribe, so a deleted contact is indistinguishable from one a merchant unsubscribed by hand. If you need to tell the two apart, stamp your own flag in attributes before calling this.
To fully erase a contact for GDPR purposes, soft-delete first, then email support@ripllo.com with the contact ID — the redaction job scrubs personal fields from related rows.
The contact object
| Field | Type | Nullable | Notes |
|---|---|---|---|
id |
string | no | Opaque cuid — no prefix. Treat as an opaque string; do not parse or validate its shape. |
accountId |
string | no | Owning workspace. |
identityHash |
string | no | SHA-256(` |
email, phone |
string | yes | Normalised. |
firstName, lastName |
string | yes | |
socialHandles |
object | no | Defaults to {}. |
subscriptions |
object | no | Defaults to {}. |
attributes |
object | no | Defaults to {}. |
source, externalRef |
string | yes | |
createdAt, updatedAt |
ISO 8601 | no |
Events
| Event type | Fires on | Status |
|---|---|---|
ripllo.contact.created.v1 |
New row inserted (not on idempotent upsert). | Reserved — not currently emitted. |
ripllo.contact.updated.v1 |
PATCH /:id or upsert that touches an existing row. |
Reserved. |
ripllo.contact.deleted.v1 |
DELETE /:id. |
Reserved. |
Next
- Contact lists — manual groupings.
- Audience segments — saved filters.
- Marketing campaigns — what you do with all this audience data.