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