Skip to main content
All endpoints require the Authorization: Bearer cliqo_… header — see Overview and authentication.

GET /v1/me

Returns the identity and capabilities of the calling key — and the server’s clock. Use it as your connectivity test. It works with any scope, including send_only, which cannot read anything else. That is deliberate: a key must be able to introduce itself even when it is not allowed to read data. server_time is ISO-8601 UTC. It is there so you can spot a clock or timezone difference before concluding that a report is wrong — GET /v1/reports/campaigns takes dates, and an off-by-one day is much easier to explain when you can compare clocks.

POST /v1/contacts

Creates or updates a contact (upsert by email, within the key’s brand). Requires a scope that writes contacts (full or send_only).
Field notes:
  • email — required; normalized (trimmed, lowercased).
  • opt_in / whatsapp_opt_in — booleans that record consent with source and date. Only send true when your system actually collected that consent; the API call becomes the consent’s provenance record.
  • whatsapp_opt_in: true without a phone → 400 WHATSAPP_OPTIN_WITHOUT_PHONE.
  • source — free-text origin label (max 50 chars), shown in the contact’s profile.
  • mode — what to do when the contact already exists; defaults to update. See the table below.
  • custom_fields — stored exactly as sent (no normalization on this path). Send keys already in lowercase snake_case and dates as YYYY-MM-DD, matching the fields created by import and forms — otherwise you create a parallel field. Exception: a key that is a system merge tag (nome, primeiro_nome, email, link_descadastro, link_ver_online, company_name, endereco, data_do_envio, cupom_codigo, cupom_validade) is stored as <key>_1 (endereco → endereco_1), because {{endereco}} in an email is the store’s address and a value under endereco would never show. If you send both, your explicit <key>_1 wins.
Error worth handling explicitly: 409 CONTACT_SUPPRESSED — the address unsubscribed or was suppressed. Do not work around it by mutating the email — the suppression protects the recipient and your sender reputation, whether it came from an unsubscribe, a spam complaint, a hard bounce or a blocklist entry.

mode — the same rules as the CSV import

The four modes the import screen already offers, now here too. All of them decide one thing: what to do when the e-mail already exists in that brand. Two details about replace, so they don’t surprise you: tags is only written when you send it (never deleted by omission), and the source of an existing contact is never rewritten — it records how the contact came in, not the latest call. An unknown mode is refused with 400 MODE_INVALID, never silently treated as update. A mode: "only_new" quietly becoming “update everything” would make the platform do the opposite of what you asked, across your whole base. The response says what actually happened: created, updated and the mode that was applied. ① Suppressed stays suppressed. An unsubscribed contact exists. If ignore (“only add new ones”) treated it as non-existent, we would re-subscribe someone who asked to leave. The suppression guard runs before the mode, so all four return the same 409. ② An existing contact is never re-subscribed. status and subscribed are set only when the contact is born. On an existing contact they stay untouched: a POST does not make someone who had stopped receiving e-mail start again. ③ opt_in: true does not resurrect refused consent. If the contact had already opted out of marketing, consent is not rewritten — and the response says so in email_consent: "skipped_previous_opt_out", so you don’t find out only at audit time. Reactivating is a deliberate act, done in the app by a person.

POST /v1/contacts/batch

The same write as POST /v1/contacts, for up to 1,000 contacts in one call. Pushing 5,000 contacts one by one was 5,000 calls and, under the 600-per-minute key limit, at least 8.5 minutes; here it is 5 calls. ✅ Measured: 1,000 contacts in 4.6 s end to end, against a 30 s duration cap. The headroom matters because the domain check does one DNS lookup per unique domain — a base with many distinct domains costs more than this measurement.
  • contacts — 1 to 1,000 items, each in the POST /v1/contacts shape (email, name, phone_whatsapp, tags, custom_fields, opt_in, whatsapp_opt_in). More than 1,000 → 413 BATCH_TOO_LARGE, with the cap in max_items.
  • mode — applies to the whole batch; the same four modes; an unknown one is 400 MODE_INVALID, never silently update.
  • list — optional; the humanised list number or its UUID, as in GET /v1/contacts. Everyone who got in is linked to it.
  • source — optional, default api.
