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": "con_01HX...",
"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": "con_01HX...",
"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, the existing row is returned with created: false; otherwise a new row is created with created: true.
Request body
| Field | Type | Notes |
|---|---|---|
email |
string | null | RFC-valid. Lowercased before hashing. |
phone |
string (4–40) | null | Normalised: [^+0-9] stripped before hashing. So (+62) 811 234 567 and +62811234567 produce the same identity. |
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
{
"data": {
"contact": { /* full Contact object */ },
"created": true
},
"error": null,
"meta": { "requestId": "...", "timestamp": "..." }
}
Examples
const { contact, created } = await ripllo.contacts.upsert({
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; partial failures don't roll back the rest of the batch.
Request body
| Field | Type | Notes |
|---|---|---|
rows |
Contact[] (1–5000) | Array of contact shapes (same fields as POST /). |
listId |
string | null | Optional. When set, every successfully-imported contact is added to this list. If the list doesn't exist with the given id, it's not auto-created — use POST /contact-lists first. |
Response
{
"data": {
"created": 412,
"updated": 87,
"skipped": 3,
"listAdded": 499
},
"error": null,
"meta": { "requestId": "...", "timestamp": "..." }
}
skipped counts rows that failed validation (e.g., malformed email). They do not appear in any subsequent listing — check your CSV before importing.
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.
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 to unsubscribed and stamps deletedAt. The contact stays queryable for historical analytics (campaign open rates, redemption counts) but no future marketing campaign will target it.
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 | con_ + ULID. |
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 | |
deletedAt |
ISO 8601 | yes | Soft-delete timestamp. |
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.