Currency

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

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