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, includingsend_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).
email— required; normalized (trimmed, lowercased).opt_in/whatsapp_opt_in— booleans that record consent with source and date. Only sendtruewhen your system actually collected that consent; the API call becomes the consent’s provenance record.whatsapp_opt_in: truewithout 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 toupdate. See the table below.custom_fields— stored exactly as sent (no normalization on this path). Send keys already in lowercasesnake_caseand dates asYYYY-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 underenderecowould never show. If you send both, your explicit<key>_1wins.
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.
Three things no mode changes — and all three carry legal weight
① Suppressed stays suppressed. An unsubscribed contact exists. Ifignore (“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 asPOST /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 thePOST /v1/contactsshape (email,name,phone_whatsapp,tags,custom_fields,opt_in,whatsapp_opt_in). More than 1,000 →413 BATCH_TOO_LARGE, with the cap inmax_items.mode— applies to the whole batch; the same four modes; an unknown one is400 MODE_INVALID, never silentlyupdate.list— optional; the humanised list number or its UUID, as inGET /v1/contacts. Everyone who got in is linked to it.source— optional, defaultapi.
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: falsedoes nothing, exactly like the single endpoint. It is not a refusal; a refusal is the contact unsubscribing. A system that sendsfalsefor everyone who never answered must not unsubscribe your whole base by accident.- A suppressed contact does not return
409— it shows up inskippedwith the reason and the rest of the batch proceeds. Same rule (the suppression guard runs first), different accounting. custom_fieldsvalues are stored as text and keys are normalised like the CSV import (lowercase_snake_case;Birth Datebecomesbirth_date). Send keys already in lowercasesnake_case, matching the ones you use onPOST /v1/contacts, or you create a parallel field. System merge tag keys are stored as<key>_1, as onPOST /v1/contacts.- Consent is per item and never invented: without
opt_in: trueon the item, no e-mail consent is recorded.whatsapp_opt_in: trueneeds a valid E.164phone_whatsapp; an invalid number counts ininvalid_phonesand 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.
full or send_only).
GET /v1/contacts
Read the contacts of one list — this is the endpoint for pulling your data out:listis 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.
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.
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:- The object comes bare, with no
dataenvelope — same asGET /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.
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—emailorwhatsapp; 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.
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, never0— e.g.opens,open_rateandamp_ratefor WhatsApp. - A rate with no denominator is
null(0/0is not0%). - A campaign whose dispatch failed may have
sent_at: nulland still appear — a failed send inside your date window is information, not noise.