API documentation
Connect Convs to your own CRM, shop or data warehouse: read contacts and leads, change stages, send conversions to the Meta queue and receive events through webhooks.
- Version
- 1.0.0
- Base URL
- https://app.convs.io/api/v1
- OpenAPI
- OpenAPI spec (JSON)
On this page
Quick start
- Create a key in the panel: Konfiguracja → API (Settings → API). Owners and admins of the organisation can create keys. The full key is shown only once, so copy it right away.
- Every URL starts with
https://app.convs.io/api/v1. Responses are JSON. - Check the key with
GET /me. It needs no scope and returns the organisation, the key name, its scopes and expiry.
curl -H "Authorization: Bearer cvs_live_…" \
https://app.convs.io/api/v1/meThe other examples read the key from the API_KEY environment variable (export API_KEY=cvs_live_…). Never put the key in website or mobile app code: it is for server-to-server calls only.
Authentication
Every request sends an organisation key in the Authorization: Bearer cvs_live_… header. A key belongs to one organisation and sees only its data.
The full key is shown once, when it is created. We store only its hash, so it cannot be recovered: revoke a lost key and create a new one.
You can revoke a key in the panel at any time and give it an expiry date. A revoked key gets 401 key_revoked, an expired one 401 key_expired.
A key has scopes. A :write scope (and leads:answers) includes :read of the same area. contacts:* and leads:answers return personal data. A missing scope answers 403 insufficient_scope with details.required_scope.
| Scope | Allows | Endpoints |
|---|---|---|
contacts:read | Read people of the Klienci (Clients) page (names, phones, e-mails — personal data). | |
contacts:write | Create and change people: details, stage, notes, tags, SMS block. | |
leads:read | Read Meta Instant Forms leads, without form answers. | |
leads:answers | Like leads:read, plus the form answers (answers) — personal data. | |
events:read | Read conversions and the status of their deliveries to Meta. | |
events:write | Send server-to-server conversions to the queue. | |
campaigns:read | Campaign report. | |
creatives:read | Creative report. | |
jobs:read | Read background jobs. | |
webhooks:read | Read webhooks and their delivery history. | |
webhooks:write | Create, change, delete and test webhooks. |
Errors
Every error has the same shape: {"error": {"code", "message", "details?"}}.
code is stable and never translated, so base your error handling on it. message is for humans, in the language of the Accept-Language header: Polish by default, en for English. details appears only when there is extra data, e.g. required_scope.
{
"error": {
"code": "insufficient_scope",
"message": "The API key lacks the contacts:write scope.",
"details": {
"required_scope": "contacts:write"
}
}
}| Code | HTTP | Meaning |
|---|---|---|
unauthorized | 401 | Missing key, wrong format or unknown key. |
key_revoked | 401 | The key was revoked. |
key_expired | 401 | The key is past its expiry date. |
insufficient_scope | 403 | The key lacks the required scope (details.required_scope). |
rate_limited | 429 | Over 120 requests per minute for this key; wait Retry-After seconds. |
invalid_json | 400 | The body is not valid JSON or has an unknown field. |
validation_error | 400 | A field or parameter has an invalid value; the message says what to fix. |
invalid_cursor | 400 | cursor is not from the previous page of the same list. |
not_found | 404 | No such record in your organisation. |
route_not_found | 404 | No such endpoint. |
conflict | 409 | The operation conflicts with the record’s current state. |
contact_exists | 409 | A person with this phone or e-mail already exists (details.contact_id). |
contact_merged | 409 | The card was merged into another one (details.merged_into). |
stage_conflict | 409 | The stage cannot be changed this way right now. |
event_conflict | 409 | The event conflicts with one already accepted. |
source_paused | 409 | The source is paused. |
limit_reached | 409 | A limit was reached, e.g. 10 webhooks per organisation. |
idempotency_key_reused | 422 | The same Idempotency-Key with a different request body. |
idempotency_in_progress | 409 | A request with this Idempotency-Key is still running; retry shortly. |
payload_too_large | 413 | Request body over 64 KB. |
internal_error | 500 | An error on our side; retry later. |
Pagination
Lists return {"data": [...], "next_cursor", "has_more"}. Set the page size with limit (1–100, default 25).
Fetch the next page by passing the previous response’s next_cursor as cursor. When has_more is false, the list is complete.
Records are ordered by updated_at, then id: order=desc newest first (default) or order=asc.
updated_since (ISO 8601) returns only records changed at or after that time. For incremental sync, store the time you start a sync, page through to the end, and use the stored time as updated_since for the next sync.
Every contact changed since a given time
cursor=""
while :; do
page=$(curl -s -G "https://app.convs.io/api/v1/contacts" \
-H "Authorization: Bearer $API_KEY" \
--data-urlencode "limit=100" \
--data-urlencode "updated_since=2026-10-01T00:00:00Z" \
${cursor:+--data-urlencode "cursor=$cursor"})
echo "$page" | jq -c '.data[]'
[ "$(echo "$page" | jq -r '.has_more')" = "true" ] || break
cursor=$(echo "$page" | jq -r '.next_cursor')
doneconst records = []
let cursor = null
do {
const params = new URLSearchParams({ limit: '100', updated_since: '2026-10-01T00:00:00Z' })
if (cursor) params.set('cursor', cursor)
const response = await fetch(`https://app.convs.io/api/v1/contacts?${params}`, {
headers: { Authorization: `Bearer ${process.env.API_KEY}` },
})
if (!response.ok) throw new Error((await response.json()).error.message)
const page = await response.json()
records.push(...page.data)
cursor = page.has_more ? page.next_cursor : null
} while (cursor)import os
import requests
records = []
params = {"limit": 100, "updated_since": "2026-10-01T00:00:00Z"}
while True:
response = requests.get(
"https://app.convs.io/api/v1/contacts",
headers={"Authorization": f"Bearer {os.environ['API_KEY']}"},
params=params,
timeout=30,
)
response.raise_for_status()
page = response.json()
records.extend(page["data"])
if not page["has_more"]:
break
params["cursor"] = page["next_cursor"]<?php
$records = [];
$params = ['limit' => 100, 'updated_since' => '2026-10-01T00:00:00Z'];
do {
$ch = curl_init('https://app.convs.io/api/v1/contacts?' . http_build_query($params));
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('API_KEY')],
]);
$page = json_decode(curl_exec($ch), true);
curl_close($ch);
if (isset($page['error'])) {
throw new RuntimeException($page['error']['message']);
}
$records = array_merge($records, $page['data']);
$params['cursor'] = $page['next_cursor'];
} while ($page['has_more']);Limits and formats
- Rate limit: 120 requests per minute per key. Every response carries
X-RateLimit-Limit,X-RateLimit-RemainingandX-RateLimit-Reset. - Over the limit you get
429 rate_limitedwith aRetry-Afterheader (seconds). Wait that long, then retry. - A request body can be at most 64 KB, otherwise
413 payload_too_large. - Times in responses are ISO 8601 in UTC, e.g.
2026-10-08T10:00:00Z.
Idempotency
- Every
POSTaccepts anIdempotency-Keyheader (1–255 visible ASCII characters). Use a value tied to the operation, e.g. a UUID stored with the record on your side. - Repeating a request with the same key and the same body within 24 hours returns the stored response with
Idempotent-Replayed: true, without running the operation again. - The same key with a different body answers
422 idempotency_key_reused. While the first request is still running you get409 idempotency_in_progress. - 5xx and 429 responses are not stored, so after them you can safely retry with the same key.
Endpoints
Keys
The calling key.
/meDescribe the calling key
Organisation, key name, scopes and expiry. A safe first call to check a new key; needs no scope.
Scope: no scope
Responses
200OKResponse fieldsName Description organizationobjectorganization.idstringorganization.namestringkeyobjectkey.idstringkey.namestringkey.prefixstringkey.scopesstring[]key.expires_atstring (date-time) | nullrate_limitobjectrate_limit.per_minuteinteger400Invalid input (validation_error,invalid_json,invalid_cursor).401Missing, unknown, revoked or expired key (unauthorized,key_revoked,key_expired).403The key lacks the scope (insufficient_scope,details.required_scope).429Over 120 requests per minute for this key (rate_limited); waitRetry-Afterseconds.
Example
curl "https://app.convs.io/api/v1/me" \
-H "Authorization: Bearer $API_KEY"const response = await fetch('https://app.convs.io/api/v1/me', {
headers: {
Authorization: `Bearer ${process.env.API_KEY}`,
},
})
if (!response.ok) throw new Error((await response.json()).error.message)
const data = await response.json()
console.log(data)import os
import requests
response = requests.get(
"https://app.convs.io/api/v1/me",
headers={
"Authorization": f"Bearer {os.environ['API_KEY']}",
},
timeout=30,
)
response.raise_for_status()
print(response.json())<?php
$ch = curl_init('https://app.convs.io/api/v1/me');
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'GET',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('API_KEY'),
],
]);
$data = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status >= 400) {
throw new RuntimeException($data['error']['message']);
}
print_r($data);Contacts
People of the Klienci page.
/contactsList contacts
People of the Klienci page (merged cards are left out). Filter by stage, tag, exact e-mail or phone, or the ad they came from.
Scope: contacts:read
Parameters
| Name | Description |
|---|---|
limitqueryinteger | Page size, 1–100 (default 25).Default: 25Range: 1–100 |
cursorquerystring | next_cursor of the previous page. |
orderquerystring | Sort by updated_at (then id): desc newest first (default) or asc.Values: asc, descDefault: desc |
updated_sincequerystring (date-time) | Only records changed at or after this ISO 8601 time. |
stagequerystring | Stage.Values: new, contacted, qualified, sold, rejected |
tagquerystring | Tag. |
emailquerystring | Exact e-mail (matched by hash). |
phonequerystring | Exact phone (matched by hash). |
campaignquerystring | People with a lead from this Meta campaign id. |
adsetquerystring | …from this ad set id. |
adquerystring | …from this ad id. |
Responses
200OKResponse fieldsName Description dataContact[]Requireddata[].idstringContact id. data[].namestringFull name. data[].phonestringPhone in E.164 form, empty when unknown. data[].emailstringE-mail, empty when unknown. data[].stagestringSales stage.Values: new,contacted,qualified,sold,rejecteddata[].cycleintegerSales cycle number; a new lead for a closed person opens the next cycle. data[].tagsstring[]Tags. data[].sms_optoutbooleanTrue when SMS to this person are blocked. data[].first_sourcestringWhere the person first came from (form name, "Ręcznie"…). data[].first_adAdSource | nullThe first ad the person came from, null for organic or unknown. data[].first_ad.platformstringPlacement platform reported by Meta (facebook, instagram…). data[].first_ad.campaign_idstringMeta campaign id. data[].first_ad.campaign_namestringCampaign name. data[].first_ad.adset_idstringMeta ad set id. data[].first_ad.adset_namestringAd set name. data[].first_ad.ad_idstringMeta ad id. data[].first_ad.ad_namestringAd name. data[].owner_idstring | nullUser id of the person's owner (opiekun). data[].last_activity_atstring (date-time) | nullLast activity time. data[].created_atstring (date-time)Creation time. data[].updated_atstring (date-time)Last change; use with updated_since.next_cursorstring | nullRequiredPass as cursorto get the next page; null on the last page.has_morebooleanRequiredTrue when another page exists. 400Invalid input (validation_error,invalid_json,invalid_cursor).401Missing, unknown, revoked or expired key (unauthorized,key_revoked,key_expired).403The key lacks the scope (insufficient_scope,details.required_scope).429Over 120 requests per minute for this key (rate_limited); waitRetry-Afterseconds.
Example
curl "https://app.convs.io/api/v1/contacts" \
-H "Authorization: Bearer $API_KEY"const response = await fetch('https://app.convs.io/api/v1/contacts', {
headers: {
Authorization: `Bearer ${process.env.API_KEY}`,
},
})
if (!response.ok) throw new Error((await response.json()).error.message)
const data = await response.json()
console.log(data)import os
import requests
response = requests.get(
"https://app.convs.io/api/v1/contacts",
headers={
"Authorization": f"Bearer {os.environ['API_KEY']}",
},
timeout=30,
)
response.raise_for_status()
print(response.json())<?php
$ch = curl_init('https://app.convs.io/api/v1/contacts');
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'GET',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('API_KEY'),
],
]);
$data = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status >= 400) {
throw new RuntimeException($data['error']['message']);
}
print_r($data);/contactsCreate a contact
Adds a person like „Dodaj osobę” in the panel (round robin owner when it is on).
Scope: contacts:write
Parameters
| Name | Description |
|---|---|
Idempotency-Keyheaderstring | Unique key (1–255 visible ASCII characters). Repeating a POST with the same key and body within 24 hours returns the stored response with Idempotent-Replayed: true.Max length: 255 |
Request body application/json
| Name | Description |
|---|---|
namestring | Full name (up to 200 characters).Max length: 200 |
phonestring | Phone with a country code, e.g. +48 600 857 482, or 9 digits (Poland). |
emailstring | E-mail address.Max length: 254 |
tagsstring[] | Tags. |
Responses
201CreatedResponse fieldsName Description idstringContact id. namestringFull name. phonestringPhone in E.164 form, empty when unknown. emailstringE-mail, empty when unknown. stagestringSales stage.Values: new,contacted,qualified,sold,rejectedcycleintegerSales cycle number; a new lead for a closed person opens the next cycle. tagsstring[]Tags. sms_optoutbooleanTrue when SMS to this person are blocked. first_sourcestringWhere the person first came from (form name, "Ręcznie"…). first_adAdSource | nullThe first ad the person came from, null for organic or unknown. first_ad.platformstringPlacement platform reported by Meta (facebook, instagram…). first_ad.campaign_idstringMeta campaign id. first_ad.campaign_namestringCampaign name. first_ad.adset_idstringMeta ad set id. first_ad.adset_namestringAd set name. first_ad.ad_idstringMeta ad id. first_ad.ad_namestringAd name. owner_idstring | nullUser id of the person's owner (opiekun). last_activity_atstring (date-time) | nullLast activity time. created_atstring (date-time)Creation time. updated_atstring (date-time)Last change; use with updated_since.400Invalid input (validation_error,invalid_json,invalid_cursor).401Missing, unknown, revoked or expired key (unauthorized,key_revoked,key_expired).403The key lacks the scope (insufficient_scope,details.required_scope).409Conflicts with the current state (conflict,contact_exists,contact_merged,stage_conflict,event_conflict,source_paused,idempotency_in_progress).413Body over 64 KB (payload_too_large).422Idempotency-Key reused with a different body (idempotency_key_reused).429Over 120 requests per minute for this key (rate_limited); waitRetry-Afterseconds.
Example
curl -X POST "https://app.convs.io/api/v1/contacts" \
-H "Authorization: Bearer $API_KEY" \
-H "Idempotency-Key: 5f0c7a2e-8d41-4b6a-9e3f-1c2d3e4f5a6b" \
-H "Content-Type: application/json" \
-d '{
"name": "Anna Nowak",
"phone": "+48600100200",
"email": "anna@example.com",
"tags": [
"vip"
]
}'const response = await fetch('https://app.convs.io/api/v1/contacts', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.API_KEY}`,
'Idempotency-Key': '5f0c7a2e-8d41-4b6a-9e3f-1c2d3e4f5a6b',
'Content-Type': 'application/json',
},
body: JSON.stringify({
"name": "Anna Nowak",
"phone": "+48600100200",
"email": "anna@example.com",
"tags": [
"vip"
]
}),
})
if (!response.ok) throw new Error((await response.json()).error.message)
const data = await response.json()
console.log(data)import os
import requests
response = requests.post(
"https://app.convs.io/api/v1/contacts",
headers={
"Authorization": f"Bearer {os.environ['API_KEY']}",
"Idempotency-Key": "5f0c7a2e-8d41-4b6a-9e3f-1c2d3e4f5a6b",
},
json={
"name": "Anna Nowak",
"phone": "+48600100200",
"email": "anna@example.com",
"tags": ["vip"],
},
timeout=30,
)
response.raise_for_status()
print(response.json())<?php
$ch = curl_init('https://app.convs.io/api/v1/contacts');
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('API_KEY'),
'Idempotency-Key: 5f0c7a2e-8d41-4b6a-9e3f-1c2d3e4f5a6b',
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'name' => 'Anna Nowak',
'phone' => '+48600100200',
'email' => 'anna@example.com',
'tags' => ['vip'],
]),
]);
$data = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status >= 400) {
throw new RuntimeException($data['error']['message']);
}
print_r($data);/contacts/{id}Get a contact
A merged card answers 409 contact_merged with details.merged_into.
Scope: contacts:read
Parameters
| Name | Description |
|---|---|
idpathstringRequired | Record id. |
Responses
200OKResponse fieldsName Description idstringContact id. namestringFull name. phonestringPhone in E.164 form, empty when unknown. emailstringE-mail, empty when unknown. stagestringSales stage.Values: new,contacted,qualified,sold,rejectedcycleintegerSales cycle number; a new lead for a closed person opens the next cycle. tagsstring[]Tags. sms_optoutbooleanTrue when SMS to this person are blocked. first_sourcestringWhere the person first came from (form name, "Ręcznie"…). first_adAdSource | nullThe first ad the person came from, null for organic or unknown. first_ad.platformstringPlacement platform reported by Meta (facebook, instagram…). first_ad.campaign_idstringMeta campaign id. first_ad.campaign_namestringCampaign name. first_ad.adset_idstringMeta ad set id. first_ad.adset_namestringAd set name. first_ad.ad_idstringMeta ad id. first_ad.ad_namestringAd name. owner_idstring | nullUser id of the person's owner (opiekun). last_activity_atstring (date-time) | nullLast activity time. created_atstring (date-time)Creation time. updated_atstring (date-time)Last change; use with updated_since.400Invalid input (validation_error,invalid_json,invalid_cursor).401Missing, unknown, revoked or expired key (unauthorized,key_revoked,key_expired).403The key lacks the scope (insufficient_scope,details.required_scope).404No such record in your organisation (not_found).409Conflicts with the current state (conflict,contact_exists,contact_merged,stage_conflict,event_conflict,source_paused,idempotency_in_progress).429Over 120 requests per minute for this key (rate_limited); waitRetry-Afterseconds.
Example
curl "https://app.convs.io/api/v1/contacts/abc123" \
-H "Authorization: Bearer $API_KEY"/contacts/{id}Update a contact
Changes name, phone, e-mail or tags; every change is a history line.
Scope: contacts:write
Parameters
| Name | Description |
|---|---|
idpathstringRequired | Record id. |
Request body application/json
| Name | Description |
|---|---|
namestring | Full name. |
phonestring | Phone; an empty string removes it (a phone or an e-mail must remain). |
emailstring | E-mail; an empty string removes it. |
tagsstring[] | Replaces all tags. |
Responses
200OKResponse fieldsName Description idstringContact id. namestringFull name. phonestringPhone in E.164 form, empty when unknown. emailstringE-mail, empty when unknown. stagestringSales stage.Values: new,contacted,qualified,sold,rejectedcycleintegerSales cycle number; a new lead for a closed person opens the next cycle. tagsstring[]Tags. sms_optoutbooleanTrue when SMS to this person are blocked. first_sourcestringWhere the person first came from (form name, "Ręcznie"…). first_adAdSource | nullThe first ad the person came from, null for organic or unknown. first_ad.platformstringPlacement platform reported by Meta (facebook, instagram…). first_ad.campaign_idstringMeta campaign id. first_ad.campaign_namestringCampaign name. first_ad.adset_idstringMeta ad set id. first_ad.adset_namestringAd set name. first_ad.ad_idstringMeta ad id. first_ad.ad_namestringAd name. owner_idstring | nullUser id of the person's owner (opiekun). last_activity_atstring (date-time) | nullLast activity time. created_atstring (date-time)Creation time. updated_atstring (date-time)Last change; use with updated_since.400Invalid input (validation_error,invalid_json,invalid_cursor).401Missing, unknown, revoked or expired key (unauthorized,key_revoked,key_expired).403The key lacks the scope (insufficient_scope,details.required_scope).404No such record in your organisation (not_found).409Conflicts with the current state (conflict,contact_exists,contact_merged,stage_conflict,event_conflict,source_paused,idempotency_in_progress).413Body over 64 KB (payload_too_large).429Over 120 requests per minute for this key (rate_limited); waitRetry-Afterseconds.
Example
curl -X PATCH "https://app.convs.io/api/v1/contacts/abc123" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Anna Nowak",
"phone": "+48600100200",
"email": "anna@example.com",
"tags": [
"vip"
]
}'/contacts/{id}/stageSet the stage
Moves the person and, in the same transaction, every open lead of the current cycle; leads with an active flow get a Meta conversion event queued. The same path as „Zmień etap” in the panel; stage automations start.
Scope: contacts:write
Parameters
| Name | Description |
|---|---|
idpathstringRequired | Record id. |
Idempotency-Keyheaderstring | Unique key (1–255 visible ASCII characters). Repeating a POST with the same key and body within 24 hours returns the stored response with Idempotent-Replayed: true.Max length: 255 |
Request body application/json
| Name | Description |
|---|---|
stagestringRequired | Target stage.Values: new, contacted, qualified, sold, rejected |
valuenumber | Sale value (for sold); counted once, on the newest lead whose Meta event is queued. |
currencystring | ISO 4217 currency of value, e.g. PLN. |
Responses
200OKResponse fieldsName Description contactContactcontact.idstringContact id. contact.namestringFull name. contact.phonestringPhone in E.164 form, empty when unknown. contact.emailstringE-mail, empty when unknown. contact.stagestringSales stage.Values: new,contacted,qualified,sold,rejectedcontact.cycleintegerSales cycle number; a new lead for a closed person opens the next cycle. contact.tagsstring[]Tags. contact.sms_optoutbooleanTrue when SMS to this person are blocked. contact.first_sourcestringWhere the person first came from (form name, "Ręcznie"…). contact.first_adAdSource | nullThe first ad the person came from, null for organic or unknown. contact.first_ad.platformstringPlacement platform reported by Meta (facebook, instagram…). contact.first_ad.campaign_idstringMeta campaign id. contact.first_ad.campaign_namestringCampaign name. contact.first_ad.adset_idstringMeta ad set id. contact.first_ad.adset_namestringAd set name. contact.first_ad.ad_idstringMeta ad id. contact.first_ad.ad_namestringAd name. contact.owner_idstring | nullUser id of the person's owner (opiekun). contact.last_activity_atstring (date-time) | nullLast activity time. contact.created_atstring (date-time)Creation time. contact.updated_atstring (date-time)Last change; use with updated_since.meta_events_queuedintegerMeta conversion events queued for open leads (the queue sends them; this is not a confirmation from Meta). leads_without_sendintegerOpen leads moved without a Meta event (no active flow for the stage). 400Invalid input (validation_error,invalid_json,invalid_cursor).401Missing, unknown, revoked or expired key (unauthorized,key_revoked,key_expired).403The key lacks the scope (insufficient_scope,details.required_scope).404No such record in your organisation (not_found).409Conflicts with the current state (conflict,contact_exists,contact_merged,stage_conflict,event_conflict,source_paused,idempotency_in_progress).413Body over 64 KB (payload_too_large).422Idempotency-Key reused with a different body (idempotency_key_reused).429Over 120 requests per minute for this key (rate_limited); waitRetry-Afterseconds.
Example
curl -X POST "https://app.convs.io/api/v1/contacts/abc123/stage" \
-H "Authorization: Bearer $API_KEY" \
-H "Idempotency-Key: 5f0c7a2e-8d41-4b6a-9e3f-1c2d3e4f5a6b" \
-H "Content-Type: application/json" \
-d '{
"stage": "sold",
"value": 1200,
"currency": "PLN"
}'const response = await fetch('https://app.convs.io/api/v1/contacts/abc123/stage', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.API_KEY}`,
'Idempotency-Key': '5f0c7a2e-8d41-4b6a-9e3f-1c2d3e4f5a6b',
'Content-Type': 'application/json',
},
body: JSON.stringify({
"stage": "sold",
"value": 1200,
"currency": "PLN"
}),
})
if (!response.ok) throw new Error((await response.json()).error.message)
const data = await response.json()
console.log(data)import os
import requests
response = requests.post(
"https://app.convs.io/api/v1/contacts/abc123/stage",
headers={
"Authorization": f"Bearer {os.environ['API_KEY']}",
"Idempotency-Key": "5f0c7a2e-8d41-4b6a-9e3f-1c2d3e4f5a6b",
},
json={
"stage": "sold",
"value": 1200,
"currency": "PLN",
},
timeout=30,
)
response.raise_for_status()
print(response.json())<?php
$ch = curl_init('https://app.convs.io/api/v1/contacts/abc123/stage');
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('API_KEY'),
'Idempotency-Key: 5f0c7a2e-8d41-4b6a-9e3f-1c2d3e4f5a6b',
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'stage' => 'sold',
'value' => 1200,
'currency' => 'PLN',
]),
]);
$data = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status >= 400) {
throw new RuntimeException($data['error']['message']);
}
print_r($data);/contacts/{id}/notesAdd a note
Adds a note, call or meeting to the person's history.
Scope: contacts:write
Parameters
| Name | Description |
|---|---|
idpathstringRequired | Record id. |
Idempotency-Keyheaderstring | Unique key (1–255 visible ASCII characters). Repeating a POST with the same key and body within 24 hours returns the stored response with Idempotent-Replayed: true.Max length: 255 |
Request body application/json
| Name | Description |
|---|---|
kindstring | Entry kind.Values: note, call, meetingDefault: note |
textstringRequired | Text, 1–5000 characters.Max length: 5000 |
timestring (date-time) | When it happened (default: now); not in the future. |
Responses
201CreatedResponse fieldsName Description idstringHistory entry id. contact_idstringContact id. kindstringEntry kind. textstringText. timestring (date-time)Entry time. 400Invalid input (validation_error,invalid_json,invalid_cursor).401Missing, unknown, revoked or expired key (unauthorized,key_revoked,key_expired).403The key lacks the scope (insufficient_scope,details.required_scope).404No such record in your organisation (not_found).409Conflicts with the current state (conflict,contact_exists,contact_merged,stage_conflict,event_conflict,source_paused,idempotency_in_progress).413Body over 64 KB (payload_too_large).422Idempotency-Key reused with a different body (idempotency_key_reused).429Over 120 requests per minute for this key (rate_limited); waitRetry-Afterseconds.
Example
curl -X POST "https://app.convs.io/api/v1/contacts/abc123/notes" \
-H "Authorization: Bearer $API_KEY" \
-H "Idempotency-Key: 5f0c7a2e-8d41-4b6a-9e3f-1c2d3e4f5a6b" \
-H "Content-Type: application/json" \
-d '{
"kind": "call",
"text": "Called back, wants an offer by Friday."
}'/contacts/{id}/sms-optoutBlock or allow SMS
Keeps the „nie wysyłaj” list and every person with the same phone in step.
Scope: contacts:write
Parameters
| Name | Description |
|---|---|
idpathstringRequired | Record id. |
Idempotency-Keyheaderstring | Unique key (1–255 visible ASCII characters). Repeating a POST with the same key and body within 24 hours returns the stored response with Idempotent-Replayed: true.Max length: 255 |
Request body application/json
| Name | Description |
|---|---|
sms_optoutbooleanRequired | True blocks SMS to the person's phone (for every person sharing it), false allows them again. |
Responses
200OKResponse fieldsName Description idstringContact id. namestringFull name. phonestringPhone in E.164 form, empty when unknown. emailstringE-mail, empty when unknown. stagestringSales stage.Values: new,contacted,qualified,sold,rejectedcycleintegerSales cycle number; a new lead for a closed person opens the next cycle. tagsstring[]Tags. sms_optoutbooleanTrue when SMS to this person are blocked. first_sourcestringWhere the person first came from (form name, "Ręcznie"…). first_adAdSource | nullThe first ad the person came from, null for organic or unknown. first_ad.platformstringPlacement platform reported by Meta (facebook, instagram…). first_ad.campaign_idstringMeta campaign id. first_ad.campaign_namestringCampaign name. first_ad.adset_idstringMeta ad set id. first_ad.adset_namestringAd set name. first_ad.ad_idstringMeta ad id. first_ad.ad_namestringAd name. owner_idstring | nullUser id of the person's owner (opiekun). last_activity_atstring (date-time) | nullLast activity time. created_atstring (date-time)Creation time. updated_atstring (date-time)Last change; use with updated_since.400Invalid input (validation_error,invalid_json,invalid_cursor).401Missing, unknown, revoked or expired key (unauthorized,key_revoked,key_expired).403The key lacks the scope (insufficient_scope,details.required_scope).404No such record in your organisation (not_found).409Conflicts with the current state (conflict,contact_exists,contact_merged,stage_conflict,event_conflict,source_paused,idempotency_in_progress).413Body over 64 KB (payload_too_large).422Idempotency-Key reused with a different body (idempotency_key_reused).429Over 120 requests per minute for this key (rate_limited); waitRetry-Afterseconds.
Example
curl -X POST "https://app.convs.io/api/v1/contacts/abc123/sms-optout" \
-H "Authorization: Bearer $API_KEY" \
-H "Idempotency-Key: 5f0c7a2e-8d41-4b6a-9e3f-1c2d3e4f5a6b" \
-H "Content-Type: application/json" \
-d '{
"sms_optout": true
}'Leads
Meta Instant Forms leads.
/leadsList leads
Meta Instant Forms leads. answers are included only for keys with leads:answers.
Scope: leads:read
Parameters
| Name | Description |
|---|---|
limitqueryinteger | Page size, 1–100 (default 25).Default: 25Range: 1–100 |
cursorquerystring | next_cursor of the previous page. |
orderquerystring | Sort by updated_at (then id): desc newest first (default) or asc.Values: asc, descDefault: desc |
updated_sincequerystring (date-time) | Only records changed at or after this ISO 8601 time. |
formquerystring | Convs form (source) id. |
campaignquerystring | Meta campaign id. |
adsetquerystring | Meta ad set id. |
adquerystring | Meta ad id. |
contactquerystring | Contact id. |
stagequerystring | Stage.Values: new, contacted, qualified, sold, rejected |
fromquerystring | Submitted on or after (YYYY-MM-DD in the organisation's zone, or ISO 8601). |
toquerystring | Submitted on or before. |
Responses
200OKResponse fieldsName Description dataLead[]Requireddata[].idstringLead id in Convs. data[].meta_lead_idstringLead id in Meta. data[].form_idstringConvs source (form) id; use as formfilter.data[].form_namestringForm name. data[].meta_form_idstringMeta form id. data[].meta_page_idstringMeta Page id. data[].stagestringLead stage.Values: new,contacted,qualified,sold,rejecteddata[].cycleintegerSales cycle of the person this lead belongs to. data[].contact_idstring | nullThe person (contact) of this lead. data[].fetch_statusstringreadywhen the lead data is stored.data[].adAdSource | nullAd the lead came from, null for organic or unknown. data[].ad.platformstringPlacement platform reported by Meta (facebook, instagram…). data[].ad.campaign_idstringMeta campaign id. data[].ad.campaign_namestringCampaign name. data[].ad.adset_idstringMeta ad set id. data[].ad.adset_namestringAd set name. data[].ad.ad_idstringMeta ad id. data[].ad.ad_namestringAd name. data[].stagesobject[]Stage history. data[].stages[].stagestringdata[].stages[].atstring (date-time)data[].stages[].meta_event_queuedbooleandata[].answersLeadAnswer[]Form answers. Only for keys with leads:answers.data[].answers[].keystringField key (also the automation variable). data[].answers[].labelstringQuestion as shown in the form. data[].answers[].valuesstring[]Answers. data[].created_atstring (date-time) | nullWhen the lead was submitted. data[].updated_atstring (date-time)Last change. next_cursorstring | nullRequiredPass as cursorto get the next page; null on the last page.has_morebooleanRequiredTrue when another page exists. 400Invalid input (validation_error,invalid_json,invalid_cursor).401Missing, unknown, revoked or expired key (unauthorized,key_revoked,key_expired).403The key lacks the scope (insufficient_scope,details.required_scope).429Over 120 requests per minute for this key (rate_limited); waitRetry-Afterseconds.
Example
curl "https://app.convs.io/api/v1/leads" \
-H "Authorization: Bearer $API_KEY"/leads/{id}Get a lead
One lead; answers only with leads:answers.
Scope: leads:read
Parameters
| Name | Description |
|---|---|
idpathstringRequired | Record id. |
Responses
200OKResponse fieldsName Description idstringLead id in Convs. meta_lead_idstringLead id in Meta. form_idstringConvs source (form) id; use as formfilter.form_namestringForm name. meta_form_idstringMeta form id. meta_page_idstringMeta Page id. stagestringLead stage.Values: new,contacted,qualified,sold,rejectedcycleintegerSales cycle of the person this lead belongs to. contact_idstring | nullThe person (contact) of this lead. fetch_statusstringreadywhen the lead data is stored.adAdSource | nullAd the lead came from, null for organic or unknown. ad.platformstringPlacement platform reported by Meta (facebook, instagram…). ad.campaign_idstringMeta campaign id. ad.campaign_namestringCampaign name. ad.adset_idstringMeta ad set id. ad.adset_namestringAd set name. ad.ad_idstringMeta ad id. ad.ad_namestringAd name. stagesobject[]Stage history. stages[].stagestringstages[].atstring (date-time)stages[].meta_event_queuedbooleananswersLeadAnswer[]Form answers. Only for keys with leads:answers.answers[].keystringField key (also the automation variable). answers[].labelstringQuestion as shown in the form. answers[].valuesstring[]Answers. created_atstring (date-time) | nullWhen the lead was submitted. updated_atstring (date-time)Last change. 400Invalid input (validation_error,invalid_json,invalid_cursor).401Missing, unknown, revoked or expired key (unauthorized,key_revoked,key_expired).403The key lacks the scope (insufficient_scope,details.required_scope).404No such record in your organisation (not_found).429Over 120 requests per minute for this key (rate_limited); waitRetry-Afterseconds.
Example
curl "https://app.convs.io/api/v1/leads/abc123" \
-H "Authorization: Bearer $API_KEY"Conversions
Conversions sent to Meta and their deliveries.
A 202 from POST /events only means the conversion is queued for Meta. A delivery status of accepted means Meta acknowledged receipt. Neither proves that a campaign optimises on this data or that the conversion will be attributed to an ad.
/eventsList events
Conversions with their deliveries; payloads are never returned.
Scope: events:read
Parameters
| Name | Description |
|---|---|
limitqueryinteger | Page size, 1–100 (default 25).Default: 25Range: 1–100 |
cursorquerystring | next_cursor of the previous page. |
orderquerystring | Sort by updated_at (then id): desc newest first (default) or asc.Values: asc, descDefault: desc |
updated_sincequerystring (date-time) | Only records changed at or after this ISO 8601 time. |
sourcequerystring | Source id. |
statusquerystring | Your status. |
external_idquerystring | Your record id. |
Responses
200OKResponse fieldsName Description dataEvent[]Requireddata[].idstringEvent id. data[].source_idstringSource id. data[].external_idstringYour record id. data[].statusstringYour status. data[].meta_event_idstringMeta deduplication id. data[].event_timestring (date-time) | nullEvent time. data[].valuenumber | nullValue. data[].currencystringCurrency. data[].deliveriesDelivery[]Deliveries of this event. data[].deliveries[].idstringDelivery id. data[].deliveries[].event_idstringConvs event id. data[].deliveries[].target_idstringMeta receiver (dataset connection) id. data[].deliveries[].dataset_idstringMeta dataset (pixel) id. data[].deliveries[].event_namestringMeta event name, e.g. Lead, Purchase. data[].deliveries[].meta_event_idstringevent_id sent to Meta. data[].deliveries[].modestringtestorlive.data[].deliveries[].statusstringDelivery status.Values: pending,sending,retry,accepted,failed,cancelleddata[].deliveries[].attemptsintegerAttempts so far. data[].deliveries[].last_errorstringLast error, empty when none. data[].deliveries[].accepted_atstring (date-time) | nullWhen Meta accepted it (receipt, not proof of attribution). data[].deliveries[].next_attempt_atstring (date-time) | nullNext attempt. data[].deliveries[].created_atstring (date-time)Creation time. data[].deliveries[].updated_atstring (date-time)Last change. data[].created_atstring (date-time)Creation time. data[].updated_atstring (date-time)Last change. next_cursorstring | nullRequiredPass as cursorto get the next page; null on the last page.has_morebooleanRequiredTrue when another page exists. 400Invalid input (validation_error,invalid_json,invalid_cursor).401Missing, unknown, revoked or expired key (unauthorized,key_revoked,key_expired).403The key lacks the scope (insufficient_scope,details.required_scope).429Over 120 requests per minute for this key (rate_limited); waitRetry-Afterseconds.
Example
curl "https://app.convs.io/api/v1/events" \
-H "Authorization: Bearer $API_KEY"/eventsSend a conversion
Server-to-server conversion ingest. The source's active flows map status to Meta events and queue one delivery per dataset; dedupe rules are unchanged. Answers 202: queued, not yet sent.
Scope: events:write
Parameters
| Name | Description |
|---|---|
Idempotency-Keyheaderstring | Unique key (1–255 visible ASCII characters). Repeating a POST with the same key and body within 24 hours returns the stored response with Idempotent-Replayed: true.Max length: 255 |
Request body application/json
| Name | Description |
|---|---|
sourcestringRequired | Id of a Google Sheets or Shoper source of your organisation (Połączenia → source details). |
external_idstringRequired | Your id of the record (order, lead…), max 128 characters.Max length: 128 |
statusstringRequired | Your status mapped to a Meta event by the source's flow, e.g. QUALIFIED or purchase.Max length: 80 |
consentbooleanRequired | Must be true: you confirm a legal basis for sending the data. |
event_timeinteger | string (date-time) | Unix seconds or ISO 8601; at most 7 days old. |
event_idstring | Meta deduplication id; generated from source + external_id + status when omitted. |
emailstring | Customer e-mail (hashed with SHA-256 before storage). |
phonestring | Customer phone in E.164 (hashed). |
lead_idstring | Meta lead id, when the conversion closes a Meta lead. |
user_idstring | Your customer id (hashed as external_id). |
fbpstring | _fbp cookie. |
fbcstring | _fbc cookie. |
event_source_urlstring | Page URL of the conversion. |
tracking_idstring | Collector tracking id (Shoper). |
client_ip_addressstring | Customer IP. |
client_user_agentstring | Customer user agent. |
valuenumber | Conversion value (required for Purchase). |
currencystring | ISO 4217 currency (required with value). |
content_idsstring[] | Product ids. |
Responses
202Accepted into the queueResponse fieldsName Description event_idstringConvs event id. deliveries_createdintegerNew deliveries queued for Meta datasets. duplicatesintegerDeliveries skipped as duplicates. sending_enabledbooleanFalse when the server does not send to Meta (queued only). 400Invalid input (validation_error,invalid_json,invalid_cursor).401Missing, unknown, revoked or expired key (unauthorized,key_revoked,key_expired).403The key lacks the scope (insufficient_scope,details.required_scope).404No such record in your organisation (not_found).409Conflicts with the current state (conflict,contact_exists,contact_merged,stage_conflict,event_conflict,source_paused,idempotency_in_progress).413Body over 64 KB (payload_too_large).422Idempotency-Key reused with a different body (idempotency_key_reused).429Over 120 requests per minute for this key (rate_limited); waitRetry-Afterseconds.
Example
curl -X POST "https://app.convs.io/api/v1/events" \
-H "Authorization: Bearer $API_KEY" \
-H "Idempotency-Key: 5f0c7a2e-8d41-4b6a-9e3f-1c2d3e4f5a6b" \
-H "Content-Type: application/json" \
-d '{
"source": "src_abc123",
"external_id": "order-1001",
"status": "purchase",
"consent": true,
"event_time": "2026-10-08T10:00:00Z",
"email": "anna@example.com",
"phone": "+48600100200",
"value": 1200,
"currency": "PLN"
}'const response = await fetch('https://app.convs.io/api/v1/events', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.API_KEY}`,
'Idempotency-Key': '5f0c7a2e-8d41-4b6a-9e3f-1c2d3e4f5a6b',
'Content-Type': 'application/json',
},
body: JSON.stringify({
"source": "src_abc123",
"external_id": "order-1001",
"status": "purchase",
"consent": true,
"event_time": "2026-10-08T10:00:00Z",
"email": "anna@example.com",
"phone": "+48600100200",
"value": 1200,
"currency": "PLN"
}),
})
if (!response.ok) throw new Error((await response.json()).error.message)
const data = await response.json()
console.log(data)import os
import requests
response = requests.post(
"https://app.convs.io/api/v1/events",
headers={
"Authorization": f"Bearer {os.environ['API_KEY']}",
"Idempotency-Key": "5f0c7a2e-8d41-4b6a-9e3f-1c2d3e4f5a6b",
},
json={
"source": "src_abc123",
"external_id": "order-1001",
"status": "purchase",
"consent": True,
"event_time": "2026-10-08T10:00:00Z",
"email": "anna@example.com",
"phone": "+48600100200",
"value": 1200,
"currency": "PLN",
},
timeout=30,
)
response.raise_for_status()
print(response.json())<?php
$ch = curl_init('https://app.convs.io/api/v1/events');
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('API_KEY'),
'Idempotency-Key: 5f0c7a2e-8d41-4b6a-9e3f-1c2d3e4f5a6b',
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'source' => 'src_abc123',
'external_id' => 'order-1001',
'status' => 'purchase',
'consent' => true,
'event_time' => '2026-10-08T10:00:00Z',
'email' => 'anna@example.com',
'phone' => '+48600100200',
'value' => 1200,
'currency' => 'PLN',
]),
]);
$data = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status >= 400) {
throw new RuntimeException($data['error']['message']);
}
print_r($data);/events/{id}Get an event
One conversion with the status of each delivery.
Scope: events:read
Parameters
| Name | Description |
|---|---|
idpathstringRequired | Record id. |
Responses
200OKResponse fieldsName Description idstringEvent id. source_idstringSource id. external_idstringYour record id. statusstringYour status. meta_event_idstringMeta deduplication id. event_timestring (date-time) | nullEvent time. valuenumber | nullValue. currencystringCurrency. deliveriesDelivery[]Deliveries of this event. deliveries[].idstringDelivery id. deliveries[].event_idstringConvs event id. deliveries[].target_idstringMeta receiver (dataset connection) id. deliveries[].dataset_idstringMeta dataset (pixel) id. deliveries[].event_namestringMeta event name, e.g. Lead, Purchase. deliveries[].meta_event_idstringevent_id sent to Meta. deliveries[].modestringtestorlive.deliveries[].statusstringDelivery status.Values: pending,sending,retry,accepted,failed,cancelleddeliveries[].attemptsintegerAttempts so far. deliveries[].last_errorstringLast error, empty when none. deliveries[].accepted_atstring (date-time) | nullWhen Meta accepted it (receipt, not proof of attribution). deliveries[].next_attempt_atstring (date-time) | nullNext attempt. deliveries[].created_atstring (date-time)Creation time. deliveries[].updated_atstring (date-time)Last change. created_atstring (date-time)Creation time. updated_atstring (date-time)Last change. 400Invalid input (validation_error,invalid_json,invalid_cursor).401Missing, unknown, revoked or expired key (unauthorized,key_revoked,key_expired).403The key lacks the scope (insufficient_scope,details.required_scope).404No such record in your organisation (not_found).429Over 120 requests per minute for this key (rate_limited); waitRetry-Afterseconds.
Example
curl "https://app.convs.io/api/v1/events/abc123" \
-H "Authorization: Bearer $API_KEY"/deliveriesList deliveries
Delivery status to Meta, e.g. status=failed&updated_since=… to follow failures.
Scope: events:read
Parameters
| Name | Description |
|---|---|
limitqueryinteger | Page size, 1–100 (default 25).Default: 25Range: 1–100 |
cursorquerystring | next_cursor of the previous page. |
orderquerystring | Sort by updated_at (then id): desc newest first (default) or asc.Values: asc, descDefault: desc |
updated_sincequerystring (date-time) | Only records changed at or after this ISO 8601 time. |
statusquerystring | Delivery status.Values: pending, sending, retry, accepted, failed, cancelled |
eventquerystring | Event id. |
targetquerystring | Receiver id. |
Responses
200OKResponse fieldsName Description dataDelivery[]Requireddata[].idstringDelivery id. data[].event_idstringConvs event id. data[].target_idstringMeta receiver (dataset connection) id. data[].dataset_idstringMeta dataset (pixel) id. data[].event_namestringMeta event name, e.g. Lead, Purchase. data[].meta_event_idstringevent_id sent to Meta. data[].modestringtestorlive.data[].statusstringDelivery status.Values: pending,sending,retry,accepted,failed,cancelleddata[].attemptsintegerAttempts so far. data[].last_errorstringLast error, empty when none. data[].accepted_atstring (date-time) | nullWhen Meta accepted it (receipt, not proof of attribution). data[].next_attempt_atstring (date-time) | nullNext attempt. data[].created_atstring (date-time)Creation time. data[].updated_atstring (date-time)Last change. next_cursorstring | nullRequiredPass as cursorto get the next page; null on the last page.has_morebooleanRequiredTrue when another page exists. 400Invalid input (validation_error,invalid_json,invalid_cursor).401Missing, unknown, revoked or expired key (unauthorized,key_revoked,key_expired).403The key lacks the scope (insufficient_scope,details.required_scope).429Over 120 requests per minute for this key (rate_limited); waitRetry-Afterseconds.
Example
curl "https://app.convs.io/api/v1/deliveries" \
-H "Authorization: Bearer $API_KEY"Reports
Campaign and creative numbers.
/campaigns/reportCampaign report
Spend, leads, qualified leads, sales and costs per campaign, ad set or ad — the numbers of the Kampanie page.
Scope: campaigns:read
Parameters
| Name | Description |
|---|---|
fromquerystring | First day YYYY-MM-DD (default: 30 days ago). |
toquerystring | Last day YYYY-MM-DD (range up to 366 days). |
levelquerystring | Row level.Values: campaign, adset, adDefault: campaign |
campaignquerystring | Only this campaign (for adset/ad level). |
adsetquerystring | Only this ad set (ad level). |
Responses
200OKResponse fieldsName Description fromstringFirst day (organisation time zone). tostringLast day. levelstringRow level.Values: campaign,adset,adtotalsobject[]Totals per currency: currentand thepreviousperiod of the same length.rowsobject[]One row per campaign / ad set / ad with metrics(spend, meta_leads, leads, qualified, rejected, sales, revenue, cost_per_lead, cost_per_qualified, cost_per_sale, revenue_per_spend; costs are null when the divisor is 0).organicobjectLeads without a known ad. syncsobject[]Spend sync state per ad account. maturingbooleanTrue when the range includes the last 30 days (sales may still come). unattributedintegerLeads of the period with a known source but no id at this level. 400Invalid input (validation_error,invalid_json,invalid_cursor).401Missing, unknown, revoked or expired key (unauthorized,key_revoked,key_expired).403The key lacks the scope (insufficient_scope,details.required_scope).429Over 120 requests per minute for this key (rate_limited); waitRetry-Afterseconds.
Example
curl "https://app.convs.io/api/v1/campaigns/report" \
-H "Authorization: Bearer $API_KEY"/creatives/reportCreative report
Metrics per creative group with weekly momentum and the fatigue rule — the numbers of the Kreacje page.
Scope: creatives:read
Parameters
| Name | Description |
|---|---|
fromquerystring | First day YYYY-MM-DD. |
toquerystring | Last day YYYY-MM-DD. |
formatquerystring | Creative format. |
campaignquerystring | Meta campaign id. |
ad_accountquerystring | Ad account id. |
tagquerystring | Tag filter dimension:value (hook, creator, offer). |
weeksqueryinteger | Weeks of momentum, 4–8. |
Responses
200OKResponse fieldsName Description fromstringFirst day. tostringLast day. weeksarray[]Week ranges used for momentum. rowsobject[]One row per creative group (same video, image or creative) with metrics, weekly trend, fatigue verdict and tags. totalsobject[]Totals per currency. syncsobject[]Spend sync state per ad account. unattributedintegerLeads without a known creative. 400Invalid input (validation_error,invalid_json,invalid_cursor).401Missing, unknown, revoked or expired key (unauthorized,key_revoked,key_expired).403The key lacks the scope (insufficient_scope,details.required_scope).429Over 120 requests per minute for this key (rate_limited); waitRetry-Afterseconds.
Example
curl "https://app.convs.io/api/v1/creatives/report" \
-H "Authorization: Bearer $API_KEY"Background jobs
Background work.
/jobsList background jobs
Lead imports, spend syncs, sheet syncs and other background work of the organisation.
Scope: jobs:read
Parameters
| Name | Description |
|---|---|
limitqueryinteger | Page size, 1–100 (default 25).Default: 25Range: 1–100 |
cursorquerystring | next_cursor of the previous page. |
orderquerystring | Sort by updated_at (then id): desc newest first (default) or asc.Values: asc, descDefault: desc |
updated_sincequerystring (date-time) | Only records changed at or after this ISO 8601 time. |
statusquerystring | Job status.Values: queued, running, done, failed, cancelled |
Responses
200OKResponse fieldsName Description dataJob[]Requireddata[].numberintegerJob number within the organisation. data[].kindstringJob kind, e.g. lead_import, ad_spend, sheets_sync. data[].labelstringDescription. data[].statusstringStatus.Values: queued,running,done,failed,cancelleddata[].prioritystringuserorbackground.data[].triggerstringWhat started it. data[].progressobjectdonecount andtext.data[].attemptsintegerAttempts. data[].errorstringError text of a failed job. data[].summarystringResult summary. data[].run_afterstring (date-time) | nullNot before. data[].started_atstring (date-time) | nullStart. data[].finished_atstring (date-time) | nullEnd. data[].createdstring (date-time)Creation time. data[].updated_atstring (date-time)Last change. next_cursorstring | nullRequiredPass as cursorto get the next page; null on the last page.has_morebooleanRequiredTrue when another page exists. 400Invalid input (validation_error,invalid_json,invalid_cursor).401Missing, unknown, revoked or expired key (unauthorized,key_revoked,key_expired).403The key lacks the scope (insufficient_scope,details.required_scope).429Over 120 requests per minute for this key (rate_limited); waitRetry-Afterseconds.
Example
curl "https://app.convs.io/api/v1/jobs" \
-H "Authorization: Bearer $API_KEY"/jobs/{number}Get a job
One job by its number.
Scope: jobs:read
Parameters
| Name | Description |
|---|---|
numberpathintegerRequired | Job number. |
Responses
200OKResponse fieldsName Description numberintegerJob number within the organisation. kindstringJob kind, e.g. lead_import, ad_spend, sheets_sync. labelstringDescription. statusstringStatus.Values: queued,running,done,failed,cancelledprioritystringuserorbackground.triggerstringWhat started it. progressobjectdonecount andtext.attemptsintegerAttempts. errorstringError text of a failed job. summarystringResult summary. run_afterstring (date-time) | nullNot before. started_atstring (date-time) | nullStart. finished_atstring (date-time) | nullEnd. createdstring (date-time)Creation time. updated_atstring (date-time)Last change. 400Invalid input (validation_error,invalid_json,invalid_cursor).401Missing, unknown, revoked or expired key (unauthorized,key_revoked,key_expired).403The key lacks the scope (insufficient_scope,details.required_scope).404No such record in your organisation (not_found).429Over 120 requests per minute for this key (rate_limited); waitRetry-Afterseconds.
Example
curl "https://app.convs.io/api/v1/jobs/42" \
-H "Authorization: Bearer $API_KEY"Webhooks
Events pushed to your server.
/webhooksList webhooks
Webhook subscriptions of the organisation (up to 10).
Scope: webhooks:read
Responses
200OKResponse fieldsName Description dataWebhook[]Requireddata[].idstringWebhook id. data[].urlstringHTTPS endpoint that receives POSTs. data[].descriptionstringYour note. data[].eventsstring[]Subscribed events.Values: contact.created,contact.stage_changed,lead.created,delivery.faileddata[].activebooleanFalse pauses deliveries. data[].last_statusintegerHTTP status of the last attempt (0 = no answer). data[].last_errorstringLast error. data[].last_delivery_atstring (date-time) | nullLast attempt. data[].consecutive_failuresintegerFailed attempts in a row. data[].created_atstring (date-time)Creation time. data[].updated_atstring (date-time)Last change. next_cursorstring | nullRequiredPass as cursorto get the next page; null on the last page.has_morebooleanRequiredTrue when another page exists. 400Invalid input (validation_error,invalid_json,invalid_cursor).401Missing, unknown, revoked or expired key (unauthorized,key_revoked,key_expired).403The key lacks the scope (insufficient_scope,details.required_scope).429Over 120 requests per minute for this key (rate_limited); waitRetry-Afterseconds.
Example
curl "https://app.convs.io/api/v1/webhooks" \
-H "Authorization: Bearer $API_KEY"/webhooksCreate a webhook
Subscribes an HTTPS URL to events. The signing secret is returned only here.
Scope: webhooks:write
Parameters
| Name | Description |
|---|---|
Idempotency-Keyheaderstring | Unique key (1–255 visible ASCII characters). Repeating a POST with the same key and body within 24 hours returns the stored response with Idempotent-Replayed: true.Max length: 255 |
Request body application/json
| Name | Description |
|---|---|
urlstringRequired | HTTPS URL (no credentials, no private or local addresses).Max length: 2000 |
eventsstring[]Required | Events to receive.Values: contact.created, contact.stage_changed, lead.created, delivery.failed |
descriptionstring | Your note.Max length: 200 |
Responses
201CreatedResponse fieldsName Description idstringWebhook id. urlstringHTTPS endpoint that receives POSTs. descriptionstringYour note. eventsstring[]Subscribed events.Values: contact.created,contact.stage_changed,lead.created,delivery.failedactivebooleanFalse pauses deliveries. last_statusintegerHTTP status of the last attempt (0 = no answer). last_errorstringLast error. last_delivery_atstring (date-time) | nullLast attempt. consecutive_failuresintegerFailed attempts in a row. created_atstring (date-time)Creation time. updated_atstring (date-time)Last change. secretstringRequiredSigning secret ( whsec_…). Shown only in this response — store it.400Invalid input (validation_error,invalid_json,invalid_cursor).401Missing, unknown, revoked or expired key (unauthorized,key_revoked,key_expired).403The key lacks the scope (insufficient_scope,details.required_scope).409Conflicts with the current state (conflict,contact_exists,contact_merged,stage_conflict,event_conflict,source_paused,idempotency_in_progress).413Body over 64 KB (payload_too_large).422Idempotency-Key reused with a different body (idempotency_key_reused).429Over 120 requests per minute for this key (rate_limited); waitRetry-Afterseconds.
Example
curl -X POST "https://app.convs.io/api/v1/webhooks" \
-H "Authorization: Bearer $API_KEY" \
-H "Idempotency-Key: 5f0c7a2e-8d41-4b6a-9e3f-1c2d3e4f5a6b" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks",
"events": [
"contact.created",
"contact.stage_changed"
]
}'/webhooks/{id}Get a webhook
One subscription with its last result.
Scope: webhooks:read
Parameters
| Name | Description |
|---|---|
idpathstringRequired | Record id. |
Responses
200OKResponse fieldsName Description idstringWebhook id. urlstringHTTPS endpoint that receives POSTs. descriptionstringYour note. eventsstring[]Subscribed events.Values: contact.created,contact.stage_changed,lead.created,delivery.failedactivebooleanFalse pauses deliveries. last_statusintegerHTTP status of the last attempt (0 = no answer). last_errorstringLast error. last_delivery_atstring (date-time) | nullLast attempt. consecutive_failuresintegerFailed attempts in a row. created_atstring (date-time)Creation time. updated_atstring (date-time)Last change. 400Invalid input (validation_error,invalid_json,invalid_cursor).401Missing, unknown, revoked or expired key (unauthorized,key_revoked,key_expired).403The key lacks the scope (insufficient_scope,details.required_scope).404No such record in your organisation (not_found).429Over 120 requests per minute for this key (rate_limited); waitRetry-Afterseconds.
Example
curl "https://app.convs.io/api/v1/webhooks/abc123" \
-H "Authorization: Bearer $API_KEY"/webhooks/{id}Update a webhook
Changes URL, events, description or pauses it; resuming resets the failure count.
Scope: webhooks:write
Parameters
| Name | Description |
|---|---|
idpathstringRequired | Record id. |
Request body application/json
| Name | Description |
|---|---|
urlstring | New URL. |
eventsstring[] | New event list.Values: contact.created, contact.stage_changed, lead.created, delivery.failed |
descriptionstring | Your note. |
activeboolean | Pause or resume. |
Responses
200OKResponse fieldsName Description idstringWebhook id. urlstringHTTPS endpoint that receives POSTs. descriptionstringYour note. eventsstring[]Subscribed events.Values: contact.created,contact.stage_changed,lead.created,delivery.failedactivebooleanFalse pauses deliveries. last_statusintegerHTTP status of the last attempt (0 = no answer). last_errorstringLast error. last_delivery_atstring (date-time) | nullLast attempt. consecutive_failuresintegerFailed attempts in a row. created_atstring (date-time)Creation time. updated_atstring (date-time)Last change. 400Invalid input (validation_error,invalid_json,invalid_cursor).401Missing, unknown, revoked or expired key (unauthorized,key_revoked,key_expired).403The key lacks the scope (insufficient_scope,details.required_scope).404No such record in your organisation (not_found).413Body over 64 KB (payload_too_large).429Over 120 requests per minute for this key (rate_limited); waitRetry-Afterseconds.
Example
curl -X PATCH "https://app.convs.io/api/v1/webhooks/abc123" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks",
"events": [
"contact.created",
"contact.stage_changed"
],
"active": false
}'/webhooks/{id}Delete a webhook
Removes the subscription and its queued deliveries.
Scope: webhooks:write
Parameters
| Name | Description |
|---|---|
idpathstringRequired | Record id. |
Responses
204Deleted400Invalid input (validation_error,invalid_json,invalid_cursor).401Missing, unknown, revoked or expired key (unauthorized,key_revoked,key_expired).403The key lacks the scope (insufficient_scope,details.required_scope).404No such record in your organisation (not_found).429Over 120 requests per minute for this key (rate_limited); waitRetry-Afterseconds.
Example
curl -X DELETE "https://app.convs.io/api/v1/webhooks/abc123" \
-H "Authorization: Bearer $API_KEY"/webhooks/{id}/testSend a test ping
Queues a signed ping event to check your endpoint and signature verification.
Scope: webhooks:write
Parameters
| Name | Description |
|---|---|
idpathstringRequired | Record id. |
Idempotency-Keyheaderstring | Unique key (1–255 visible ASCII characters). Repeating a POST with the same key and body within 24 hours returns the stored response with Idempotent-Replayed: true.Max length: 255 |
Responses
202QueuedResponse fieldsName Description delivery_idstringQueued delivery of a pingevent.400Invalid input (validation_error,invalid_json,invalid_cursor).401Missing, unknown, revoked or expired key (unauthorized,key_revoked,key_expired).403The key lacks the scope (insufficient_scope,details.required_scope).404No such record in your organisation (not_found).409Conflicts with the current state (conflict,contact_exists,contact_merged,stage_conflict,event_conflict,source_paused,idempotency_in_progress).422Idempotency-Key reused with a different body (idempotency_key_reused).429Over 120 requests per minute for this key (rate_limited); waitRetry-Afterseconds.
Example
curl -X POST "https://app.convs.io/api/v1/webhooks/abc123/test" \
-H "Authorization: Bearer $API_KEY" \
-H "Idempotency-Key: 5f0c7a2e-8d41-4b6a-9e3f-1c2d3e4f5a6b"/webhooks/{id}/rotate-secretRotate the signing secret
Replaces the signing secret at once; the new secret is returned only here. Deliveries still queued are signed with the new secret.
Scope: webhooks:write
Parameters
| Name | Description |
|---|---|
idpathstringRequired | Record id. |
Idempotency-Keyheaderstring | Unique key (1–255 visible ASCII characters). Repeating a POST with the same key and body within 24 hours returns the stored response with Idempotent-Replayed: true.Max length: 255 |
Responses
200OKResponse fieldsName Description idstringWebhook id. urlstringHTTPS endpoint that receives POSTs. descriptionstringYour note. eventsstring[]Subscribed events.Values: contact.created,contact.stage_changed,lead.created,delivery.failedactivebooleanFalse pauses deliveries. last_statusintegerHTTP status of the last attempt (0 = no answer). last_errorstringLast error. last_delivery_atstring (date-time) | nullLast attempt. consecutive_failuresintegerFailed attempts in a row. created_atstring (date-time)Creation time. updated_atstring (date-time)Last change. secretstringRequiredSigning secret ( whsec_…). Shown only in this response — store it.400Invalid input (validation_error,invalid_json,invalid_cursor).401Missing, unknown, revoked or expired key (unauthorized,key_revoked,key_expired).403The key lacks the scope (insufficient_scope,details.required_scope).404No such record in your organisation (not_found).409Conflicts with the current state (conflict,contact_exists,contact_merged,stage_conflict,event_conflict,source_paused,idempotency_in_progress).422Idempotency-Key reused with a different body (idempotency_key_reused).429Over 120 requests per minute for this key (rate_limited); waitRetry-Afterseconds.
Example
curl -X POST "https://app.convs.io/api/v1/webhooks/abc123/rotate-secret" \
-H "Authorization: Bearer $API_KEY" \
-H "Idempotency-Key: 5f0c7a2e-8d41-4b6a-9e3f-1c2d3e4f5a6b"/webhooks/{id}/deliveriesList webhook deliveries
Recent attempts (kept 30 days), newest first.
Scope: webhooks:read
Parameters
| Name | Description |
|---|---|
idpathstringRequired | Record id. |
limitqueryinteger | Page size, 1–100 (default 25).Default: 25Range: 1–100 |
cursorquerystring | next_cursor of the previous page. |
orderquerystring | Sort by updated_at (then id): desc newest first (default) or asc.Values: asc, descDefault: desc |
updated_sincequerystring (date-time) | Only records changed at or after this ISO 8601 time. |
Responses
200OKResponse fieldsName Description dataWebhookDelivery[]Requireddata[].idstringDelivery id (header X-Convs-Delivery). data[].webhook_idstringWebhook id. data[].eventstringEvent type. data[].event_idstringEvent id ( evt_…, the same for every webhook of one event).data[].statusstringStatus.Values: pending,sending,retry,delivered,failed,cancelleddata[].attemptsintegerAttempts. data[].response_statusintegerHTTP status of the last attempt. data[].last_errorstringLast error. data[].next_attempt_atstring (date-time) | nullNext attempt. data[].delivered_atstring (date-time) | nullDelivered at. data[].created_atstring (date-time)Creation time. data[].updated_atstring (date-time)Last change. next_cursorstring | nullRequiredPass as cursorto get the next page; null on the last page.has_morebooleanRequiredTrue when another page exists. 400Invalid input (validation_error,invalid_json,invalid_cursor).401Missing, unknown, revoked or expired key (unauthorized,key_revoked,key_expired).403The key lacks the scope (insufficient_scope,details.required_scope).404No such record in your organisation (not_found).429Over 120 requests per minute for this key (rate_limited); waitRetry-Afterseconds.
Example
curl "https://app.convs.io/api/v1/webhooks/abc123/deliveries" \
-H "Authorization: Bearer $API_KEY"Webhooks
A webhook sends a JSON POST to your HTTPS URL when something changes in the organisation. An organisation can have up to 10 webhooks. The signing secret (whsec_…) is returned only in the response that creates the webhook.
Delivery is at least once: the same event can arrive more than once, so skip any id you have already handled. Payloads carry ids and states only, never names, phones or e-mails; fetch details with GET.
Events
contact.created
A person was added (form lead, manual, API, automation).
{
"id": "evt_9f2c4b1a7d3e5f60a1b2c3d4",
"type": "contact.created",
"created_at": "2026-10-08T10:00:00Z",
"organization_id": "org123",
"data": {
"contact_id": "abc123",
"stage": "new",
"first_source": "Formularz kontaktowy"
}
}contact.stage_changed
A person moved to another stage.
{
"id": "evt_9f2c4b1a7d3e5f60a1b2c3d4",
"type": "contact.stage_changed",
"created_at": "2026-10-08T10:00:00Z",
"organization_id": "org123",
"data": {
"contact_id": "abc123",
"from": "new",
"to": "qualified",
"cycle": 1,
"meta_events_queued": 1,
"leads_without_send": 0
}
}lead.created
A Meta Instant Forms lead was stored.
{
"id": "evt_9f2c4b1a7d3e5f60a1b2c3d4",
"type": "lead.created",
"created_at": "2026-10-08T10:00:00Z",
"organization_id": "org123",
"data": {
"lead_id": "l123",
"meta_lead_id": "1234567890",
"form_id": "src123",
"contact_id": "abc123",
"campaign_id": "120200000",
"adset_id": "",
"ad_id": "",
"created_at": "2026-10-08T10:00:00Z"
}
}delivery.failed
A conversion delivery to Meta failed for good.
{
"id": "evt_9f2c4b1a7d3e5f60a1b2c3d4",
"type": "delivery.failed",
"created_at": "2026-10-08T10:00:00Z",
"organization_id": "org123",
"data": {
"delivery_id": "d123",
"event_id": "e123",
"target_id": "t123",
"dataset_id": "123456789",
"event_name": "Lead",
"mode": "live",
"error": "Połącz konto Meta ponownie lub sprawdź token."
}
}Headers
| Header | Description |
|---|---|
X-Convs-Signature | sha256= + hex HMAC-SHA256 of {X-Convs-Timestamp}.{raw body} with the webhook secret. |
X-Convs-Timestamp | Unix seconds of the attempt; reject values older than 5 minutes. |
X-Convs-Event | Event type. |
X-Convs-Delivery | Delivery id (changes per webhook, stays across retries). |
Acknowledgement and retries
- Answer with any 2xx code within 15 seconds. Store the event first and do longer work later.
- Any other answer, or none, is retried: up to 8 attempts in total, after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h and 24 h.
X-Convs-Deliverystays the same across attempts of one delivery;GET /webhooks/{id}/deliveriesshows the attempt history.
Verifying the signature
- Read the raw request body before anything parses it.
- Reject the request when the timestamp is older than 5 minutes.
- Compute HMAC-SHA256 of
{timestamp}.{raw body}with the secret and compare it with the signature header (sha256=<hex>) in constant time.
import crypto from 'node:crypto'
import http from 'node:http'
const secret = process.env.WEBHOOK_SECRET // whsec_…
function verify(rawBody, timestamp, signature) {
const age = Math.abs(Date.now() / 1000 - Number(timestamp))
if (!timestamp || !(age <= 300)) return false
const expected = 'sha256=' + crypto.createHmac('sha256', secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest('hex')
const a = Buffer.from(String(signature ?? ''))
const b = Buffer.from(expected)
return a.length === b.length && crypto.timingSafeEqual(a, b)
}
http.createServer((req, res) => {
const chunks = []
req.on('data', chunk => chunks.push(chunk))
req.on('end', () => {
const raw = Buffer.concat(chunks) // raw body, before JSON parsing
if (!verify(raw, req.headers['x-convs-timestamp'], req.headers['x-convs-signature'])) {
res.writeHead(401).end()
return
}
const event = JSON.parse(raw.toString('utf8'))
// skip an event.id you have already handled, and answer fast
res.writeHead(200).end()
})
}).listen(3000)import hashlib
import hmac
import os
import time
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["WEBHOOK_SECRET"].encode() # whsec_…
@app.post("/webhooks")
def webhook():
raw = request.get_data() # raw body, before JSON parsing
timestamp = request.headers.get("X-Convs-Timestamp", "")
signature = request.headers.get("X-Convs-Signature", "")
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
abort(401)
expected = "sha256=" + hmac.new(SECRET, timestamp.encode() + b"." + raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature):
abort(401)
event = request.get_json()
# skip an event.id you have already handled, and answer fast
return "", 200<?php
$secret = getenv('WEBHOOK_SECRET'); // whsec_…
$raw = file_get_contents('php://input'); // raw body, before JSON parsing
$timestamp = $_SERVER['HTTP_X_CONVS_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_CONVS_SIGNATURE'] ?? '';
if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) {
http_response_code(401);
exit;
}
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $raw, $secret);
if (!hash_equals($expected, $signature)) {
http_response_code(401);
exit;
}
$event = json_decode($raw, true);
// skip an event.id you have already handled, and answer fast
http_response_code(200);Creating and testing
Create a webhook, store the secret from the response, then send a test ping to check your endpoint and signature verification.
curl -X POST "https://app.convs.io/api/v1/webhooks" \
-H "Authorization: Bearer $API_KEY" \
-H "Idempotency-Key: 5f0c7a2e-8d41-4b6a-9e3f-1c2d3e4f5a6b" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks",
"events": [
"contact.created",
"contact.stage_changed"
]
}'curl -X POST "https://app.convs.io/api/v1/webhooks/abc123/test" \
-H "Authorization: Bearer $API_KEY" \
-H "Idempotency-Key: 5f0c7a2e-8d41-4b6a-9e3f-1c2d3e4f5a6b"Changelog
- 1.0.0 · : First public version.