# Meliora API: full documentation > Meliora public API v1: read campaigns, leads, conversations and the event feed with an organization API key. Read-only, early access. Base URL: https://api.melioralab.io/v1. Authentication: the `Authorization: Bearer ` header. Never pass the key in the URL. Site: https://docs.melioralab.io/en/. Specification: https://docs.melioralab.io/en/openapi.json. Index: https://docs.melioralab.io/en/llms.txt. --- ## Meliora API Read campaigns, leads, stored conversations and events from your Meliora organization in your CRM or your own system. Version v1, early access. AI agent prompt: https://docs.melioralab.io/en/agent-prompt.txt ### What you can do The API returns your organization's data as JSON, authenticated with an organization API key. Common use cases: - **Sync replies to your CRM.** The event feed tells you when a lead gets the first message and when they reply. The `fields.record_id` field on the lead links it to a CRM record. - **Track campaign progress.** Campaign and lead states, delivery state and the reason a lead is waiting, such as the sending window or the account's daily quota. - **Pull conversations.** Stored messages with a lead: sequence steps, manual messages from your managers, and replies. - **Reconcile data.** Lists of campaigns and leads, filtered by workspace, campaign, status and username. ### Endpoints Every endpoint in v1 is read-only and uses the GET method. | Endpoint | Returns | Permission | |---|---|---| | [`GET /v1/me`](https://docs.melioralab.io/en/reference/api-context/#getMe) | Retrieve API key details | any | | [`GET /v1/workspaces`](https://docs.melioralab.io/en/reference/workspaces/#listWorkspaces) | List workspaces | any | | [`GET /v1/campaigns`](https://docs.melioralab.io/en/reference/campaigns/#listCampaigns) | List campaigns | `campaigns:read` | | [`GET /v1/campaigns/{id}`](https://docs.melioralab.io/en/reference/campaigns/#getCampaign) | Retrieve a campaign | `campaigns:read` | | [`GET /v1/leads`](https://docs.melioralab.io/en/reference/leads/#listLeads) | List leads | `leads:read` | | [`GET /v1/leads/{id}`](https://docs.melioralab.io/en/reference/leads/#getLead) | Retrieve a lead | `leads:read` | | [`GET /v1/leads/{id}/messages`](https://docs.melioralab.io/en/reference/messages/#listLeadMessages) | List lead conversation | `messages:read` | | [`GET /v1/events`](https://docs.melioralab.io/en/reference/events/#listEvents) | List events | `events:read` | ### How the API works - Base URL: `https://api.melioralab.io/v1`. - Pass the key in the `Authorization: Bearer mel_live_...` header. The key belongs to your organization, not to a team member, so it keeps working if the person who created it leaves. - Responses are UTF-8 JSON. Field names use `snake_case`, IDs are UUIDs, and timestamps are ISO 8601 in UTC with a `Z` suffix. - A key sees only what its permissions and scope allow. To the key, an object outside its scope does not exist and returns 404. - Call the API from your server. Browser requests from third-party sites are not supported. ### Early access The API is in early access. Keys are issued on request: contact [Support](https://docs.melioralab.io/en/support/), and we will enable API access for your organization and issue a key. Read access is available on the Pro plan and above. Before you start: - **Read-only.** You can't import contacts or start or stop a sequence through the API yet. Do this in Meliora instead. - **The conversation archive may be incomplete.** The API returns what has been stored. `meta.completeness` tells you how complete it is. - **The feed keeps events for 30 days.** Poll it regularly. After a long outage, reconcile records as described in [Polling events](https://docs.melioralab.io/en/events-polling/). - **Some status values are not sent yet.** See [Lead and delivery states](https://docs.melioralab.io/en/lead-states/) for details. ### Where to start 1. [Quickstart](https://docs.melioralab.io/en/quickstart/): make your first request in 5 minutes. 2. [Authentication and keys](https://docs.melioralab.io/en/authentication/): permissions, scope, rotation and revocation. 3. [Polling events](https://docs.melioralab.io/en/events-polling/): a reliable polling loop for CRM integrations. 4. [API reference](https://docs.melioralab.io/en/reference/): every field, parameter and example response. 5. [AI agents and MCP](https://docs.melioralab.io/en/ai-agents/): how to give these docs to Claude, ChatGPT, Cursor or Claude Code. You can download the OpenAPI 3.1 specification: [openapi.json](https://docs.melioralab.io/en/openapi.json). Import it into Postman, Insomnia or a client generator. --- ## Quickstart Five steps from an API key to the event feed. You need a terminal with curl, Node.js 18+ or Python 3. AI agent prompt: https://docs.melioralab.io/en/agent-prompt.txt #### Get an API key During early access, we issue keys for you: contact [Support](https://docs.melioralab.io/en/support/) and tell us which permissions and scope you need. To get started, the Campaigns, Leads and Events permissions are enough. If your organization has **Settings › Integrations › API** enabled, an Owner or Admin can create a key with the "Create key" button. The key is shown only once. Save it right away: you can't recover it, only rotate it. #### Store the key in an environment variable Don't hardcode the key or pass it in the request URL. Store it in an environment variable: ```bash export MELIORA_API_KEY="mel_live_..." ``` #### Check access `GET /v1/me` returns the organization, permissions and limits of the key. It works with any set of permissions. ```bash curl https://api.melioralab.io/v1/me \ -H "Authorization: Bearer $MELIORA_API_KEY" ``` ```js const res = await fetch('https://api.melioralab.io/v1/me', { headers: { Authorization: `Bearer ${process.env.MELIORA_API_KEY}` }, }); console.log(res.status, await res.json()); ``` ```python import os import requests resp = requests.get( "https://api.melioralab.io/v1/me", headers={"Authorization": f"Bearer {os.environ['MELIORA_API_KEY']}"}, timeout=15, ) print(resp.status_code, resp.json()) ``` In the response, check `scopes` and `scope`: these are the key's permissions and scope. If you get an error, look up its code and how to fix it in [Errors](https://docs.melioralab.io/en/errors/). The most common one is `invalid_api_key`: the key was copied only partially or with an extra space. #### List campaigns and leads Campaigns are returned in pages, oldest first: ```bash curl -G https://api.melioralab.io/v1/campaigns \ -H "Authorization: Bearer $MELIORA_API_KEY" \ -d status=active ``` Take the `id` of the campaign you need and request its leads: ```bash curl -G https://api.melioralab.io/v1/leads \ -H "Authorization: Bearer $MELIORA_API_KEY" \ -d campaign_id=4d8b2f73-6e1a-4c95-b7d0-8a3f5e2c9b14 \ -d limit=100 ``` If the response has `has_more: true`, pass the `next_cursor` value as the `cursor` parameter of your next request. See [Pagination](https://docs.melioralab.io/en/pagination/) for details. #### Subscribe to events To learn about new messages and replies without walking through every lead, read the event feed. The first request with `start=now` returns an empty page and the cursor the feed starts from: ```bash curl -G https://api.melioralab.io/v1/events \ -H "Authorization: Bearer $MELIORA_API_KEY" \ -d start=now ``` Save `next_cursor` and request `GET /v1/events?cursor=...` once a minute. Every response contains a new cursor, even when there are no events: always save it. ### What's next - [Polling events](https://docs.melioralab.io/en/events-polling/): a loop that never loses or duplicates events, with Node.js and Python examples. - [CRM mapping](https://docs.melioralab.io/en/crm-mapping/): how to match a lead to a CRM record with `record_id`. - [Rate limits and retries](https://docs.melioralab.io/en/rate-limits/): how to handle 429 and 503. --- ## Authentication and keys Every request is signed with an organization API key. The key defines exactly what you can read: permissions, workspaces, campaigns and message text. ### How to pass the key Pass the key in the `Authorization` header with the `Bearer` scheme: ```http GET /v1/me HTTP/1.1 Host: api.melioralab.io Authorization: Bearer mel_live_AbCdEfGhIjKlMnOpQrStUvWxYz012345 ``` A key looks like `mel_live_` followed by 32 Latin letters or digits. Session cookies from the Meliora app and other sign-in methods don't replace the key. A request with no key or an invalid key gets 401 `invalid_api_key`. After 20 responses with status 401 within 5 minutes, the IP address is blocked for 15 minutes, so don't retry an invalid key in a loop. The key is not accepted in the request URL: a parameter like `?api_key=...` returns 400 `unknown_parameter`. If a key ever ends up in a URL, a proxy log or browser history, rotate it. ### Get access The API is in early access, and keys are issued on request. Contact [Support](https://docs.melioralab.io/en/support/) and tell us which permissions, scope, expiration and IP addresses you need. We will enable API access for your organization, issue a key and help you with your first request. The full key is shared only once. When the API section is enabled for your organization, keys are managed by an **Owner or Admin** with access to every workspace and campaign in the organization. Other members can view the section but can't change anything. 1. Open **Settings › Integrations**, then the **API** block. 2. If access is turned off, click "Enable API". 3. Click "Create key" and set a name, permissions and scope. Optionally, set an expiration date and allowed IP addresses. 4. Copy the key from the "The key will be shown only once" dialog. API read access is available on the Pro plan and above. If your plan is lower, your subscription is inactive or your organization's access is suspended, requests get 403 with a reason code: `api_not_available`, `subscription_inactive` or `organization_suspended`. An organization can have up to 10 active keys. This count includes keys that are still preparing and old keys during a rotation overlap period. ### Permissions You choose permissions when you create a key and can change them with the "Edit" button. | Permission | In Meliora | What it unlocks | |---|---|---| | `campaigns:read` | Campaigns | `GET /v1/campaigns`, `GET /v1/campaigns/{id}` | | `leads:read` | Leads | `GET /v1/leads`, `GET /v1/leads/{id}` | | `messages:read` | Conversations | `GET /v1/leads/{id}/messages` and message text in events | | `events:read` | Events | `GET /v1/events` | `GET /v1/me` and `GET /v1/workspaces` work with any set of permissions. **Message text** is returned only if the key has the `messages:read` permission **and** the "Message text" checkbox is selected. The checkbox is off by default. Without it, `GET /v1/leads/{id}/messages` and event requests with `include_text=true` get 403 `scope_required`. ### Scope Scope limits what data the key can see: - **Workspaces:** all existing items or selected ones, up to 50. - **Campaigns:** all campaigns in the selected workspaces or selected ones, up to 500. - **Include future items:** when the scope is set to all items, the key also sees workspaces or campaigns created later. If you clear this checkbox, the scope is fixed to what exists at the moment you save it. To the key, an object outside its scope does not exist: a request for it returns 404 `not_found`, just like a request for an object that doesn't exist. You can read the key's current scope in the `scope` field of the [`GET /v1/me`](https://docs.melioralab.io/en/reference/api-context/) response. ### Expiration and IP addresses - **Expiration** is set in UTC. Once it passes, the key gets 401 `invalid_api_key`. Leave the field empty for a key that never expires. The expiration is returned in the `expires_at` field of `GET /v1/me`. - **Allowed IPs** are set as addresses or CIDR ranges, IPv4 or IPv6, up to 50 entries. A request from any other address gets 403 `ip_not_allowed`. Leave the field empty to allow any address. ### New key preparation Right after you create or rotate a key, it may be in the Preparing state. During this time, requests get 503 `integration_initializing` with a `Retry-After` header. Retry after the specified time. Preparation takes no longer than 15 minutes. If the key isn't ready by then, its state changes to Not prepared and requests get 401: issue a new key or contact Support. ### Rotation The "Rotate" button issues a new secret with the same permissions and scope. Choose an overlap period: | Overlap | What happens to the old key | |---|---| | No overlap | Revoked immediately | | One day | Keeps working for 24 hours | | Seven days | Keeps working for 7 days | The overlap lets you update the key on all your servers with no downtime. Cursors issued to the old key keep working with the new one, as long as permissions and scope haven't changed. ### Editing and revoking - **Changes** to permissions, scope or the "Message text" checkbox take effect immediately. Cursors issued before the change return 400 `cursor_mismatch` afterward: restart the list, and for the event feed, follow the [recovery steps](https://docs.melioralab.io/en/events-polling/#vosstanovlenie). - **Revoking** stops the key immediately and permanently. You can't undo a revocation. - **Disable API** turns off access for the whole organization with one button in the same section. While access is off, all keys get 403 `api_disabled`. ### Request log The "Log" button next to a key shows the requests from the last 14 days: time, endpoint, status, error code and duration. Support can find a specific request by its `Request-Id` from the log. ### Storing your key The key gives access to your organization's data. Treat it like a password: - keep it in a secrets manager or in server environment variables, not in your repository; - call the API only from a server: the key must never reach a browser or a mobile app; - don't write the key to logs; to identify it, use its prefix and last 4 characters, which are shown in Meliora; - give each integration its own key with the minimum permissions and scope it needs; - if you suspect a leak, revoke the key immediately or rotate it with no overlap. --- ## Errors A successful response always has status 200. An error comes with a 4xx or 5xx status and an RFC 9457 body. ### Format An error is returned with the `Content-Type: application/problem+json` header and a body with eight fields: ```json { "type": "urn:meliora:problem:invalid_parameter", "title": "Invalid parameter", "status": 400, "detail": "Check the parameter value", "instance": "urn:request:0f3c7a95-2e8b-4d16-b9a4-6c5e1d8f7b20", "code": "invalid_parameter", "param": "limit", "request_id": "0f3c7a95-2e8b-4d16-b9a4-6c5e1d8f7b20" } ``` | Field | Description | |---|---| | `type` | URI of the error type: `urn:meliora:problem:` followed by the code. | | `title` | Short name in English. | | `status` | HTTP status, the same as the response status. | | `detail` | Human-readable explanation. The language depends on the `Accept-Language` header. | | `instance` | URI of the request: `urn:request:` followed by the request ID. | | `code` | Machine-readable error code. | | `param` | The parameter the error relates to, or `null`. | | `request_id` | Request ID, the same as in the `Request-Id` header. | ### Rejections before key validation Some requests are rejected before the key is validated: the per-IP rate limit is exceeded, the path is unknown, the request has a body, or the service is overloaded. These responses have the same `status`, `code`, `param` and `request_id` fields, but `type` is `about:blank`, and `title` and `detail` match `code`: ```json { "type": "about:blank", "title": "rate_limit_exceeded", "status": 429, "code": "rate_limit_exceeded", "detail": "rate_limit_exceeded", "instance": "urn:request:0f3c7a95-2e8b-4d16-b9a4-6c5e1d8f7b20", "request_id": "0f3c7a95-2e8b-4d16-b9a4-6c5e1d8f7b20", "param": null } ``` So rely on `status` and `code`, not on `type` and `title`. ### Handling errors - Branch your logic on `status` and `code`. The `detail` text may change, so don't parse it in code. - If you get an unfamiliar `code`, handle the error by its HTTP status: 4xx means a problem with the request or access, 5xx means a temporary problem on our side. - For a parameter error, `param` contains the parameter name: `limit`, `status`, `cursor`, `include_text` and so on. - An error never advances the cursor. When you retry, a request with the same cursor returns the same data. ### Explanation language By default, `detail` is in Russian. To get English text, send the `Accept-Language: en` header. The `code` and `title` fields don't depend on the language. ### Request-Id Every response, including errors, has a `Request-Id` header with the request's UUID. Log it together with the endpoint and the time. When you contact [Support](https://docs.melioralab.io/en/support/), the `Request-Id` is all we need to find the request. Never send us the key itself. ### Error codes | Status | Code | What happened | |---|---|---| | 400 | `invalid_parameter` | The parameter value has an invalid format, is out of range or is used in an invalid combination. The parameter name is in `param`. | | 400 | `unknown_parameter` | The route does not accept this parameter. An API key in the URL also causes this error. | | 400 | `cursor_mismatch` | The cursor is corrupted, was issued for a different route or filters, or the key's permissions and scope have changed. | | 401 | `invalid_api_key` | The key is missing, invalid, revoked or expired. | | 403 | `scope_required` | The key does not have permission for this read. Texts also require "Message text" to be enabled. | | 403 | `api_disabled` | API access is disabled in Meliora. | | 403 | `api_not_available` | The organization's plan does not include the API. Read access requires Pro or higher. | | 403 | `subscription_inactive` | The organization's subscription is not active. | | 403 | `module_not_available` | The outreach module is unavailable for this organization. | | 403 | `organization_suspended` | Organization access is suspended. | | 403 | `ip_not_allowed` | The request came from an IP address that is not on the key's allow list. | | 404 | `not_found` | The object was not found or is outside the key's scope. An unknown path also returns 404. | | 405 | `method_not_allowed` | Only the GET method is allowed. The public endpoint answers other methods with a 404 without a body, so this code is not seen from outside. | | 410 | `cursor_expired` | The cursor expired: 15 minutes for lists, 30 days for the event feed. | | 429 | `rate_limit_exceeded` | The request limit of the key, organization or IP address is exhausted. Retry after `Retry-After` seconds. | | 503 | `service_unavailable` | The data could not be read right now. Retry later. | | 503 | `server_busy` | The service is overloaded. Retry after `Retry-After` seconds. | | 503 | `integration_initializing` | The new key is still being prepared. Retry later. | ### What to retry | Status | Retry? | How | |---|---|---| | 400 | No | Fix the parameter named in `param`. For `cursor_mismatch`, restart the list. | | 401, 403 | No | Check the key, its permissions and your organization settings. | | 404 | No | The object doesn't exist or is outside the key's scope. | | 410 | No | The cursor expired: restart the list or [recover the feed](https://docs.melioralab.io/en/events-polling/#vosstanovlenie). | | 429 | Yes | Wait `Retry-After` seconds and add random jitter. | | 503 | Yes | Wait `Retry-After` seconds and increase the delay if the error repeats. | For more on delays between retries, see [Rate limits and retries](https://docs.melioralab.io/en/rate-limits/). ### Special cases - **An unknown path** or an object ID that isn't a valid UUID returns 404 `not_found`. Write the path exactly as in the reference: a trailing `/` or encoded characters in the path also return 404. - **Methods other than GET**, including `HEAD` and `OPTIONS`, and URLs outside `/v1/` get 404 with no body. You won't see the `method_not_allowed` code from outside. - **A body in a GET request**, a URL longer than 8 KiB or headers larger than 8 KiB return 400 `invalid_parameter` with `param` set to `body`, `uri` or `headers`. - **A repeated parameter** (`?status=new&status=replied`) and **an empty value** (`?status=`) return 400 `invalid_parameter`. - **A long-running request** is aborted after 10 seconds with 503 `service_unavailable`. --- ## Pagination Lists come back in pages. You move to the next page with a cursor, not with page numbers or offsets. ### List response Every list uses the same envelope: ```json { "object": "list", "data": [ { "object": "lead", "id": "5a7c3e92-1f4b-4d68-9c2e-7b0a6d4f8e31" } ], "has_more": true, "next_cursor": "v1.k1.Qm9hcmRfMWZ4.c3RyaWN0bHlfb3BhcXVl.dGFnX2V4YW1wbGU.bWFjX2V4YW1wbGVfdmFsdWU" } ``` | Field | Description | |---|---| | `object` | Always `list`. | | `data` | The objects on the current page. | | `has_more` | `true` if more data follows this page. | | `next_cursor` | The cursor for the next page. `null` when there are no more pages. | | `meta` | Additional details. Present on conversations and the event feed. | Lists don't return a total count (`total`) or an offset (`offset`). ### Parameters - `limit`: how many objects to return. Defaults to 50, from 1 to 100. The event feed allows up to 200. - `cursor`: the `next_cursor` value from the previous response. Record order is fixed: oldest to newest by creation time, with ties broken by `id`. There is no sort parameter. New records are added to the end of the list. ### How to page through a list 1. Send the first request with the filters you need. 2. Process `data`. 3. If `has_more` is `true`, repeat the request with the same filters and `cursor=`. 4. Stop when `has_more` is `false`. ```js async function listAll(path, params = {}) { const items = []; let cursor = null; do { const url = new URL(`https://api.melioralab.io/v1${path}`); for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); if (cursor) url.searchParams.set('cursor', cursor); const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.MELIORA_API_KEY}` }, }); if (!res.ok) throw new Error(`Meliora API: ${res.status} ${res.headers.get('Request-Id')}`); const page = await res.json(); items.push(...page.data); cursor = page.has_more ? page.next_cursor : null; } while (cursor); return items; } const leads = await listAll('/leads', { campaign_id: '4d8b2f73-6e1a-4c95-b7d0-8a3f5e2c9b14', limit: 100 }); ``` ```python import os import requests def list_all(path, params=None): session = requests.Session() session.headers["Authorization"] = f"Bearer {os.environ['MELIORA_API_KEY']}" params = dict(params or {}) items = [] while True: resp = session.get(f"https://api.melioralab.io/v1{path}", params=params, timeout=15) resp.raise_for_status() page = resp.json() items.extend(page["data"]) if not page["has_more"]: return items params["cursor"] = page["next_cursor"] leads = list_all("/leads", {"campaign_id": "4d8b2f73-6e1a-4c95-b7d0-8a3f5e2c9b14", "limit": 100}) ``` ### Cursor rules - **Cursors are opaque.** Don't parse or build them yourself. Just pass them back. - **A cursor is bound to its request.** The filters on the next request must match the first one. If the filters differ, or the key's permissions or scope have changed, you get 400 `cursor_mismatch`. - **You can change `limit`** between pages. That isn't an error. - **A list cursor lasts 15 minutes.** An expired cursor returns 410 `cursor_expired`: start again from the first page. An event feed cursor lasts 30 days. See [Polling events](https://docs.melioralab.io/en/events-polling/). - **A page can be shorter than `limit`.** A response never exceeds 1 MiB, so a long page is cut short and the remaining records come on the next page. Rely on `has_more`, not on the record count. ### Lists aren't snapshots You read a list page by page, and the data can change between requests: leads get added and statuses get updated. Records are never skipped or repeated because of shifting offsets, but a lead's status on the first page may have changed by the time you read the last one. If you need a consistent state, combine the list walk with the [event feed](https://docs.melioralab.io/en/events-polling/): events after your anchor show what changed. --- ## Rate limits and retries Rate limits are counted in weight units, so heavier requests cost more. Going over the limit returns 429. Overload or temporary unavailability returns 503. Both responses tell you when to retry. ### Request weight | Endpoint | Weight | |---|---| | `GET /v1/me` | 1 | | `GET /v1/workspaces` | 1 | | `GET /v1/campaigns` | 2 | | `GET /v1/campaigns/{id}` | 1 | | `GET /v1/leads` | 2 | | `GET /v1/leads/{id}` | 1 | | `GET /v1/leads/{id}/messages` | 3 | | `GET /v1/events` | 2, 3 with `include_text=true` | ### Limits by plan Limits apply both to each key and to the whole organization, across all of its keys combined. | Window | Pro | Premium | Enterprise | |---|---|---|---| | Per key, per second | 5 | 10 | 10 | | Per key, per minute | 300 | 600 | 600 | | Per organization, per second | 10 | 25 | 25 | | Per organization, per minute | 600 | 1500 | 1500 | | Concurrent requests per key | 5 | 5 | 5 | | Concurrent requests per organization | 10 | 10 | 10 | For example, on the Pro plan one key can make 2 lead list requests per second (weight 2, limit 5) and up to 150 such requests per minute. [`GET /v1/me`](https://docs.melioralab.io/en/reference/api-context/) returns the key's current limits in the `limits` field. The API also limits requests from a single IP address: 20 per second on average, with short bursts up to 40. If one address receives 20 responses with status 401 within 5 minutes, the address is blocked for 15 minutes, and all its requests get 429 with `Retry-After`. Check your key before you retry requests that failed with 401. Sometimes limits are temporarily reduced, for example during maintenance on our side. When that happens, `limits.mode` returns `emergency` and the limit fields show lower values. Code that reads the response headers adjusts on its own. ### Headers Successful responses and 429 responses for key and organization limits include the limit state. A 429 response for the IP address limit doesn't include these headers, only `Retry-After`. ```http RateLimit-Policy: "key_second";q=5;w=1, "key_minute";q=300;w=60, "org_second";q=10;w=1, "org_minute";q=600;w=60 RateLimit: "key_second";r=3;t=1, "key_minute";r=291;t=60, "org_second";r=8;t=1, "org_minute";r=583;t=60 RateLimit-Limit: 5 RateLimit-Remaining: 3 RateLimit-Reset: 1 ``` | Header | Value | |---|---| | `RateLimit-Policy` | The windows: name, limit `q` in weight units and window length `w` in seconds. | | `RateLimit` | For each window, the remaining amount `r` and the seconds until reset `t`. | | `RateLimit-Limit` | The limit of the window closest to running out. | | `RateLimit-Remaining` | The amount remaining in that window. | | `RateLimit-Reset` | Seconds until that window resets. | ### 429 response If a request's weight exceeds the remaining amount, or you exceed the number of concurrent requests, you get 429 `rate_limit_exceeded` with a `Retry-After` header in seconds. The request was not processed. Wait `Retry-After` seconds and add random jitter so that several processes don't retry at the same moment. ### 503 response 503 means the request wasn't processed right now. The code tells you why: | Code | Reason | `Retry-After` | |---|---|---| | `server_busy` | The service is overloaded | 1 second | | `service_unavailable` | The data couldn't be read for now | 5 seconds | | `integration_initializing` | A new key is still being prepared | 5 seconds | Retry with a growing delay: start with `Retry-After`, then double it up to 60 seconds. If you keep getting 503 for more than a few minutes, contact [support](https://docs.melioralab.io/en/support/) and include the `Request-Id`. ### Retries are safe All v1 routes only read data, so you can retry any request without side effects. An error doesn't advance the cursor: retrying with the same cursor returns the same page. ```js const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); async function meliora(path, params = {}, maxAttempts = 6) { const url = new URL(`https://api.melioralab.io/v1${path}`); for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); for (let attempt = 1; ; attempt++) { let res; try { res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.MELIORA_API_KEY}` }, signal: AbortSignal.timeout(15_000), }); } catch (networkError) { if (attempt >= maxAttempts) throw networkError; await sleep(Math.min(60, 2 ** attempt) * 1000); continue; } if (res.ok) return res.json(); const problem = await res.json().catch(() => ({})); if ((res.status === 429 || res.status === 503) && attempt < maxAttempts) { const base = Number(res.headers.get('Retry-After')) || 5; const wait = Math.min(60, base * (res.status === 503 ? 2 ** (attempt - 1) : 1)); await sleep((wait + Math.random() * Math.max(1, wait * 0.25)) * 1000); continue; } const error = new Error(`Meliora API ${res.status} ${problem.code ?? ''}`); Object.assign(error, { status: res.status, code: problem.code, requestId: res.headers.get('Request-Id') }); throw error; } } ``` ```python import os import random import time import requests session = requests.Session() session.headers["Authorization"] = f"Bearer {os.environ['MELIORA_API_KEY']}" class MelioraError(Exception): def __init__(self, status, code, request_id): super().__init__(f"Meliora API {status} {code}") self.status, self.code, self.request_id = status, code, request_id def meliora(path, params=None, max_attempts=6): for attempt in range(1, max_attempts + 1): try: resp = session.get(f"https://api.melioralab.io/v1{path}", params=params, timeout=15) except requests.RequestException: if attempt == max_attempts: raise time.sleep(min(60, 2 ** attempt)) continue if resp.ok: return resp.json() if resp.status_code in (429, 503) and attempt < max_attempts: base = int(resp.headers.get("Retry-After", "5")) wait = min(60, base * (2 ** (attempt - 1) if resp.status_code == 503 else 1)) time.sleep(wait + random.uniform(0, max(1, wait * 0.25))) continue try: code = resp.json().get("code") except ValueError: code = None raise MelioraError(resp.status_code, code, resp.headers.get("Request-Id")) raise RuntimeError("unreachable") ``` ### Staying within limits - Poll the [event feed](https://docs.melioralab.io/en/events-polling/) once a minute instead of walking all leads on a schedule. - Use `limit=100` for lists and up to 200 for the feed: fewer requests for the same amount of data. - Filter on our side: `campaign_id`, `status` and `username` cost less than exporting the whole list. - Don't run more than 5 concurrent requests per key. Extra requests get 429. - Request message text only where you need it: events with `include_text=true` and conversations have a weight of 3. --- ## Versioning The version is part of the URL: /v1. Within a version, responses change only in backward-compatible ways. Breaking changes ship in a new version at a separate URL. ### Version numbers - **The API version** is in the path: `https://api.melioralab.io/v1`. `GET /v1/me` also returns it in the `api_version` field. - **The event format version** is in the `api_version` field of every event. It is currently `2026-10-18`. It changes when the set of event fields changes. The value matches the format version of the same events in the organization's webhooks. ### Backward-compatible changes These changes can appear in v1 without a new version: - new routes; - new optional request parameters; - new fields in responses and nested objects; - new event types in the feed; - new error codes under existing HTTP statuses; - changes to the `detail` text of errors. ### Breaking changes We don't make these changes in v1: removing or renaming fields, changing a field's type, requiring a new parameter, or changing the meaning of existing values. If one of these becomes necessary, we'll release a new version at a separate URL. We'll announce it and the migration timeline in advance in the [changelog](https://docs.melioralab.io/en/changelog/). ### Building a resilient client - **Ignore unknown fields** in responses. Don't fail on them. - **Treat an unknown enum value** as `unknown`. We try not to extend v1 enums, but this code costs you nothing. - **Skip events of unknown types**, but still save the cursor of the page they came on. - **Handle an unknown error code** by its HTTP status. - **Don't parse the format of IDs** or cursors: the message `id` and `next_cursor` are opaque. ### Changelog Every API change is recorded in the [changelog](https://docs.melioralab.io/en/changelog/). The [openapi.json](https://docs.melioralab.io/en/openapi.json) specification is updated along with it. --- ## Polling events The event feed reports two facts: a lead received its first message, and a lead replied. The loop below never loses or duplicates events, even after failures and downtime. ### How the feed works `GET /v1/events` returns events in the order they enter the feed. There are two types: | Type | When it appears | |---|---| | `lead.first_message_sent` | The lead received its first message, including one sent manually. | | `lead.replied` | The lead replied. | Feed properties: - **Ordered by arrival, not by when the fact happened.** The `timestamp` field shows when the fact happened. An event with an earlier `timestamp` can arrive after its neighbor. Don't use `timestamp` as a cursor. - **At-least-once delivery.** After a failure, a page can be delivered again. The deduplication key is `event.id`: `evt_` followed by 32 hexadecimal characters. - **IDs match webhooks.** If webhooks are set up in your organization, the same fact has the same `id`. You can use the feed and webhooks together and deduplicate by `id`. - **There is always a cursor.** `next_cursor` is never `null`, even on an empty page. Every response returns a new cursor, and you need to save it. - **30-day retention.** Events older than 30 days leave the feed. A cursor is valid for 30 days after it is issued, and each request extends it. What to know about event data: - `lead.replied` arrives for every counted reply, so one lead can have several. - `data.reply.automation_stopped` is currently always `null`: the event doesn't confirm that the sequence stopped. The `data.reply.is_first_reply` field is currently not sent. - `data.first_message_sent_at` on a reply event is often `null`. For the time of the first message, rely on the `lead.first_message_sent` event or the lead record instead. - `data.lead.fields.record_id` is currently `null`, and `record_id_status` is `unknown`. To link an event to your CRM, see the [CRM mapping](https://docs.melioralab.io/en/crm-mapping/#record-id) guide. - `data.lead.username` is lowercase. ### First run You can start in one of two ways: - **From now.** A request with `start=now` returns an empty page and a cursor. The feed starts with events that appear after it. Fetch the past state by walking through [campaigns](https://docs.melioralab.io/en/reference/campaigns/) and [leads](https://docs.melioralab.io/en/reference/leads/). - **From the available history.** A request without `cursor` and without `start` begins at the earliest event the feed holds, but no earlier than 30 days ago and no earlier than when event collection was enabled for your organization. For a CRM integration, the first option is usually more convenient: get a cursor first, then reconcile the records, then read the feed from the cursor. That way, no change made during reconciliation is lost. ### Reliable loop 1. Load the saved cursor. If there is none, do a first run. 2. Request `GET /v1/events?cursor=&limit=100`. 3. In **a single transaction** in your database, apply the events on the page and save the new `next_cursor`. Skip events whose `id` you have already applied. 4. If `has_more` is `true`, request the next page right away. 5. If `has_more` is `false`, wait a minute and repeat from step 2. Why a transaction: if the process crashes between applying events and saving the cursor, it repeats the same page after a restart, and deduplication by `id` keeps any event from being applied twice. If you save the cursor before applying the events, a failure loses them. > **Warning.** Don't change filters while polling with a saved cursor. The cursor is bound to the `workspace_id`, `campaign_id`, `type` and `include_text` of the first request. A different set of filters returns 400 `cursor_mismatch`. Use a separate cursor for each set of filters. ### Errors while polling | Response | What to do | |---|---| | 429, 503 | Retry the same request with the same cursor after `Retry-After`. The cursor doesn't advance. | | Network error, timeout | Retry with the same cursor. | | 410 `cursor_expired` | The cursor is older than 30 days. Run recovery. | | 400 `cursor_mismatch` | The key's permissions or scope changed, or the filters changed. Run recovery. | | 401, 403 | The key was revoked or expired, or access was turned off. Stop polling and check the key. | ### Recovery after downtime Run recovery after a 410 or `cursor_mismatch` response, after downtime longer than 30 days, and when `meta.coverage.status` reports that data may be incomplete. 1. Record the gap on your side: the time the stream broke. 2. Get a new anchor: `GET /v1/events?start=now`. Save its cursor only after step 3. 3. Walk through current [campaigns](https://docs.melioralab.io/en/reference/campaigns/) and [leads](https://docs.melioralab.io/en/reference/leads/) and update the records in your system. The lead's `first_message_sent_at`, `first_reply_at` and `status` fields show what happened during the gap. 4. Save the anchor cursor and continue the normal loop. Events that appear during the walk-through arrive in the feed after the anchor. Merge them into your records monotonically: never move a status backward, and never replace a later time with an earlier one. ### Feed completeness Every page includes `meta`: - `meta.available_from`: the point from which the feed has events. - `meta.coverage.status`: `complete`, `partial` or `unknown`. During early access you usually get `unknown` or `partial`: feed completeness is not guaranteed. For important processes, reconcile lead records periodically, for example once a day. - `meta.coverage.gaps`: intervals during which events may not have been fully collected. After such an interval, reconcile the leads of the affected campaigns. - `meta.collection.state`: the collection state. `degraded` means the data may be incomplete. > **Note.** If the feed stays empty while messages and replies are going on in your organization, contact [Support](https://docs.melioralab.io/en/support/): during early access, we enable the event feed for each organization. ### Message text With `include_text=true`, an event includes text: in `data.first_message` for `lead.first_message_sent`, and in `data.reply` for `lead.replied`. You need the `messages:read` permission and "Message text" enabled on the key. Text is included only if it was saved and proven to belong to the conversation. Otherwise `text` is `null`, and the reason is in `text_unavailable_reason`. A request with text has a weight of 3 instead of 2. ### Node.js example Replace the storage functions with your own. `meliora()`, with retries on 429 and 503, is described in the [Rate limits and retries](https://docs.melioralab.io/en/rate-limits/#povtor-bezopasen) guide. ```js const POLL_INTERVAL_MS = 60_000; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); export async function runEventLoop(db) { let cursor = await db.loadCursor(); if (!cursor) cursor = await resync(db); for (;;) { let page; try { page = await meliora('/events', { cursor, limit: 100 }); } catch (err) { if (err.status === 410 || err.code === 'cursor_mismatch') { cursor = await resync(db); continue; } throw err; // 401 and 403: stop and check the key } await db.transaction(async (tx) => { for (const event of page.data) { if (await tx.isApplied(event.id)) continue; if (event.type === 'lead.first_message_sent' || event.type === 'lead.replied') { await applyToCrm(tx, event); } await tx.markApplied(event.id); // mark unknown types as applied too } await tx.saveCursor(page.next_cursor); }); cursor = page.next_cursor; if (!page.has_more) await sleep(POLL_INTERVAL_MS); } } async function resync(db) { await db.recordGap(new Date()); const anchor = await meliora('/events', { start: 'now' }); await reconcileLeads(db); // walk /v1/leads and update records await db.saveCursor(anchor.next_cursor); return anchor.next_cursor; } ``` ### Python example ```python import time from datetime import datetime, timezone POLL_INTERVAL = 60 def run_event_loop(db): cursor = db.load_cursor() or resync(db) while True: try: page = meliora("/events", {"cursor": cursor, "limit": 100}) except MelioraError as err: if err.status == 410 or err.code == "cursor_mismatch": cursor = resync(db) continue raise # 401 and 403: stop and check the key with db.transaction() as tx: for event in page["data"]: if tx.is_applied(event["id"]): continue if event["type"] in ("lead.first_message_sent", "lead.replied"): apply_to_crm(tx, event) tx.mark_applied(event["id"]) # mark unknown types as applied too tx.save_cursor(page["next_cursor"]) cursor = page["next_cursor"] if not page["has_more"]: time.sleep(POLL_INTERVAL) def resync(db): db.record_gap(datetime.now(timezone.utc)) anchor = meliora("/events", {"start": "now"}) reconcile_leads(db) # walk /v1/leads and update records db.save_cursor(anchor["next_cursor"]) return anchor["next_cursor"] ``` The `meliora()` function and the `MelioraError` class are in the [Rate limits and retries](https://docs.melioralab.io/en/rate-limits/#povtor-bezopasen) guide. To match an event to a CRM record, see the [CRM mapping](https://docs.melioralab.io/en/crm-mapping/) guide. ### How often to poll Once a minute is enough for most integrations. That uses 2 weight units per minute of the key's limit. You can poll less often, for example once an hour: a cursor is valid for 30 days, and the feed keeps events for 30 days. While `has_more` is `true`, fetch pages back to back without pausing. Polling more often doesn't make events appear sooner: they enter the feed as data is collected, and the length of this delay is not guaranteed. --- ## CRM mapping Reliable mapping relies on your CRM record ID, which you upload together with your contacts. The API returns it in the lead record, and events link to a lead by its id. ### The record_id field Pass the CRM record ID when you upload contacts to a campaign. Add a `record_id` column to the contacts file, and in the upload dialog choose "Custom column" for it with the name `record_id`. The value can be up to 128 characters, with no line breaks. The API returns it in `fields.record_id` on the [lead](https://docs.melioralab.io/en/reference/leads/#lead-fields), in both `GET /v1/leads` and `GET /v1/leads/{id}`. > **Warning.** Feed events currently don't include `record_id`: `data.lead.fields.record_id` is `null`, and `record_id_status` is `unknown`. Link an event by `data.lead.id`: get `record_id` from `GET /v1/leads/{id}` and store the `lead.id` and `record_id` pair on your side, so you don't have to fetch the lead for every event. ```json { "fields": { "record_id": "crm-10482", "first_name": "Anna", "company": "Example Ltd" }, "fields_truncated": false, "record_id_status": "valid" } ``` ### record_id status Before you use `record_id`, check `record_id_status`: | Value | What it means | What to do | |---|---|---| | `valid` | A single, consistent value. | Look up the CRM record by `fields.record_id`. | | `missing` | There is no value. | Match manually or by your own rules. | | `conflict` | The contact record has two different values. `fields.record_id` is `null`. | Fix the record. Don't pick a value yourself. | | `unknown` | The value's reliability is not confirmed, for example in older data. `fields.record_id` is `null`. | Check the lead with `GET /v1/leads/{id}` or manually. | `valid` means the value is correct in Meliora's data. Whether that record exists in your CRM is for your integration to check. On events, `record_id_status` is currently always `unknown`. See the warning above. ### Why not the username A Telegram username (`username`) doesn't work as a mapping key: - a person can change or remove their username, and then `username` becomes different or `null`; - one contact in two campaigns produces **two leads** with different `id` values and the same username; - different people can have the same username at different times. The `GET /v1/leads?username=...` filter is handy for searching and checking, but not for automatic matching. The `external_id` field is always `null` in v1: it is reserved for future versions. ### Which fields are returned `fields` contains only the keys listed here, if the contact has them: `record_id`, `first_name`, `last_name`, `company`, `position`, `website`, `source`, `utm_source`, `utm_medium`, `utm_campaign`. The `record_id` key is always present. The others appear only when they have a value. Not returned: phone, email, other custom columns and service data. The "Full name" column doesn't go into `fields` either: for the API, upload the first and last name as separate "Name" and "Last name" columns. Values longer than 1024 characters are truncated, and the whole object is at most 8 KiB. If anything is truncated or omitted because of size, `fields_truncated` is `true`. ### CRM statuses An example mapping for a CRM pipeline: | Event or field | CRM status | |---|---| | `lead.first_message_sent` or a non-empty `first_message_sent_at` | In progress | | `lead.replied` or a non-empty `first_reply_at` | Connected | `lead.replied` arrives for every counted reply, so one lead can have several. Move the status **forward only**: "Connected" must never go back to "In progress", even if the first message event arrives after the reply. Merge contact and reply times the same way: keep the earliest known time, and don't replace it with a later one. ```js const RANK = { IN_PROGRESS: 1, CONNECTED: 2 }; // Get record_id from the lead record and remember it: events don't include it. async function crmIdFor(tx, leadId) { const known = await tx.getCrmId(leadId); if (known !== undefined) return known; const lead = await meliora(`/leads/${leadId}`); const crmId = lead.record_id_status === 'valid' ? lead.fields.record_id : null; await tx.rememberCrmId(leadId, crmId); return crmId; } async function applyToCrm(tx, event) { const leadId = event.data.lead.id; const crmId = await crmIdFor(tx, leadId); if (!crmId) { await tx.queueForReview(event.id, leadId); // manual review return; } const target = event.type === 'lead.replied' ? 'CONNECTED' : 'IN_PROGRESS'; const record = await tx.crm.get(crmId); if (!record) return tx.queueForReview(event.id, leadId); if ((RANK[record.status] ?? 0) < RANK[target]) await tx.crm.setStatus(crmId, target); if (event.type === 'lead.replied') { await tx.crm.addNote(crmId, `Reply in Telegram, ${event.data.reply.replied_at}`); } } ``` ```python RANK = {"IN_PROGRESS": 1, "CONNECTED": 2} def crm_id_for(tx, lead_id): # Get record_id from the lead record and remember it: events don't include it. known = tx.get_crm_id(lead_id) if known is not None: return known or None lead = meliora(f"/leads/{lead_id}") crm_id = lead["fields"]["record_id"] if lead["record_id_status"] == "valid" else None tx.remember_crm_id(lead_id, crm_id or "") return crm_id def apply_to_crm(tx, event): lead_id = event["data"]["lead"]["id"] crm_id = crm_id_for(tx, lead_id) if not crm_id: tx.queue_for_review(event["id"], lead_id) # manual review return target = "CONNECTED" if event["type"] == "lead.replied" else "IN_PROGRESS" record = tx.crm.get(crm_id) if record is None: tx.queue_for_review(event["id"], lead_id) return if RANK.get(record.status, 0) < RANK[target]: tx.crm.set_status(crm_id, target) if event["type"] == "lead.replied": tx.crm.add_note(crm_id, f"Reply in Telegram, {event['data']['reply']['replied_at']}") ``` ### Conversations in your CRM You can get message text in two ways: - from events with `include_text=true`: the text of the first message and of the reply; - from the [conversation](https://docs.melioralab.io/en/reference/messages/) endpoint `GET /v1/leads/{id}/messages`: all saved messages with the lead. What to know about the conversation archive: - The archive can be incomplete: conversations from before the API was connected are only partly saved, and messages sent from a phone or directly in Telegram are not included. Currently `meta.completeness` always reports the archive as incomplete. - Edits and deletions in Telegram are not tracked: the copy keeps the original text. - Attachments are passed only as a type in `media_kind`, without files. If an outgoing message had an attachment, its text may be missing from the copy. - A message can appear in the archive later, and with an earlier time than messages you have already read. So read a lead's entire conversation, all pages, and merge it with what you have already saved by `id`. - When `text_unavailable: true`, no text is returned, and the reason is in `text_unavailable_reason`. Don't create empty notes in your CRM for such messages. Read conversations only for leads that had an event or a status change, not for every lead: the request has a weight of 3. ### Initial load To connect campaigns that are already running: 1. Get a feed cursor: `GET /v1/events?start=now`. 2. Walk through `GET /v1/leads` for the campaigns you need, match leads to CRM records by `record_id`, and store the `lead.id` and `record_id` pairs for event processing. 3. Carry statuses over based on `first_message_sent_at` and `first_reply_at`. 4. Start polling the feed from the cursor you got in step 1. For details of the loop, see the [Polling events](https://docs.melioralab.io/en/events-polling/) guide. --- ## Lead and delivery statuses A lead has four state fields. status tells you where the lead is in the campaign. delivery_state and automation_state tell you what is happening with sending. reason explains why the sequence is waiting. ### How to read the states - `status`: the outcome of the lead's participation in the campaign. It changes rarely: messaged, replied, opted out. - `delivery_state`: what is happening to the next unsent message in the sequence. If there is none, it describes the last sent message. - `automation_state`: whether the sequence is running for this lead. - `reason`: why the sequence is waiting and, when known, when the next attempt is expected. If there is not enough data, or sources contradict each other, the API returns `unknown` instead of guessing. This is a normal state, not an error. ### Lead status | `status` | Meaning | |---|---| | `new` | The lead was added to the campaign. No messages yet. | | `contacted` | The first message was sent. No reply yet. | | `completed` | The sequence finished without a reply. | | `replied` | The lead replied. | | `blocked` | This contact can't be messaged, or we chose not to. For example, the recipient doesn't exist, doesn't accept messages, was already messaged, or is on the stop list. | | `failed` | Sending failed. | | `suspended` | Work with the lead is temporarily paused, for example while the sender account is resting. | | `deleted` | The lead was removed from the campaign. | | `opted_out` | The lead asked not to be messaged again. | | `unknown` | The status could not be determined. | ### Delivery state | `delivery_state` | Meaning | Now | |---|---|---| | `queued` | The message is queued for sending. | Returned | | `held` | Sending is held until someone makes a decision in Meliora. | Returned | | `sent` | Sending is confirmed. No further messages are expected, or they are not scheduled yet. | Returned | | `unknown` | The outcome is unknown. For example, the send attempt was not confirmed or there is not enough data. | Returned | | `waiting_queue` | The next message is expected but not queued yet. | Not returned yet | | `failed` | Sending failed permanently and no retry is scheduled. | Not returned yet | `unknown` does not mean failure. Don't resend a message to the lead on your own just because you got `unknown`: the message may have been delivered. ### Sequence state | `automation_state` | Meaning | Now | |---|---|---| | `running` | The sequence is running: the campaign is active and sending is allowed. The sending window, the daily quota and Telegram pauses only delay the next message. | Returned | | `paused` | The campaign is paused, not launched yet, or stopped because of the subscription. | Returned | | `held` | The sequence is held until someone makes a decision in Meliora. | Returned | | `stopped` | The sequence is stopped: the lead replied, opted out or was stopped, or the campaign is stopped or archived. | Returned | | `unknown` | The state could not be determined. | Returned | | `completed` | All steps of the sequence are done. | Not returned yet | For leads with no message currently scheduled, such as leads who replied or finished the sequence, `automation_state` often comes back as `unknown` for now. Check `status` for the outcome. > **Note.** Values marked "Not returned yet" are in the schema but are not returned during early access: those leads come back with `unknown`. Filtering `GET /v1/leads?delivery_state=failed` or `waiting_queue` currently returns an empty list. We will announce in the [Changelog](https://docs.melioralab.io/en/changelog/) when these values start being returned. ### Wait reason Both leads and campaigns have a `reason` object: ```json { "reason_code": "telegram_wait", "reason_observed_at": "2026-10-27T08:41:07.000Z", "next_attempt_at": "2026-10-27T09:26:00.000Z", "requires_customer_action": false } ``` | `reason_code` | Meaning | Now | |---|---|---| | `none` | The sequence is not waiting for anything. | Returned | | `daily_limit` | The sender account has used up its daily quota. | Returned | | `queue_pending` | The message is waiting its turn to be sent. | Returned | | `telegram_wait` | Telegram asked the account to wait before sending again. | Returned | | `account_unavailable` | The sender account is unavailable. | Returned | | `account_restricted` | Telegram restricted the sender account. | Returned | | `campaign_paused` | The campaign is paused or not launched yet. | Returned | | `subscription_inactive` | The subscription is inactive or credits ran out. | Returned | | `manual_hold` | Sending is held until someone makes a decision in Meliora. | Returned | | `automation_stopped` | The sequence is stopped. | Returned | | `unknown` | The reason is unknown. | Returned | | `schedule_window` | Waiting for the sending window set in the campaign schedule. | Not returned yet | | `service_unavailable` | The reason could not be read right now. | Not returned yet | - `next_attempt_at` is set only when the time is known. Otherwise it is `null`. - `requires_customer_action: true` means the sequence won't continue until someone acts in Meliora, for example launches the campaign or renews the subscription. - API rate limits (a 429 response) don't affect `reason`. They limit your requests, not message sending. ### Campaign status | `status` | Meaning | |---|---| | `draft` | Draft. | | `scheduled` | Launch is scheduled. | | `launching` | The campaign is launching. | | `active` | The campaign is running. | | `paused` | Paused. | | `paused_no_credits` | Paused: out of credits. | | `stopped` | Stopped. | | `completed` | Completed. | | `archived` | Archived. | | `failed` | Launch or run error. | | `unknown` | The status could not be determined. | ### Timestamps - `first_message_sent_at`: when the first message was sent. If sending is not confirmed, the field is `null`, even when the status is `contacted`. - `first_reply_at`: when the first reply arrived. - `created_at`: when the lead was added to the campaign. It never changes. - All timestamps are in UTC with milliseconds, for example `2026-10-26T07:30:00.000Z`. - `observed_at`: when the server read the data for this response. It is not the time the lead last changed. --- ## AI agents and MCP The docs are built for AI assistants. Every page has a Markdown version, the full docs are available as a single file, and a ready-made prompt gives your agent the integration rules. AI agent prompt: https://docs.melioralab.io/en/agent-prompt.txt ### Files for agents | File | Contents | |---|---| | [llms.txt](https://docs.melioralab.io/en/llms.txt) | Table of contents: links to the Markdown of every page, with a short description. | | [llms-full.txt](https://docs.melioralab.io/en/llms-full.txt) | The full docs in one Markdown file: guides and reference. | | [openapi.json](https://docs.melioralab.io/en/openapi.json) | OpenAPI 3.1 spec: routes, parameters, response schemas and examples. | | `/en/md/.md` | Markdown of a single page, for example [/en/md/events-polling.md](https://docs.melioralab.io/en/md/events-polling.md) or [/en/md/reference/leads.md](https://docs.melioralab.io/en/md/reference/leads.md). | | [agent-prompt.txt](https://docs.melioralab.io/en/agent-prompt.txt) | The AI agent prompt as plain text. | Every guide and reference page has a "Copy page" menu to the right of the title. Use it to copy the page's Markdown, open the Markdown on its own, or open the page in ChatGPT or Claude with a question already filled in. ### Connect the docs #### Claude, ChatGPT and other chat assistants Paste the [prompt](https://docs.melioralab.io/en/agent-prompt.txt) into the chat using the "Copy prompt" button. If the assistant has internet access, it reads `llms-full.txt` and `openapi.json` on its own. If it doesn't, attach the [llms-full.txt](https://docs.melioralab.io/en/llms-full.txt) file to your message. To ask about a single page, open the "Copy page" menu and choose "Open in ChatGPT" or "Open in Claude". #### Cursor Add the docs as a source: in Cursor's docs settings, enter `https://docs.melioralab.io/en/llms-full.txt`, then reference it in chat with `@`. You can save the prompt in your project rules, in the `.cursor/rules` folder. #### Claude Code and other terminal agents Save the prompt in your project's instructions file, such as `CLAUDE.md` or `AGENTS.md`, or pass it at the start of the session. An agent with network access reads the docs from the links in the prompt. Without network access, download the files into your project: ```bash curl -o docs/meliora-llms-full.txt https://docs.melioralab.io/en/llms-full.txt curl -o docs/meliora-openapi.json https://docs.melioralab.io/en/openapi.json ``` ### Your key and the agent The agent needs a key only if it makes API requests itself. Reading the docs and writing code doesn't require a key. > **Warning.** Don't paste your key into a chat with a model, and don't put it in the prompt. The model provider may store your conversation, and the key gives access to your organization's data. If the agent needs to make requests: 1. Create a **separate key** for it, not the one your integration uses. 2. Grant **minimal permissions**. Campaigns and Leads are usually enough. Enable Message text only if you really need it. 3. Limit the **scope** to one test campaign. 4. Set an **expiration**, for example one week, and an IP allowlist if you can. 5. Pass the key through the **environment variable** `MELIORA_API_KEY` in the environment where the agent runs code. The prompt tells the agent to read the key from there and never print it. 6. **Revoke the key** when the work is done. For details on permissions, scope and revoking keys, see [Authentication and keys](https://docs.melioralab.io/en/authentication/). ### MCP server Meliora doesn't have its own MCP server yet. It is planned and listed on the [What's next](https://docs.melioralab.io/en/roadmap/) page. Agents that support MCP can work with the docs through their own web page or file reading tool: give them links to `llms-full.txt` and `openapi.json`, or the prompt. Third-party MCP servers that present themselves as a Meliora server are not affiliated with us. Don't give them your keys. --- ## Changelog Every API change is listed here: new routes, fields, values and fixes. ### 410 error on lists The `detail` text of the `cursor_expired` error now covers both lists and the event feed: for a list, start again without `cursor`; for the feed, run recovery. The new text reads: "The cursor expired. Restart a list without cursor. For the event feed, record the gap, request start=now, reconcile campaigns and leads, then resume the feed". The code and status are unchanged. ### v1, early access The first version of the public API. Read-only access with an organization API key. - Routes: `GET /v1/me`, `GET /v1/workspaces`, `GET /v1/campaigns`, `GET /v1/campaigns/{id}`, `GET /v1/leads`, `GET /v1/leads/{id}`, `GET /v1/leads/{id}/messages`, `GET /v1/events`. - Key permissions: `campaigns:read`, `leads:read`, `messages:read`, `events:read`, plus a separate "Message text" checkbox. - Key scope by workspace and campaign, expiration date, IP allowlist, rotation with an overlap period, revocation and a request log. - An event feed with `lead.first_message_sent` and `lead.replied`, a 30-day cursor, and the `start=now` and `include_text` parameters. - Errors in RFC 9457 format with a `Request-Id` header, and rate limits in weight units with `RateLimit` headers. - An OpenAPI 3.1 specification. - Materials for AI agents: `llms.txt`, `llms-full.txt`, Markdown for every page, an agent prompt and a "Copy page" menu. Known early access limitations: - `delivery_state` with the values `waiting_queue` and `failed`, and `automation_state` with the value `completed`, are not returned yet. - Completeness of the conversation archive and the event feed is not guaranteed: check `meta.completeness` and `meta.coverage`. - The API can't write data yet. Plans are described in [What's next](https://docs.melioralab.io/en/roadmap/). --- ## What's next The first version of the API only reads data. Below is what we're working on next. There are no dates, and the scope and order may change. ### Writing data - Adding contacts to a campaign through the API, with your system's identifier in the `external_id` field. - Stopping the sequence for an individual lead. - Campaigns whose first message is provided through the API. ### Events - New event types: each sequence message sent, a manual message from a manager, a lead stopped. ### Later - Replying to leads through the API. - Managing campaigns and the stop list. - A test environment for debugging integrations. - Our own MCP server, so AI agents can work with the Meliora API directly. Until then, agents can use the [agent files and prompt](https://docs.melioralab.io/en/ai-agents/). If you need something that isn't on this list, or the order matters to you, contact [support](https://docs.melioralab.io/en/support/). Requests from early access customers shape the plan. Until the API supports writing, you upload contacts and launch campaigns in your Meliora organization. --- ## Support Message us on Telegram. We help with API questions, keys and early access setup. ### Contact Telegram: [@meliora_support](https://t.me/meliora_support). ### What to include To help us find the cause faster, include: - the `Request-Id` from the response header or the `request_id` field of the error; - the request time with its time zone; - the route and parameters, without the key; - the status and `code` from the response; - the key name, or its prefix and last 4 characters as shown in Meliora. > **Warning.** Never send a full API key, whether to support or in shared chats. If a key ends up in a conversation, revoke it in Meliora and create a new one. ### FAQ #### There's no API section in Settings During early access, we issue keys on request, so the section may be hidden in your Meliora settings. Message us: we'll enable your organization and issue a key with the permissions you need. #### I got 403 api_not_available Read access through the API is available on the Pro plan and above. Change your plan in Meliora or message us. #### The event feed is empty Check that the key has the "Events" permission and that you didn't start the feed with `start=now` before any events occurred. If messages are being sent and replies are coming in but the feed stays empty, message us: during early access, we enable the event feed for each organization ourselves. #### Message text is missing The key needs the "Conversations" permission and the "Message text" checkbox turned on. If text is unavailable for a specific message, the reason is in `text_unavailable_reason`. --- ## API reference Every route, field and response example of the Meliora API v1. The Meliora API v1 returns your organization's data as JSON. Every route uses the GET method and only reads data. This reference is generated from the [openapi.json](https://docs.melioralab.io/en/openapi.json) specification. ### Base URL All paths in this reference are relative to `https://api.melioralab.io`. Send requests over HTTPS from your server. ### Authentication Pass your organization's API key in the `Authorization: Bearer mel_live_...` header. Key permissions and scope are described in [Authentication and keys](https://docs.melioralab.io/en/authentication/). ### Data format - JSON in UTF-8, with field names in `snake_case`. - IDs are UUIDs. Message IDs and cursors are opaque strings. - Timestamps are ISO 8601 in UTC with milliseconds and a `Z` suffix: `2026-10-26T07:30:00.000Z`. - Every object has an `object` field with its type. - An unknown value comes back as `unknown` or `null`. The field is never omitted. ### Routes | Endpoint | Returns | Permission | |---|---|---| | [`GET /v1/me`](https://docs.melioralab.io/en/reference/api-context/#getMe) | Retrieve API key details | any | | [`GET /v1/workspaces`](https://docs.melioralab.io/en/reference/workspaces/#listWorkspaces) | List workspaces | any | | [`GET /v1/campaigns`](https://docs.melioralab.io/en/reference/campaigns/#listCampaigns) | List campaigns | `campaigns:read` | | [`GET /v1/campaigns/{id}`](https://docs.melioralab.io/en/reference/campaigns/#getCampaign) | Retrieve a campaign | `campaigns:read` | | [`GET /v1/leads`](https://docs.melioralab.io/en/reference/leads/#listLeads) | List leads | `leads:read` | | [`GET /v1/leads/{id}`](https://docs.melioralab.io/en/reference/leads/#getLead) | Retrieve a lead | `leads:read` | | [`GET /v1/leads/{id}/messages`](https://docs.melioralab.io/en/reference/messages/#listLeadMessages) | List lead conversation | `messages:read` | | [`GET /v1/events`](https://docs.melioralab.io/en/reference/events/#listEvents) | List events | `events:read` | ### Response headers | Header | Description | |---|---| | `Request-Id` | The request UUID. Present on every response, including errors. | | `Cache-Control` | Always `no-store`: responses are not cached. | | `RateLimit-Policy`, `RateLimit` | Limit windows and remaining amounts. See [Rate limits and retries](https://docs.melioralab.io/en/rate-limits/#zagolovki). | | `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset` | The limit, remaining amount and time until reset for the window closest to running out. | | `Retry-After` | Seconds to wait before retrying. Only on 429 and 503 responses. | ### Lists and errors Lists come back in pages with a cursor. See [Pagination](https://docs.melioralab.io/en/pagination/). Errors use the `application/problem+json` format. The error object is described below, and the codes are listed in [Errors](https://docs.melioralab.io/en/errors/). ### The Problem object An error in RFC 9457 format (`application/problem+json`). | Field | Type | Description | |---|---|---| | `type` | string | Error type URI: `urn:meliora:problem:`. | | `title` | string | Short error title in English. | | `status` | integer | HTTP status. | | `detail` | string | Explanation in Russian or English, based on the `Accept-Language` header. Do not parse this text in code. | | `instance` | string | Request URI: `urn:request:`. | | `code` | string | Machine-readable error code. Handle errors by `status` and `code`. | | `param` | string or null | Parameter the error refers to, or `null`. | | `request_id` | string (uuid) | Request ID, the same as in the `Request-Id` header. | ```json { "type": "urn:meliora:problem:invalid_parameter", "title": "Invalid parameter", "status": 400, "detail": "Check the parameter value", "instance": "urn:request:0f3c7a95-2e8b-4d16-b9a4-6c5e1d8f7b20", "code": "invalid_parameter", "param": "limit", "request_id": "0f3c7a95-2e8b-4d16-b9a4-6c5e1d8f7b20" } ``` --- ## Key context Which key is making the request, what it can see and which limits apply to it right now. Base URL: https://api.melioralab.io/v1. Pass the key in the `Authorization: Bearer mel_live_...` header. Endpoints: - `GET /v1/me`: Retrieve API key details ### The ApiContext object Details of the key used for the request: organization, permissions, scope, limits and collection status. | Field | Type | Description | |---|---|---| | `object` | string | Object type, always `api_context`. | | `key_id` | string (uuid) | Key ID. The key secret is never returned in responses. | | `organization` | object | The organization on whose behalf the key acts. | | `organization.id` | string (uuid) | Organization ID. | | `organization.name` | string | Organization name. | | `scopes` | array of string | Read permissions granted to the key. | | `scope` | object | Key scope: which workspaces and campaigns it can see. | | `scope.workspace_mode` | string | `all`: all workspaces of the organization; `selected`: only the listed ones. Values: `all`, `selected`. | | `scope.workspace_ids` | array of string | Selected workspaces. An empty array when `workspace_mode=all`. | | `scope.workspace_include_future` | boolean | Whether workspaces created after the key was issued are included in the scope. | | `scope.campaign_mode` | string | `all`: all campaigns in the selected workspaces; `selected`: only the listed ones. Values: `all`, `selected`. | | `scope.campaign_ids` | array of string | Selected campaigns. An empty array when `campaign_mode=all`. | | `scope.campaign_include_future` | boolean | Whether campaigns created after the key was issued are included in the scope. | | `scope.scope_version` | integer | Scope version number. Increases with every change to the key's scope or permissions. | | `api_version` | string | API version, always `v1`. | | `expires_at` | string (date-time) or null | When the key expires. `null` if no expiry is set. | | `limits` | object | Rate limits currently applied to the key. | | `limits.key_second` | integer | Weight units per second per key. | | `limits.key_minute` | integer | Weight units per minute per key. | | `limits.org_second` | integer | Weight units per second per organization, across all keys combined. | | `limits.org_minute` | integer | Weight units per minute per organization, across all keys combined. | | `limits.key_concurrent` | integer | Concurrent requests per key. | | `limits.org_concurrent` | integer | Concurrent requests per organization. | | `limits.max_response_bytes` | string | Maximum response size in bytes (1 MiB). Long pages are shortened, no data is lost. Always `1048576`. | | `limits.mode` | string | `normal`: normal mode; `emergency`: temporarily reduced limits. Values: `normal`, `emergency`. | | `collection` | object | Status of saved conversation collection for the API. | | `collection.state` | string | `running`: collection is active; `paused`: paused in Meliora; `preparing`: preparing after being enabled; `degraded`: data may be incomplete. Values: `running`, `paused`, `preparing`, `degraded`. | | `collection.enabled_at` | string (date-time) or null | When collection was enabled. `null` if it has not been enabled yet. | | `collection.pause_pending` | boolean | A pause or resume was requested in Meliora but has not been applied yet. | Example: ```json { "object": "api_context", "key_id": "9b1e7c42-5a6d-4f83-9e0b-2d7c4a8f1e65", "organization": { "id": "2c8f4a61-7d3e-4b9a-8f21-6a0d5e9c3b17", "name": "Example" }, "scopes": [ "campaigns:read", "events:read", "leads:read", "messages:read" ], "scope": { "workspace_mode": "all", "workspace_ids": [], "workspace_include_future": true, "campaign_mode": "all", "campaign_ids": [], "campaign_include_future": true, "scope_version": 1 }, "api_version": "v1", "expires_at": null, "limits": { "key_second": 5, "key_minute": 300, "org_second": 10, "org_minute": 600, "key_concurrent": 5, "org_concurrent": 10, "max_response_bytes": 1048576, "mode": "normal" }, "collection": { "state": "running", "enabled_at": "2026-10-20T08:00:00.000Z", "pause_pending": false } } ``` ### Retrieve API key details `GET /v1/me` Returns the organization, permissions, scope and limits of the key. Use it as the first request to check the connection. Available to a key with any set of permissions. Permission: any read permission. Request weight: 1. #### Parameters No parameters. #### Response Returns a `ApiContext` object. #### Endpoint-specific errors - `scope_required` (403): The key does not have permission for this read. Texts also require "Message text" to be enabled. - `not_found` (404): The object was not found or is outside the key's scope. An unknown path also returns 404. Common errors: https://docs.melioralab.io/en/md/errors.md #### Example request ```bash curl https://api.melioralab.io/v1/me \ -H "Authorization: Bearer $MELIORA_API_KEY" ``` #### Example response ```json { "object": "api_context", "key_id": "9b1e7c42-5a6d-4f83-9e0b-2d7c4a8f1e65", "organization": { "id": "2c8f4a61-7d3e-4b9a-8f21-6a0d5e9c3b17", "name": "Example" }, "scopes": [ "campaigns:read", "events:read", "leads:read", "messages:read" ], "scope": { "workspace_mode": "all", "workspace_ids": [], "workspace_include_future": true, "campaign_mode": "all", "campaign_ids": [], "campaign_include_future": true, "scope_version": 1 }, "api_version": "v1", "expires_at": null, "limits": { "key_second": 5, "key_minute": 300, "org_second": 10, "org_minute": 600, "key_concurrent": 5, "org_concurrent": 10, "max_response_bytes": 1048576, "mode": "normal" }, "collection": { "state": "running", "enabled_at": "2026-10-20T08:00:00.000Z", "pause_pending": false } } ``` --- ## Workspaces Workspaces are separate areas of your organization. Each has its own campaigns, accounts and contacts. Base URL: https://api.melioralab.io/v1. Pass the key in the `Authorization: Bearer mel_live_...` header. Endpoints: - `GET /v1/workspaces`: List workspaces ### The Workspace object Workspace: a separate area of the organization with its own campaigns. | Field | Type | Description | |---|---|---| | `object` | string | Object type, always `workspace`. | | `id` | string (uuid) | Workspace ID. | | `name` | string | Workspace name. | | `created_at` | string (date-time) or null | When the workspace was created. Can be `null` for older workspaces. | Example: ```json { "object": "workspace", "id": "3f6a9d21-8b4c-4e7f-a102-5c9e8d7b6a43", "name": "Main workspace", "created_at": "2026-09-01T12:00:00.000Z" } ``` ### List workspaces `GET /v1/workspaces` Workspaces in the key's scope, oldest first. Available to a key with any set of permissions. Permission: any read permission. Request weight: 1. #### Parameters | Parameter | In | Type | Required | Description | |---|---|---|---|---| | `limit` | query | integer | no | Number of objects to return per page. From 1 to 100. Defaults to `50`. | | `cursor` | query | string | no | The `next_cursor` value from the previous response. Other filters must match the first request. | #### Response A list: `object`, `data`, `has_more`, `next_cursor`. Pagination: https://docs.melioralab.io/en/md/pagination.md #### Endpoint-specific errors - `cursor_mismatch` (400): The cursor is corrupted, was issued for a different route or filters, or the key's permissions and scope have changed. - `scope_required` (403): The key does not have permission for this read. Texts also require "Message text" to be enabled. - `not_found` (404): The object was not found or is outside the key's scope. An unknown path also returns 404. - `cursor_expired` (410): The cursor expired: 15 minutes for lists, 30 days for the event feed. Common errors: https://docs.melioralab.io/en/md/errors.md #### Example request ```bash curl https://api.melioralab.io/v1/workspaces \ -H "Authorization: Bearer $MELIORA_API_KEY" ``` #### Example response ```json { "object": "list", "data": [ { "object": "workspace", "id": "3f6a9d21-8b4c-4e7f-a102-5c9e8d7b6a43", "name": "Main workspace", "created_at": "2026-09-01T12:00:00.000Z" }, { "object": "workspace", "id": "7a2c5e81-3d9f-4b6a-8c40-1e5f7a9b2d68", "name": "Test outreach", "created_at": "2026-10-02T09:30:00.000Z" } ], "has_more": false, "next_cursor": null } ``` --- ## Campaigns A campaign is a message sequence that Telegram accounts send to contacts on a schedule. Base URL: https://api.melioralab.io/v1. Pass the key in the `Authorization: Bearer mel_live_...` header. Endpoints: - `GET /v1/campaigns`: List campaigns - `GET /v1/campaigns/{id}`: Retrieve a campaign ### The Campaign object Campaign: a sequence of messages that Telegram accounts send to contacts. | Field | Type | Description | |---|---|---| | `object` | string | Object type, always `campaign`. | | `id` | string (uuid) | Campaign ID. | | `workspace_id` | string (uuid) | Workspace the campaign belongs to. | | `name` | string | Campaign name. | | `status` | string | Campaign status. An unknown value is returned as `unknown`. Values: `draft`, `scheduled`, `active`, `paused`, `stopped`, `completed`, `archived`, `launching`, `failed`, `paused_no_credits`, `unknown`. | | `first_message_mode` | string | Where the first message comes from. Always `template` in v1: from the campaign template. | | `accepts_api_leads` | boolean | Whether the campaign accepts contacts through the API. Always `false` in v1. | | `reason` | object | Why the campaign is not sending messages right now, if known. | | `reason.reason_code` | string | Reason code. Values: `none`, `schedule_window`, `daily_limit`, `queue_pending`, `telegram_wait`, `account_unavailable`, `account_restricted`, `campaign_paused`, `subscription_inactive`, `manual_hold`, `automation_stopped`, `service_unavailable`, `unknown`. | | `reason.reason_observed_at` | string (date-time) or null | When the reason was recorded. `null` if unknown. | | `reason.next_attempt_at` | string (date-time) or null | When the next attempt is expected. `null` if the time is unknown. | | `reason.requires_customer_action` | boolean or null | Whether someone needs to act in Meliora for sending to resume. `null` if unknown. | | `created_at` | string (date-time) | When the campaign was created. | | `observed_at` | string (date-time) | Time the server read the data. | Example: ```json { "object": "campaign", "id": "4d8b2f73-6e1a-4c95-b7d0-8a3f5e2c9b14", "workspace_id": "3f6a9d21-8b4c-4e7f-a102-5c9e8d7b6a43", "name": "Example campaign", "status": "active", "first_message_mode": "template", "accepts_api_leads": false, "reason": { "reason_code": "none", "reason_observed_at": null, "next_attempt_at": null, "requires_customer_action": null }, "created_at": "2026-10-20T08:00:00.000Z", "observed_at": "2026-10-27T09:15:42.000Z" } ``` ### List campaigns `GET /v1/campaigns` Campaigns in the key's scope, oldest first by `created_at`. Permission: `campaigns:read`. Request weight: 2. #### Parameters | Parameter | In | Type | Required | Description | |---|---|---|---|---| | `limit` | query | integer | no | Number of objects to return per page. From 1 to 100. Defaults to `50`. | | `cursor` | query | string | no | The `next_cursor` value from the previous response. Other filters must match the first request. | | `workspace_id` | query | string (uuid) | no | Only objects in this workspace. | | `status` | query | string | no | Only campaigns with this status. Values: `draft`, `scheduled`, `active`, `paused`, `stopped`, `completed`, `archived`, `launching`, `failed`, `paused_no_credits`, `unknown`. | #### Response A list: `object`, `data`, `has_more`, `next_cursor`. Pagination: https://docs.melioralab.io/en/md/pagination.md #### Endpoint-specific errors - `cursor_mismatch` (400): The cursor is corrupted, was issued for a different route or filters, or the key's permissions and scope have changed. - `scope_required` (403): The key does not have permission for this read. Texts also require "Message text" to be enabled. - `not_found` (404): The object was not found or is outside the key's scope. An unknown path also returns 404. - `cursor_expired` (410): The cursor expired: 15 minutes for lists, 30 days for the event feed. Common errors: https://docs.melioralab.io/en/md/errors.md #### Example request ```bash curl -G https://api.melioralab.io/v1/campaigns \ -H "Authorization: Bearer $MELIORA_API_KEY" \ -d workspace_id=3f6a9d21-8b4c-4e7f-a102-5c9e8d7b6a43 \ -d status=active ``` #### Example response ```json { "object": "list", "data": [ { "object": "campaign", "id": "4d8b2f73-6e1a-4c95-b7d0-8a3f5e2c9b14", "workspace_id": "3f6a9d21-8b4c-4e7f-a102-5c9e8d7b6a43", "name": "Example campaign", "status": "active", "first_message_mode": "template", "accepts_api_leads": false, "reason": { "reason_code": "none", "reason_observed_at": null, "next_attempt_at": null, "requires_customer_action": null }, "created_at": "2026-10-20T08:00:00.000Z", "observed_at": "2026-10-27T09:15:42.000Z" }, { "object": "campaign", "id": "6e3a9c15-2b7d-4f80-9a64-3c1d8e5f7b29", "workspace_id": "3f6a9d21-8b4c-4e7f-a102-5c9e8d7b6a43", "name": "Follow-up", "status": "paused", "first_message_mode": "template", "accepts_api_leads": false, "reason": { "reason_code": "campaign_paused", "reason_observed_at": "2026-10-25T16:40:00.000Z", "next_attempt_at": null, "requires_customer_action": true }, "created_at": "2026-10-22T11:15:00.000Z", "observed_at": "2026-10-27T09:15:42.000Z" } ], "has_more": true, "next_cursor": "v1.k1.Qm9hcmRfMWZ4.c3RyaWN0bHlfb3BhcXVl.dGFnX2V4YW1wbGU.bWFjX2V4YW1wbGVfdmFsdWU" } ``` ### Retrieve a campaign `GET /v1/campaigns/{id}` A single campaign by ID. A campaign outside the key's scope returns 404, as if it did not exist. Permission: `campaigns:read`. Request weight: 1. #### Parameters | Parameter | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | string (uuid) | yes | Campaign ID. | #### Response Returns a `Campaign` object. #### Endpoint-specific errors - `scope_required` (403): The key does not have permission for this read. Texts also require "Message text" to be enabled. - `not_found` (404): The object was not found or is outside the key's scope. An unknown path also returns 404. Common errors: https://docs.melioralab.io/en/md/errors.md #### Example request ```bash curl https://api.melioralab.io/v1/campaigns/4d8b2f73-6e1a-4c95-b7d0-8a3f5e2c9b14 \ -H "Authorization: Bearer $MELIORA_API_KEY" ``` #### Example response ```json { "object": "campaign", "id": "4d8b2f73-6e1a-4c95-b7d0-8a3f5e2c9b14", "workspace_id": "3f6a9d21-8b4c-4e7f-a102-5c9e8d7b6a43", "name": "Example campaign", "status": "active", "first_message_mode": "template", "accepts_api_leads": false, "reason": { "reason_code": "none", "reason_observed_at": null, "next_attempt_at": null, "requires_customer_action": null }, "created_at": "2026-10-20T08:00:00.000Z", "observed_at": "2026-10-27T09:15:42.000Z" } ``` --- ## Leads A lead is one contact taking part in one campaign. If the same person is in two campaigns, that is two leads with different `id` values. Base URL: https://api.melioralab.io/v1. Pass the key in the `Authorization: Bearer mel_live_...` header. Endpoints: - `GET /v1/leads`: List leads - `GET /v1/leads/{id}`: Retrieve a lead ### The Lead object Lead: the participation of one contact in one campaign. The same person in two campaigns produces two different leads. | Field | Type | Description | |---|---|---| | `object` | string | Object type, always `lead`. | | `id` | string (uuid) | Lead ID (participation in a campaign). | | `workspace_id` | string (uuid) | Workspace of the campaign. | | `campaign_id` | string (uuid) | Campaign the contact participates in. | | `username` | string or null | Telegram username without `@`. Can be `null`. Not unique: use `fields.record_id` to link to your CRM. | | `status` | string | Lead status in the campaign. Values: `new`, `contacted`, `completed`, `replied`, `blocked`, `failed`, `suspended`, `deleted`, `opted_out`, `unknown`. | | `external_id` | null | Reserved for future versions. Always `null` in v1. | | `fields` | object | Allowed fields from the contact record. | | `fields.record_id` | string or null | Record ID in an external CRM, passed when the contacts were uploaded. `null` if `record_id_status` is not `valid`. May be absent. | | `fields.first_name` | string or null | First name. May be absent. | | `fields.last_name` | string or null | Last name. May be absent. | | `fields.company` | string or null | Company. May be absent. | | `fields.position` | string or null | Job title. May be absent. | | `fields.website` | string or null | Website. May be absent. | | `fields.source` | string or null | Contact source. May be absent. | | `fields.utm_source` | string or null | The `utm_source` tag. May be absent. | | `fields.utm_medium` | string or null | The `utm_medium` tag. May be absent. | | `fields.utm_campaign` | string or null | The `utm_campaign` tag. May be absent. | | `fields_truncated` | boolean | `true` if some field values were shortened or hidden due to size limits. | | `record_id_status` | string | How reliable `fields.record_id` is: `valid`, `missing`, `conflict` or `unknown`. Values: `valid`, `missing`, `conflict`, `unknown`. | | `first_contact_account` | object or null | Account that sent the first message. `null` if unknown. | | `first_contact_account.id` | string (uuid) | Account ID. | | `first_contact_account.username` | string or null | Account username without `@`. Can be `null`. | | `first_contact_account.name` | string or null | Account display name. Can be `null`. | | `first_message_sent_at` | string (date-time) or null | When the first message was sent. `null` if sending is not confirmed. | | `first_reply_at` | string (date-time) or null | When the first reply arrived. `null` if there was no reply. | | `delivery_state` | string | Delivery state of the next or most recent message in the sequence. Values: `waiting_queue`, `queued`, `held`, `sent`, `failed`, `unknown`. | | `automation_state` | string | State of the automatic sequence for this lead. Leads without a scheduled message, such as those who replied, currently often have `unknown`. Values: `running`, `paused`, `held`, `stopped`, `completed`, `unknown`. | | `reason` | object | Why the sequence is waiting, if known. | | `reason.reason_code` | string | Reason code. Values: `none`, `schedule_window`, `daily_limit`, `queue_pending`, `telegram_wait`, `account_unavailable`, `account_restricted`, `campaign_paused`, `subscription_inactive`, `manual_hold`, `automation_stopped`, `service_unavailable`, `unknown`. | | `reason.reason_observed_at` | string (date-time) or null | When the reason was recorded. `null` if unknown. | | `reason.next_attempt_at` | string (date-time) or null | When the next attempt is expected. `null` if the time is unknown. | | `reason.requires_customer_action` | boolean or null | Whether someone needs to act in Meliora for sending to resume. `null` if unknown. | | `created_at` | string (date-time) | When the contact was added to the campaign. | | `observed_at` | string (date-time) | Time the server read the data. | Example: ```json { "object": "lead", "id": "5a7c3e92-1f4b-4d68-9c2e-7b0a6d4f8e31", "workspace_id": "3f6a9d21-8b4c-4e7f-a102-5c9e8d7b6a43", "campaign_id": "4d8b2f73-6e1a-4c95-b7d0-8a3f5e2c9b14", "username": "example_user", "status": "replied", "external_id": null, "fields": { "record_id": "crm-10482", "first_name": "Anna", "company": "Example Ltd", "position": "Head of sales", "utm_source": "partner" }, "fields_truncated": false, "record_id_status": "valid", "first_contact_account": { "id": "1d9f6b28-4c3e-4a75-8b19-5e7a2c0f6d83", "username": "example_sender", "name": "Mary from Example" }, "first_message_sent_at": "2026-10-26T07:30:00.000Z", "first_reply_at": "2026-10-26T09:12:00.000Z", "delivery_state": "sent", "automation_state": "unknown", "reason": { "reason_code": "none", "reason_observed_at": null, "next_attempt_at": null, "requires_customer_action": null }, "created_at": "2026-10-25T14:00:00.000Z", "observed_at": "2026-10-27T09:15:42.000Z" } ``` ### List leads `GET /v1/leads` Leads (contacts' participation in campaigns) in the key's scope, oldest first by `created_at`. Permission: `leads:read`. Request weight: 2. #### Parameters | Parameter | In | Type | Required | Description | |---|---|---|---|---| | `limit` | query | integer | no | Number of objects to return per page. From 1 to 100. Defaults to `50`. | | `cursor` | query | string | no | The `next_cursor` value from the previous response. Other filters must match the first request. | | `workspace_id` | query | string (uuid) | no | Only objects in this workspace. | | `campaign_id` | query | string (uuid) | no | Only objects in this campaign. | | `username` | query | string | no | Exact Telegram username. A leading `@` and letter case are ignored. | | `status` | query | string | no | Only leads with this status. Values: `new`, `contacted`, `completed`, `replied`, `blocked`, `failed`, `suspended`, `deleted`, `opted_out`, `unknown`. | | `delivery_state` | query | string | no | Only leads with this delivery state. The values `waiting_queue` and `failed` are not returned at the moment, so filtering by them returns an empty list. Values: `waiting_queue`, `queued`, `held`, `sent`, `failed`, `unknown`. | #### Response A list: `object`, `data`, `has_more`, `next_cursor`. Pagination: https://docs.melioralab.io/en/md/pagination.md #### Endpoint-specific errors - `cursor_mismatch` (400): The cursor is corrupted, was issued for a different route or filters, or the key's permissions and scope have changed. - `scope_required` (403): The key does not have permission for this read. Texts also require "Message text" to be enabled. - `not_found` (404): The object was not found or is outside the key's scope. An unknown path also returns 404. - `cursor_expired` (410): The cursor expired: 15 minutes for lists, 30 days for the event feed. Common errors: https://docs.melioralab.io/en/md/errors.md #### Example request ```bash curl -G https://api.melioralab.io/v1/leads \ -H "Authorization: Bearer $MELIORA_API_KEY" \ -d campaign_id=4d8b2f73-6e1a-4c95-b7d0-8a3f5e2c9b14 \ -d limit=50 ``` #### Example response ```json { "object": "list", "data": [ { "object": "lead", "id": "5a7c3e92-1f4b-4d68-9c2e-7b0a6d4f8e31", "workspace_id": "3f6a9d21-8b4c-4e7f-a102-5c9e8d7b6a43", "campaign_id": "4d8b2f73-6e1a-4c95-b7d0-8a3f5e2c9b14", "username": "example_user", "status": "replied", "external_id": null, "fields": { "record_id": "crm-10482", "first_name": "Anna", "company": "Example Ltd", "position": "Head of sales", "utm_source": "partner" }, "fields_truncated": false, "record_id_status": "valid", "first_contact_account": { "id": "1d9f6b28-4c3e-4a75-8b19-5e7a2c0f6d83", "username": "example_sender", "name": "Mary from Example" }, "first_message_sent_at": "2026-10-26T07:30:00.000Z", "first_reply_at": "2026-10-26T09:12:00.000Z", "delivery_state": "sent", "automation_state": "unknown", "reason": { "reason_code": "none", "reason_observed_at": null, "next_attempt_at": null, "requires_customer_action": null }, "created_at": "2026-10-25T14:00:00.000Z", "observed_at": "2026-10-27T09:15:42.000Z" }, { "object": "lead", "id": "8c1e5a37-9d2f-4b74-a6e8-2f9b3c7d5a40", "workspace_id": "3f6a9d21-8b4c-4e7f-a102-5c9e8d7b6a43", "campaign_id": "4d8b2f73-6e1a-4c95-b7d0-8a3f5e2c9b14", "username": "sample_contact", "status": "contacted", "external_id": null, "fields": { "record_id": null, "first_name": "Igor" }, "fields_truncated": false, "record_id_status": "missing", "first_contact_account": { "id": "1d9f6b28-4c3e-4a75-8b19-5e7a2c0f6d83", "username": "example_sender", "name": "Mary from Example" }, "first_message_sent_at": "2026-10-26T07:42:00.000Z", "first_reply_at": null, "delivery_state": "queued", "automation_state": "running", "reason": { "reason_code": "queue_pending", "reason_observed_at": "2026-10-27T08:58:10.000Z", "next_attempt_at": "2026-10-27T10:00:00.000Z", "requires_customer_action": false }, "created_at": "2026-10-25T14:00:01.000Z", "observed_at": "2026-10-27T09:15:42.000Z" } ], "has_more": true, "next_cursor": "v1.k1.Qm9hcmRfMWZ4.c3RyaWN0bHlfb3BhcXVl.dGFnX2V4YW1wbGU.bWFjX2V4YW1wbGVfdmFsdWU" } ``` ### Retrieve a lead `GET /v1/leads/{id}` A single lead by ID. A lead outside the key's scope returns 404. Permission: `leads:read`. Request weight: 1. #### Parameters | Parameter | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | string (uuid) | yes | Lead ID. | #### Response Returns a `Lead` object. #### Endpoint-specific errors - `scope_required` (403): The key does not have permission for this read. Texts also require "Message text" to be enabled. - `not_found` (404): The object was not found or is outside the key's scope. An unknown path also returns 404. Common errors: https://docs.melioralab.io/en/md/errors.md #### Example request ```bash curl https://api.melioralab.io/v1/leads/5a7c3e92-1f4b-4d68-9c2e-7b0a6d4f8e31 \ -H "Authorization: Bearer $MELIORA_API_KEY" ``` #### Example response ```json { "object": "lead", "id": "5a7c3e92-1f4b-4d68-9c2e-7b0a6d4f8e31", "workspace_id": "3f6a9d21-8b4c-4e7f-a102-5c9e8d7b6a43", "campaign_id": "4d8b2f73-6e1a-4c95-b7d0-8a3f5e2c9b14", "username": "example_user", "status": "replied", "external_id": null, "fields": { "record_id": "crm-10482", "first_name": "Anna", "company": "Example Ltd", "position": "Head of sales", "utm_source": "partner" }, "fields_truncated": false, "record_id_status": "valid", "first_contact_account": { "id": "1d9f6b28-4c3e-4a75-8b19-5e7a2c0f6d83", "username": "example_sender", "name": "Mary from Example" }, "first_message_sent_at": "2026-10-26T07:30:00.000Z", "first_reply_at": "2026-10-26T09:12:00.000Z", "delivery_state": "sent", "automation_state": "unknown", "reason": { "reason_code": "none", "reason_observed_at": null, "next_attempt_at": null, "requires_customer_action": null }, "created_at": "2026-10-25T14:00:00.000Z", "observed_at": "2026-10-27T09:15:42.000Z" } ``` --- ## Conversations Saved messages of an account's conversation with a lead: outbound sequence steps, manual messages and replies. Base URL: https://api.melioralab.io/v1. Pass the key in the `Authorization: Bearer mel_live_...` header. Endpoints: - `GET /v1/leads/{id}/messages`: List lead conversation ### The Message object A message from the saved conversation with a lead. The archive may be incomplete: see `meta.completeness`. | Field | Type | Description | |---|---|---| | `object` | string | Object type, always `message`. | | `id` | string | Opaque, stable message ID. Do not parse its format. | | `direction` | string | `outbound`: from the account to the lead; `inbound`: from the lead. Values: `outbound`, `inbound`. | | `author` | string | `campaign`: a sequence step; `manager`: a manual message; `lead`: a lead reply; `unknown`: not determined. Values: `campaign`, `manager`, `lead`, `unknown`. | | `sent_at` | string (date-time) or null | When the message was sent or received. `null` if unknown. | | `state` | string | `sent`: sending confirmed; `received`: incoming message saved; `unknown`: outcome not confirmed. Values: `sent`, `received`, `unknown`. | | `media_kind` | string | Attachment type. Files and links to them are not returned. Values: `none`, `photo`, `video`, `voice`, `audio`, `document`, `sticker`, `animation`, `video_note`, `contact`, `location`, `poll`, `other`, `unknown`. | | `text` | string or null | Saved text, up to 4000 characters. `null` if the text is unavailable. | | `text_truncated` | boolean or null | `true`: the text is shortened; `false`: the text is complete; `null`: completeness is unknown. | | `text_unavailable` | boolean | `true` if the text is not returned. The reason is in `text_unavailable_reason`. | | `text_unavailable_reason` | string or null | `not_saved`: the text was not saved; `ownership_unproven`: it is not proven that the message belongs to this conversation; `source_conflict`: sources disagree; `purged`: the text was deleted after the retention period. Values: `not_saved`, `ownership_unproven`, `source_conflict`, `purged`. | Example: ```json { "object": "message", "id": "m_6b2e9f403a7c4d158e921c5a7f3b9d08", "direction": "outbound", "author": "campaign", "sent_at": "2026-10-26T07:30:00.000Z", "state": "sent", "media_kind": "none", "text": "Good afternoon, Anna! I'm writing from Example: could you tell me who handles outreach at your company?", "text_truncated": false, "text_unavailable": false, "text_unavailable_reason": null } ``` ### List lead conversation `GET /v1/leads/{id}/messages` Saved messages of the conversation with a lead, oldest first. Requires the `messages:read` permission and "Message text" enabled on the key. The archive may be incomplete: see `meta.completeness`. Permission: `messages:read` and “Message text” enabled on the key. Request weight: 3. #### Parameters | Parameter | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | string (uuid) | yes | Lead ID. | | `limit` | query | integer | no | Number of objects to return per page. From 1 to 100. Defaults to `50`. | | `cursor` | query | string | no | The `next_cursor` value from the previous response. Other filters must match the first request. | #### Response A list: `object`, `data`, `has_more`, `next_cursor`, `meta`. Pagination: https://docs.melioralab.io/en/md/pagination.md The `meta` field: | Field | Type | Description | |---|---|---| | `meta.completeness` | object | How complete the saved conversation archive with the lead is. Completeness is not calculated yet: `overall` and `inbound` are returned as `partial`, `outbound` as `unknown`. | | `meta.completeness.overall` | string | Overall completeness: `complete`, `partial` or `unknown`. Values: `complete`, `partial`, `unknown`. | | `meta.completeness.inbound` | string | Completeness of inbound messages. Values: `complete`, `partial`, `unknown`. | | `meta.completeness.outbound` | string | Completeness of outbound messages. Values: `complete`, `partial`, `unknown`. | | `meta.completeness.available_from` | string (date-time) or null | Start of the period covered by the data. `null` if unknown. | | `meta.completeness.available_to` | string (date-time) or null | End of the period covered by the data. `null` if unknown. | | `meta.completeness.observed_at` | string (date-time) | Time the server read the data. | #### Endpoint-specific errors - `cursor_mismatch` (400): The cursor is corrupted, was issued for a different route or filters, or the key's permissions and scope have changed. - `scope_required` (403): The key does not have permission for this read. Texts also require "Message text" to be enabled. - `not_found` (404): The object was not found or is outside the key's scope. An unknown path also returns 404. - `cursor_expired` (410): The cursor expired: 15 minutes for lists, 30 days for the event feed. Common errors: https://docs.melioralab.io/en/md/errors.md #### Example request ```bash curl https://api.melioralab.io/v1/leads/5a7c3e92-1f4b-4d68-9c2e-7b0a6d4f8e31/messages \ -H "Authorization: Bearer $MELIORA_API_KEY" ``` #### Example response ```json { "object": "list", "data": [ { "object": "message", "id": "m_6b2e9f403a7c4d158e921c5a7f3b9d08", "direction": "outbound", "author": "campaign", "sent_at": "2026-10-26T07:30:00.000Z", "state": "sent", "media_kind": "none", "text": "Good afternoon, Anna! I'm writing from Example: could you tell me who handles outreach at your company?", "text_truncated": false, "text_unavailable": false, "text_unavailable_reason": null }, { "object": "message", "id": "m_c7a1d3e59b2f4a68b4d05e8f2a6c1b97", "direction": "inbound", "author": "lead", "sent_at": "2026-10-26T09:12:00.000Z", "state": "received", "media_kind": "none", "text": "Hello! That's me, let's have a call tomorrow.", "text_truncated": null, "text_unavailable": false, "text_unavailable_reason": null }, { "object": "message", "id": "m_a3f8c6e27d1b4e949c058b2d4f7a6e13", "direction": "inbound", "author": "lead", "sent_at": "2026-10-26T09:13:30.000Z", "state": "received", "media_kind": "photo", "text": null, "text_truncated": null, "text_unavailable": true, "text_unavailable_reason": "not_saved" } ], "has_more": false, "next_cursor": null, "meta": { "completeness": { "overall": "partial", "inbound": "partial", "outbound": "unknown", "available_from": "2026-10-26T07:30:00.000Z", "available_to": "2026-10-27T09:15:42.000Z", "observed_at": "2026-10-27T09:15:42.000Z" } } } ``` --- ## Events The event feed: the first message to a lead and the lead's reply. Read it with a cursor to learn about changes without scanning every lead. Base URL: https://api.melioralab.io/v1. Pass the key in the `Authorization: Bearer mel_live_...` header. Endpoints: - `GET /v1/events`: List events ### The lead.first_message_sent event A first message was sent to the lead. `data.first_message` is present only with `include_text=true`. | Field | Type | Description | |---|---|---| | `object` | string | Object type, always `event`. | | `id` | string | Event ID: `evt_` followed by 32 hexadecimal characters. The deduplication key. | | `api_version` | string | Event format version. Always `"2026-10-18"`. | | `timestamp` | string (date-time) | When the fact occurred. | | `test` | boolean | Always `false`. | | `type` | string | Event type. Always `"lead.first_message_sent"`. | | `data` | object | Event data. | | `data.organization_id` | string (uuid) | Organization where the event occurred. | | `data.workspace` | object | Workspace at the time of the event. | | `data.workspace.id` | string (uuid) | ID. | | `data.workspace.name` | string or null | Name. Can be `null`. | | `data.campaign` | object or null | Campaign at the time of the event. Can be `null`. | | `data.campaign.id` | string (uuid) | ID. | | `data.campaign.name` | string or null | Name. Can be `null`. | | `data.lead` | object | Lead snapshot. | | `data.lead.id` | string (uuid) | Lead ID. | | `data.lead.username` | string or null | Telegram username without `@`. Can be `null`. | | `data.lead.status` | string | Lead status at the time of the event. Values: `new`, `contacted`, `completed`, `replied`, `blocked`, `failed`, `suspended`, `deleted`, `opted_out`, `unknown`. | | `data.lead.fields` | object | Contact record fields at the time of the event. | | `data.lead.fields.record_id` | string or null | Record ID in an external CRM, passed when the contacts were uploaded. `null` if `record_id_status` is not `valid`. May be absent. | | `data.lead.fields.first_name` | string or null | First name. May be absent. | | `data.lead.fields.last_name` | string or null | Last name. May be absent. | | `data.lead.fields.company` | string or null | Company. May be absent. | | `data.lead.fields.position` | string or null | Job title. May be absent. | | `data.lead.fields.website` | string or null | Website. May be absent. | | `data.lead.fields.source` | string or null | Contact source. May be absent. | | `data.lead.fields.utm_source` | string or null | The `utm_source` tag. May be absent. | | `data.lead.fields.utm_medium` | string or null | The `utm_medium` tag. May be absent. | | `data.lead.fields.utm_campaign` | string or null | The `utm_campaign` tag. May be absent. | | `data.lead.fields_truncated` | boolean | `true` if some values were shortened. | | `data.lead.record_id_status` | string | Reliability of `fields.record_id`. In v1 events return `unknown`, and `fields.record_id` is `null`: get `record_id` from `GET /v1/leads/{id}` using `data.lead.id`. Values: `valid`, `missing`, `conflict`, `unknown`. | | `data.account` | object or null | Account that handled the conversation. `null` if unknown. | | `data.account.id` | string (uuid) | Account ID. | | `data.account.username` | string or null | Account username without `@`. Can be `null`. | | `data.account.name` | string or null | Account display name. Can be `null`. | | `data.first_message_sent_at` | string (date-time) or null | When the first message was sent. `null` if sending is not confirmed; this is common for reply events. | | `data.first_message` | object | First message text. Only with `include_text=true`. May be absent. | | `data.first_message.text` | string or null | Saved text, up to 4000 characters. `null` if the text is unavailable. | | `data.first_message.text_truncated` | boolean or null | `true`: the text is shortened; `false`: the text is complete; `null`: completeness is unknown. | | `data.first_message.text_unavailable` | boolean | `true` if the text is not returned. | | `data.first_message.text_unavailable_reason` | string or null | Reason the text is unavailable, or `null`. Values: `not_saved`, `ownership_unproven`, `source_conflict`, `purged`. | Example: ```json { "object": "event", "id": "evt_3b9e2c7a1f4d4e8a9b6c0d2e5f7a8c91", "type": "lead.first_message_sent", "api_version": "2026-10-18", "timestamp": "2026-10-26T07:30:00.000Z", "test": false, "data": { "organization_id": "2c8f4a61-7d3e-4b9a-8f21-6a0d5e9c3b17", "workspace": { "id": "3f6a9d21-8b4c-4e7f-a102-5c9e8d7b6a43", "name": "Main workspace" }, "campaign": { "id": "4d8b2f73-6e1a-4c95-b7d0-8a3f5e2c9b14", "name": "Example campaign" }, "lead": { "id": "5a7c3e92-1f4b-4d68-9c2e-7b0a6d4f8e31", "username": "example_user", "status": "contacted", "fields": { "record_id": null, "first_name": "Anna", "company": "Example Ltd" }, "fields_truncated": false, "record_id_status": "unknown" }, "account": { "id": "1d9f6b28-4c3e-4a75-8b19-5e7a2c0f6d83", "username": "example_sender", "name": "Mary from Example" }, "first_message_sent_at": "2026-10-26T07:30:00.000Z" } } ``` ### The lead.replied event The lead replied. Sent for every counted reply, so one lead can have several. Text fields in `data.reply` are present only with `include_text=true`. | Field | Type | Description | |---|---|---| | `object` | string | Object type, always `event`. | | `id` | string | Event ID: `evt_` followed by 32 hexadecimal characters. The deduplication key. | | `api_version` | string | Event format version. Always `"2026-10-18"`. | | `timestamp` | string (date-time) | When the fact occurred. | | `test` | boolean | Always `false`. | | `type` | string | Event type. Always `"lead.replied"`. | | `data` | object | Event data. | | `data.organization_id` | string (uuid) | Organization where the event occurred. | | `data.workspace` | object | Workspace at the time of the event. | | `data.workspace.id` | string (uuid) | ID. | | `data.workspace.name` | string or null | Name. Can be `null`. | | `data.campaign` | object or null | Campaign at the time of the event. Can be `null`. | | `data.campaign.id` | string (uuid) | ID. | | `data.campaign.name` | string or null | Name. Can be `null`. | | `data.lead` | object | Lead snapshot. | | `data.lead.id` | string (uuid) | Lead ID. | | `data.lead.username` | string or null | Telegram username without `@`. Can be `null`. | | `data.lead.status` | string | Lead status at the time of the event. Values: `new`, `contacted`, `completed`, `replied`, `blocked`, `failed`, `suspended`, `deleted`, `opted_out`, `unknown`. | | `data.lead.fields` | object | Contact record fields at the time of the event. | | `data.lead.fields.record_id` | string or null | Record ID in an external CRM, passed when the contacts were uploaded. `null` if `record_id_status` is not `valid`. May be absent. | | `data.lead.fields.first_name` | string or null | First name. May be absent. | | `data.lead.fields.last_name` | string or null | Last name. May be absent. | | `data.lead.fields.company` | string or null | Company. May be absent. | | `data.lead.fields.position` | string or null | Job title. May be absent. | | `data.lead.fields.website` | string or null | Website. May be absent. | | `data.lead.fields.source` | string or null | Contact source. May be absent. | | `data.lead.fields.utm_source` | string or null | The `utm_source` tag. May be absent. | | `data.lead.fields.utm_medium` | string or null | The `utm_medium` tag. May be absent. | | `data.lead.fields.utm_campaign` | string or null | The `utm_campaign` tag. May be absent. | | `data.lead.fields_truncated` | boolean | `true` if some values were shortened. | | `data.lead.record_id_status` | string | Reliability of `fields.record_id`. In v1 events return `unknown`, and `fields.record_id` is `null`: get `record_id` from `GET /v1/leads/{id}` using `data.lead.id`. Values: `valid`, `missing`, `conflict`, `unknown`. | | `data.account` | object or null | Account that handled the conversation. `null` if unknown. | | `data.account.id` | string (uuid) | Account ID. | | `data.account.username` | string or null | Account username without `@`. Can be `null`. | | `data.account.name` | string or null | Account display name. Can be `null`. | | `data.first_message_sent_at` | string (date-time) or null | When the first message was sent. `null` if sending is not confirmed; this is common for reply events. | | `data.reply` | object | Lead reply. | | `data.reply.id` | string (uuid) | Reply ID. | | `data.reply.replied_at` | string (date-time) | When the reply arrived. | | `data.reply.automation_stopped` | boolean or null | Whether the sequence stopped after the reply. `null` if the stop is not confirmed. Currently always `null`. | | `data.reply.is_first_reply` | boolean | Whether this is the lead's first reply in the campaign. Not sent at the moment. May be absent. | | `data.reply.text` | string or null | Reply text. Only with `include_text=true`. May be absent. | | `data.reply.text_truncated` | boolean or null | Whether the text is shortened. Only with `include_text=true`. May be absent. | | `data.reply.text_unavailable` | boolean | Whether the text is unavailable. Only with `include_text=true`. May be absent. | | `data.reply.text_unavailable_reason` | string or null | Reason the text is unavailable. Only with `include_text=true`. Values: `not_saved`, `ownership_unproven`, `source_conflict`, `purged`. May be absent. | Example: ```json { "object": "event", "id": "evt_8d4f1a6c2e9b4c7d8a3e5f0b1c6d9e27", "type": "lead.replied", "api_version": "2026-10-18", "timestamp": "2026-10-26T09:12:00.000Z", "test": false, "data": { "organization_id": "2c8f4a61-7d3e-4b9a-8f21-6a0d5e9c3b17", "workspace": { "id": "3f6a9d21-8b4c-4e7f-a102-5c9e8d7b6a43", "name": "Main workspace" }, "campaign": { "id": "4d8b2f73-6e1a-4c95-b7d0-8a3f5e2c9b14", "name": "Example campaign" }, "lead": { "id": "5a7c3e92-1f4b-4d68-9c2e-7b0a6d4f8e31", "username": "example_user", "status": "replied", "fields": { "record_id": null, "first_name": "Anna", "company": "Example Ltd" }, "fields_truncated": false, "record_id_status": "unknown" }, "account": { "id": "1d9f6b28-4c3e-4a75-8b19-5e7a2c0f6d83", "username": "example_sender", "name": "Mary from Example" }, "first_message_sent_at": null, "reply": { "id": "e4b7a2d9-6c1f-4e38-9a5b-0d8c3f7e1a62", "replied_at": "2026-10-26T09:12:00.000Z", "automation_stopped": null } } } ``` ### List events `GET /v1/events` Events in the order they occur: the first message to a lead and the lead's reply. Poll the feed with the cursor. `next_cursor` is always present, even on an empty page. With `include_text=true` the request weight is 3. Permission: `events:read`. Request weight: 2 (3 with `include_text=true`). #### Parameters | Parameter | In | Type | Required | Description | |---|---|---|---|---| | `limit` | query | integer | no | Number of objects to return per page. From 1 to 200. Defaults to `50`. | | `cursor` | query | string | no | The `next_cursor` value from the previous response. Other filters must match the first request. | | `workspace_id` | query | string (uuid) | no | Only objects in this workspace. | | `campaign_id` | query | string (uuid) | no | Only objects in this campaign. | | `type` | query | string | no | Only events of this type. Values: `lead.first_message_sent`, `lead.replied`. | | `include_text` | query | string | no | Include message texts in events. Requires the `messages:read` permission and "Message text" enabled on the key. Values: `true`, `false`. Defaults to `false`. | | `start` | query | string | no | `now`: start the feed from the current moment. Returns an empty page and a cursor. Cannot be combined with `cursor`. Values: `now`. | #### Response A list: `object`, `data`, `has_more`, `next_cursor`, `meta`. Pagination: https://docs.melioralab.io/en/md/pagination.md The `meta` field: | Field | Type | Description | |---|---|---| | `meta.available_from` | string (date-time) or null | Earliest time for which the feed has events. `null` if unknown. | | `meta.retention_days` | string | Number of days the feed retains events. Always `30`. | | `meta.collection` | object | Status of saved conversation collection for the API. | | `meta.collection.state` | string | `running`: collection is active; `paused`: paused in Meliora; `preparing`: preparing after being enabled; `degraded`: data may be incomplete. Values: `running`, `paused`, `preparing`, `degraded`. | | `meta.collection.enabled_at` | string (date-time) or null | When collection was enabled. `null` if it has not been enabled yet. | | `meta.collection.pause_pending` | boolean | A pause or resume was requested in Meliora but has not been applied yet. | | `meta.coverage` | object | Event feed completeness. | | `meta.coverage.status` | string | `complete`, `partial` or `unknown`. Values: `complete`, `partial`, `unknown`. | | `meta.coverage.gaps` | array of object | Up to 50 intervals of possible incompleteness. | | `meta.coverage.gaps[].id` | string (uuid) | Interval ID. | | `meta.coverage.gaps[].from` | string (date-time) | Start of the interval. | | `meta.coverage.gaps[].to` | string (date-time) or null | End of the interval. `null` if the interval is not closed yet. | | `meta.coverage.gaps[].state` | string | `open`, `recovering`, `recovered`, `unrecoverable` or `unknown`. Values: `open`, `recovering`, `recovered`, `unrecoverable`, `unknown`. | | `meta.coverage.gaps_truncated` | boolean | `true` if there are more than 50 intervals. | | `meta.coverage.incomplete_from` | string (date-time) or null | Earliest boundary of the hidden incomplete intervals. `null` if there are none. | | `meta.observed_at` | string (date-time) | Time the server read the data. | #### Endpoint-specific errors - `cursor_mismatch` (400): The cursor is corrupted, was issued for a different route or filters, or the key's permissions and scope have changed. - `scope_required` (403): The key does not have permission for this read. Texts also require "Message text" to be enabled. - `not_found` (404): The object was not found or is outside the key's scope. An unknown path also returns 404. - `cursor_expired` (410): The cursor expired: 15 minutes for lists, 30 days for the event feed. Common errors: https://docs.melioralab.io/en/md/errors.md #### Example request ```bash curl -G https://api.melioralab.io/v1/events \ -H "Authorization: Bearer $MELIORA_API_KEY" \ -d limit=100 ``` #### Example response: Response ```json { "object": "list", "data": [ { "object": "event", "id": "evt_3b9e2c7a1f4d4e8a9b6c0d2e5f7a8c91", "type": "lead.first_message_sent", "api_version": "2026-10-18", "timestamp": "2026-10-26T07:30:00.000Z", "test": false, "data": { "organization_id": "2c8f4a61-7d3e-4b9a-8f21-6a0d5e9c3b17", "workspace": { "id": "3f6a9d21-8b4c-4e7f-a102-5c9e8d7b6a43", "name": "Main workspace" }, "campaign": { "id": "4d8b2f73-6e1a-4c95-b7d0-8a3f5e2c9b14", "name": "Example campaign" }, "lead": { "id": "5a7c3e92-1f4b-4d68-9c2e-7b0a6d4f8e31", "username": "example_user", "status": "contacted", "fields": { "record_id": null, "first_name": "Anna", "company": "Example Ltd" }, "fields_truncated": false, "record_id_status": "unknown" }, "account": { "id": "1d9f6b28-4c3e-4a75-8b19-5e7a2c0f6d83", "username": "example_sender", "name": "Mary from Example" }, "first_message_sent_at": "2026-10-26T07:30:00.000Z" } }, { "object": "event", "id": "evt_8d4f1a6c2e9b4c7d8a3e5f0b1c6d9e27", "type": "lead.replied", "api_version": "2026-10-18", "timestamp": "2026-10-26T09:12:00.000Z", "test": false, "data": { "organization_id": "2c8f4a61-7d3e-4b9a-8f21-6a0d5e9c3b17", "workspace": { "id": "3f6a9d21-8b4c-4e7f-a102-5c9e8d7b6a43", "name": "Main workspace" }, "campaign": { "id": "4d8b2f73-6e1a-4c95-b7d0-8a3f5e2c9b14", "name": "Example campaign" }, "lead": { "id": "5a7c3e92-1f4b-4d68-9c2e-7b0a6d4f8e31", "username": "example_user", "status": "replied", "fields": { "record_id": null, "first_name": "Anna", "company": "Example Ltd" }, "fields_truncated": false, "record_id_status": "unknown" }, "account": { "id": "1d9f6b28-4c3e-4a75-8b19-5e7a2c0f6d83", "username": "example_sender", "name": "Mary from Example" }, "first_message_sent_at": null, "reply": { "id": "e4b7a2d9-6c1f-4e38-9a5b-0d8c3f7e1a62", "replied_at": "2026-10-26T09:12:00.000Z", "automation_stopped": null } } } ], "has_more": false, "next_cursor": "v1.k1.Qm9hcmRfMWZ4.c3RyaWN0bHlfb3BhcXVl.dGFnX2V4YW1wbGU.bWFjX2V4YW1wbGVfdmFsdWU", "meta": { "available_from": "2026-10-20T08:00:00.000Z", "retention_days": 30, "collection": { "state": "running", "enabled_at": "2026-10-20T08:00:00.000Z", "pause_pending": false }, "coverage": { "status": "unknown", "gaps": [], "gaps_truncated": false, "incomplete_from": null }, "observed_at": "2026-10-27T09:15:42.000Z" } } ``` #### Example response: With include_text=true ```json { "object": "list", "data": [ { "object": "event", "id": "evt_3b9e2c7a1f4d4e8a9b6c0d2e5f7a8c91", "type": "lead.first_message_sent", "api_version": "2026-10-18", "timestamp": "2026-10-26T07:30:00.000Z", "test": false, "data": { "organization_id": "2c8f4a61-7d3e-4b9a-8f21-6a0d5e9c3b17", "workspace": { "id": "3f6a9d21-8b4c-4e7f-a102-5c9e8d7b6a43", "name": "Main workspace" }, "campaign": { "id": "4d8b2f73-6e1a-4c95-b7d0-8a3f5e2c9b14", "name": "Example campaign" }, "lead": { "id": "5a7c3e92-1f4b-4d68-9c2e-7b0a6d4f8e31", "username": "example_user", "status": "contacted", "fields": { "record_id": null, "first_name": "Anna", "company": "Example Ltd" }, "fields_truncated": false, "record_id_status": "unknown" }, "account": { "id": "1d9f6b28-4c3e-4a75-8b19-5e7a2c0f6d83", "username": "example_sender", "name": "Mary from Example" }, "first_message_sent_at": "2026-10-26T07:30:00.000Z", "first_message": { "text": "Good afternoon, Anna! I'm writing from Example: could you tell me who handles outreach at your company?", "text_truncated": false, "text_unavailable": false, "text_unavailable_reason": null } } }, { "object": "event", "id": "evt_8d4f1a6c2e9b4c7d8a3e5f0b1c6d9e27", "type": "lead.replied", "api_version": "2026-10-18", "timestamp": "2026-10-26T09:12:00.000Z", "test": false, "data": { "organization_id": "2c8f4a61-7d3e-4b9a-8f21-6a0d5e9c3b17", "workspace": { "id": "3f6a9d21-8b4c-4e7f-a102-5c9e8d7b6a43", "name": "Main workspace" }, "campaign": { "id": "4d8b2f73-6e1a-4c95-b7d0-8a3f5e2c9b14", "name": "Example campaign" }, "lead": { "id": "5a7c3e92-1f4b-4d68-9c2e-7b0a6d4f8e31", "username": "example_user", "status": "replied", "fields": { "record_id": null, "first_name": "Anna", "company": "Example Ltd" }, "fields_truncated": false, "record_id_status": "unknown" }, "account": { "id": "1d9f6b28-4c3e-4a75-8b19-5e7a2c0f6d83", "username": "example_sender", "name": "Mary from Example" }, "first_message_sent_at": null, "reply": { "id": "e4b7a2d9-6c1f-4e38-9a5b-0d8c3f7e1a62", "replied_at": "2026-10-26T09:12:00.000Z", "automation_stopped": null, "text": "Hello! That's me, let's have a call tomorrow.", "text_truncated": null, "text_unavailable": false, "text_unavailable_reason": null } } } ], "has_more": false, "next_cursor": "v1.k1.Qm9hcmRfMWZ4.c3RyaWN0bHlfb3BhcXVl.dGFnX2V4YW1wbGU.bWFjX2V4YW1wbGVfdmFsdWU", "meta": { "available_from": "2026-10-20T08:00:00.000Z", "retention_days": 30, "collection": { "state": "running", "enabled_at": "2026-10-20T08:00:00.000Z", "pause_pending": false }, "coverage": { "status": "unknown", "gaps": [], "gaps_truncated": false, "incomplete_from": null }, "observed_at": "2026-10-27T09:15:42.000Z" } } ``` #### Example response: With start=now ```json { "object": "list", "data": [], "has_more": false, "next_cursor": "v1.k1.Qm9hcmRfMWZ4.c3RyaWN0bHlfb3BhcXVl.dGFnX2V4YW1wbGU.bWFjX2V4YW1wbGVfdmFsdWU", "meta": { "available_from": "2026-10-20T08:00:00.000Z", "retention_days": 30, "collection": { "state": "running", "enabled_at": "2026-10-20T08:00:00.000Z", "pause_pending": false }, "coverage": { "status": "unknown", "gaps": [], "gaps_truncated": false, "incomplete_from": null }, "observed_at": "2026-10-27T09:15:42.000Z" } } ```