The response counts what happened: received, created, updated, unchanged, linked, email_consents_granted, whatsapp_consents_granted, invalid_phones, and a skipped list with index (the position in your array), email and reason — missing or invalid e-mail, an e-mail repeated in the batch (the last occurrence wins), a domain that receives no mail, a suppressed contact, whatsapp_opt_in without a phone, or “does not exist” under updateonly. created + updated + unchanged + skipped.length equals received, with one exception: a contact created by another call at the same instant shows up in no counter. unchanged only counts under ignore (the other modes always rewrite an existing contact). A skipped item never fails the batch — the others go in. Where it differs from POST /v1/contacts, on purpose:
  • opt_in: false does nothing, exactly like the single endpoint. It is not a refusal; a refusal is the contact unsubscribing. A system that sends false for everyone who never answered must not unsubscribe your whole base by accident.
  • A suppressed contact does not return 409 — it shows up in skipped with the reason and the rest of the batch proceeds. Same rule (the suppression guard runs first), different accounting.
  • custom_fields values are stored as text and keys are normalised like the CSV import (lowercase_snake_case; Birth Date becomes birth_date). Send keys already in lowercase snake_case, matching the ones you use on POST /v1/contacts, or you create a parallel field. System merge tag keys are stored as <key>_1, as on POST /v1/contacts.
  • Consent is per item and never invented: without opt_in: true on the item, no e-mail consent is recorded. whatsapp_opt_in: true needs a valid E.164 phone_whatsapp; an invalid number counts in invalid_phones and the contact is stored without the channel.
  • A failure on our side mid-batch returns 503 BATCH_WRITE_FAILED, and whatever was written stays written. Retrying the whole call is safe — the write is idempotent by e-mail.
Requires a key that writes contacts (full or send_only).

GET /v1/contacts

Read the contacts of one list — this is the endpoint for pulling your data out:
  • list is required — the humanised list number or its UUID. There is no “export everything” default: returning the whole base because someone forgot a parameter is not a safe default.
  • limit — 1 to 1000; defaults to 100.
  • offset — defaults to 0.
Each contact carries id, email, name, phone_whatsapp, status, subscribed, tags, custom_fields, source, created_at and updated_at. Requires a key that can read — scope “Read only” or “Everything”. A “Send only” key gets 403 API_KEY_SCOPE_INSUFICIENTE; it can write contacts but deliberately cannot read them back. One number that surprises people: meta.total here counts eligible recipients, while contact_count in /v1/lists counts memberships. The same list legitimately shows two different numbers — unsubscribed and suppressed contacts stay in the list but would not receive the campaign. Neither is wrong; they answer different questions.

GET /v1/lists

Every list in the brand the key belongs to:
  • limit — 1 to 1000; defaults to 100.
  • offset — defaults to 0.
Each item carries the list’s humanised number (the same one shown in the app), its id, name, description, contact_count, created_at and updated_at. The meta object always states count, total, limit, offset and has_more. has_more is always present, including when it is false — so you never have to tell “absent” from “false”, which is how integrations quietly stop at the first page.

GET /v1/lists/

One list, by either identifier:
Two things worth knowing:
  • The object comes bare, with no data envelope — same as GET /v1/me, the other single-resource endpoint, and the convention you expect from a retrieve.
  • It is byte-identical to an item of the plural’s data[], because both are produced by the same serialiser. Code written against one works against the other.
A list that does not exist in this brand returns 404 with code LIST_NOT_FOUND — the same answer whether the id is unknown or belongs to another brand, so the endpoint never confirms the existence of somebody else’s data.

GET /v1/reports/campaigns

Campaign metrics, filterable by period and channel:
  • from / to — ISO-8601 dates. The window is capped at 366 days.
  • channel — email or whatsapp; omit for both.

The window, and what happens when you omit it

A wider window costs more to compute, and the cost grows with the age of the account — so the range is bounded: The response always states the window it used, in meta.from and meta.to, plus the cap in meta.max_window_days. Read those instead of assuming: an omitted range is answered with a default, not with “everything”, and you would otherwise have no way to tell.

The response

It is JSON — this endpoint returns no file, and there is no CSV or XLSX variant. data is an array with one entry per campaign; meta describes the query that produced it.
Rates are percentages already rounded to two decimals (31.43 means 31.43%), not fractions. meta.truncated says whether the source cut the list short. Reading rules (by design):
  • A metric that doesn’t exist for the channel is null, never 0 — e.g. opens, open_rate and amp_rate for WhatsApp.
  • A rate with no denominator is null (0/0 is not 0%).
  • A campaign whose dispatch failed may have sent_at: null and still appear — a failed send inside your date window is information, not noise.

Custom events (site/pixel)

Beyond the v1 surface, your store can push custom events (for the “custom event” segment dimension) through the pixel endpoint using your account’s pixel token — details on the [pixel reference] (coming to this help center), or ask us at [email protected].