Webhooks
A webhook pushes an event to your system when a lead receives their first message or replies. There is nothing to poll: accept the request, verify the signature and respond with 2xx.
#Webhooks or the event feed
Webhooks and the event feed carry the same facts: lead.first_message_sent and lead.replied. The difference is which side starts the exchange.
| Webhooks | Feed GET /v1/events | |
|---|---|---|
| How events arrive | Meliora sends a POST to your endpoint | Your system polls the API |
| What you need | A public https endpoint | An API key with events:read |
| Where it's configured | In the Meliora app | In your integration code |
| If your side is down | Retries for about 3 days, then a manual retry | Events wait in the feed for 30 days |
| Text of the first message | Not sent | include_text=true |
record_id validation | None, the value arrives as uploaded | record_id_status |
Choose webhooks when you want to react quickly without constant polling and you have a server or an automation service with a public URL. Choose the feed when your system doesn't accept inbound requests, or when you must be able to collect every event after a long outage.
You can use both: webhooks as the fast signal, the feed as the safety net. A fact has the same id in a webhook and in the feed, so deduplicating by id prevents applying an event twice.
#How it works
- Something happens in the app: a lead receives their first message, or a counted reply arrives.
- Meliora collects the event and sends a
POSTwith a JSON body to every matching endpoint. - Your system verifies the signature, stores the event and responds with a 2xx status within 10 seconds.
- If there is no 2xx response, Meliora retries on a schedule with the same
id.
An endpoint receives events collected after it was saved. Past events are not sent to a new endpoint: get the history from the feed or by listing leads.
#Set up an endpoint
Webhooks 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. Webhooks don't need an API key.
- Open Settings › Integrations, find the Webhook events block and click "Add endpoint".
- Paste your system's URL into the endpoint URL field. The description is visible only in the app and helps you tell endpoints apart.
- Under events, select "First message sent" (
lead.first_message_sent), "Reply received" (lead.replied), or both. - Under campaigns, keep "All campaigns" to also receive events from campaigns created later, or pick specific ones. If your organization has several workspaces, choose workspaces the same way.
- To receive the lead's reply text, turn on "Send reply text".
- If the receiving side checks a key in a request header, add it under "Additional headers".
- Save the endpoint and copy the signing secret from the box that appears.
- Click "Test" and open "Log": the test event and your endpoint's response appear there.
You can view the signing secret again at any time with the "Signing secret" button on the endpoint card. An organization can have up to 10 endpoints. Webhooks are not available in the demo organization.
#Endpoint requirements
https://and port 443 only. A URL with another port can't be saved.- A hostname, not an IP address.
- No username or password in the URL. For key-based access, use additional headers.
- A valid TLS certificate for the hostname from a public certificate authority. Self-signed certificates don't work.
- The hostname must resolve only to public internet addresses. If any resolved address points to a private network, delivery is refused.
- The hostname needs an A (IPv4) record: delivery over IPv6 only is currently not guaranteed.
- Redirects are not followed: a 3xx response counts as a failure. Use the final URL.
- Query parameters after
?are kept and sent as is. The URL can be up to 2048 characters.
Requests come from Meliora servers. Their IP addresses are not published and can change, so don't filter requests by IP: verify the signature instead.
A URL can contain a secret path, so after you save it the app shows only its beginning and last characters. To change the URL, click "Edit" and paste the new one. Leaving the field empty keeps the current URL.
#Additional headers
If your service only accepts requests that carry a key in a header, add up to 5 headers. A name starts with x-, such as x-api-key, or is Authorization. A value can be up to 1024 characters. Values are hidden after you save them, and you can only replace them with "Replace headers". Signature headers and standard request headers can't be overridden.
#The request
Each event arrives as a separate POST request with a UTF-8 JSON body. The body is compact, without indentation, and non-ASCII characters are not escaped. The body is at most 256 KiB.
POST /meliora/webhook HTTP/1.1
Host: crm.example.com
Content-Type: application/json
User-Agent: Meliora-Webhooks/1
webhook-id: evt_aab3db461d0542ff88c3124491aa2cc4
webhook-timestamp: 1793092509
webhook-signature: v1,kt8BoIJwYXnW8z5nUWwQitssRUMxKam8Dv+TgAhDV0s=
{"id":"evt_aab3db461d0542ff88c3124491aa2cc4","type":"lead.replied",...}| Header | Value |
|---|---|
webhook-id | The event ID: evt_ followed by 32 hexadecimal characters. The same on every retry and equal to the id field in the body. |
webhook-timestamp | When this attempt was sent, in Unix seconds. Each retry has its own value. |
webhook-signature | The signature, v1,<base64>. During secret rotation, two signatures separated by a space. |
Content-Type | application/json |
User-Agent | Meliora-Webhooks/1 |
Your additional headers are sent as well, if you set any. HTTP header names are case-insensitive, so read them without relying on case.
#The body
| Field | Description |
|---|---|
id | The event ID and deduplication key. The same as the event's ID in the feed. |
type | lead.first_message_sent, lead.replied, or webhook.test for a test event. |
api_version | The event format version, currently 2026-10-18. See API versions. |
timestamp | When the fact happened. This is not the send time: a retry carries the same timestamp. |
test | true only for a test event. |
data | The event data. |
The common data fields are described in the events reference: workspace, campaign, lead, sender account, first message time and reply. Examples and the differences between webhooks and the feed follow.
{
"id": "evt_61017785512246cdb0f3fafecba197a2",
"type": "lead.first_message_sent",
"api_version": "2026-10-18",
"timestamp": "2026-10-27T09:02:11.204518+00:00",
"test": false,
"data": {
"event_id": "evt_61017785512246cdb0f3fafecba197a2",
"organization_id": "594b5bf4-aef7-480d-86b4-393390132326",
"workspace": { "id": "3512de95-fcaa-4f57-b692-5caee3217b3e", "name": "Sales" },
"campaign": { "id": "5cad4b4d-356f-4043-ba37-a42f51fc8a87", "name": "Autumn campaign" },
"lead": {
"id": "083f7a84-6cb7-49e1-9b1e-3a6ee783f70e",
"username": "ivan_p",
"username_at": "@ivan_p",
"telegram_url": "https://t.me/ivan_p",
"username_variants": [
"ivan_p", "@ivan_p", "t.me/ivan_p", "https://t.me/ivan_p", "http://t.me/ivan_p",
"https://t.me/ivan_p/", "telegram.me/ivan_p", "https://telegram.me/ivan_p"
],
"telegram_user_id": 5012345678,
"status": "contacted",
"fields": { "record_id": "crm-10482", "first_name": "Ivan", "company": "Example Ltd" }
},
"account": { "id": "aaa3f0d2-63a1-44df-b091-410ebc606e8e", "username": "anna_sales", "name": "Anna" },
"first_message_sent_at": "2026-10-27T09:02:11.204518+00:00"
}
}{
"id": "evt_aab3db461d0542ff88c3124491aa2cc4",
"type": "lead.replied",
"api_version": "2026-10-18",
"timestamp": "2026-10-27T09:15:02.481233+00:00",
"test": false,
"data": {
"event_id": "evt_aab3db461d0542ff88c3124491aa2cc4",
"organization_id": "594b5bf4-aef7-480d-86b4-393390132326",
"workspace": { "id": "3512de95-fcaa-4f57-b692-5caee3217b3e", "name": "Sales" },
"campaign": { "id": "5cad4b4d-356f-4043-ba37-a42f51fc8a87", "name": "Autumn campaign" },
"lead": {
"id": "083f7a84-6cb7-49e1-9b1e-3a6ee783f70e",
"username": "ivan_p",
"username_at": "@ivan_p",
"telegram_url": "https://t.me/ivan_p",
"username_variants": [
"ivan_p", "@ivan_p", "t.me/ivan_p", "https://t.me/ivan_p", "http://t.me/ivan_p",
"https://t.me/ivan_p/", "telegram.me/ivan_p", "https://telegram.me/ivan_p"
],
"telegram_user_id": 5012345678,
"status": "replied",
"fields": { "record_id": "crm-10482", "first_name": "Ivan", "company": "Example Ltd" }
},
"account": { "id": "aaa3f0d2-63a1-44df-b091-410ebc606e8e", "username": "anna_sales", "name": "Anna" },
"first_message_sent_at": "2026-10-27T09:02:11.204518+00:00",
"reply": {
"id": "d3c448da-10fe-4493-ad22-55e1eb36478a",
"replied_at": "2026-10-27T09:15:02.481233+00:00",
"text": "Hi! Sounds interesting, tell me more"
}
}
}Key order in the JSON is not guaranteed. Ignore unknown fields, and respond with 2xx to an event of an unknown type, otherwise it keeps being retried. See API versions.
#Differences from the feed
| Field | Webhook | Feed GET /v1/events |
|---|---|---|
object | Absent | "event" |
data.event_id | Equal to id | Absent |
data.lead.username_at, telegram_url, telegram_user_id | Present | Absent |
data.lead.username_variants | Eight common ways to write the username | Absent |
data.lead.fields | Every column uploaded with the lead except the phone number, as a snapshot at event time | Only the keys listed in CRM mapping, with record_id from the current lead record |
data.lead.record_id_status | Absent | valid, missing, conflict or unknown |
data.lead.status | The lead status when the event was collected | One of the reference values, otherwise unknown |
data.reply.text | When "Send reply text" is on | With include_text=true |
| Text of the first message | Not sent | data.first_message with include_text=true |
data.reply.automation_stopped | Absent | Always null |
| Time format | ISO 8601 UTC with a +00:00 offset, up to microseconds | ISO 8601 UTC with Z and milliseconds |
What to know about webhook data:
data.lead.fieldsholds the columns of the file uploaded during import or campaign creation. Names are lowercase Latin, and spaces become_: aLead Sourcecolumn arrives aslead_source. Phone numbers and internal fields are not sent.data.lead.fields.record_idis present if you uploaded such a column. Webhooks have norecord_id_statuscheck: if the value is missing or looks wrong, look the lead up withGET /v1/leads/{id}.data.lead.statusshows the status at the moment the event was collected. Its set of values is not fixed: base decisions ontype, not on this field.data.lead.usernameis lowercase, without@. If the lead has no username,usernameand the related fields arenull.data.accountisnullif the sender account can't be found, for example because it was deleted.data.first_message_sent_aton a reply event comes from the lead record and can benull.
Reply text. Without "Send reply text", there is no data.reply.text field. With it, text holds the reply. If the text can't be read, text is null and text_unavailable is true.
Size. If the body exceeds 256 KiB, the reply text is removed first (text is null, text_truncated is true), then the lead fields (fields is empty, fields_truncated is true). If the body is still too large, the event is not sent and the log shows that the event is too large.
#Verify the signature
Anyone who learns your endpoint URL can send it a forged event. The signature proves that Meliora sent the request and that the body wasn't changed. Signatures follow the Standard Webhooks specification, so its ready-made libraries work too.
A signing secret looks like whsec_ followed by 44 base64 characters. Store it as a secret, for example in an environment variable, and never commit it to code.
To verify a request:
- Take the raw request body: the bytes before any JSON parsing.
- Build the signed content: the
webhook-idvalue, a dot, thewebhook-timestampvalue, a dot, then the body. - The HMAC key is the part of the secret after
whsec_, decoded from base64. - Compute HMAC-SHA256 over the signed content and encode the result in base64.
- The
webhook-signatureheader holds one or more space-separated signatures, each in the formv1,<base64>. The event is authentic if your result matches at least onev1signature. Compare in constant time, not with a plain==. - Check
webhook-timestamp: the specification recommends rejecting requests whose time differs from yours by more than 5 minutes. This stops an intercepted request from being replayed later. Keep your server clock synchronized.
import crypto from 'node:crypto';
import express from 'express';
const TOLERANCE_SECONDS = 5 * 60;
// The current secret; during rotation you can list two, separated by a comma
const SECRETS = process.env.MELIORA_WEBHOOK_SECRETS.split(',');
export function verifyWebhook(rawBody, headers, secrets) {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signatures = headers['webhook-signature'];
if (!id || !timestamp || !signatures) throw new Error('missing headers');
const sentAt = Number(timestamp);
if (!Number.isInteger(sentAt) || Math.abs(Date.now() / 1000 - sentAt) > TOLERANCE_SECONDS) {
throw new Error('timestamp outside tolerance');
}
const signed = Buffer.concat([Buffer.from(`${id}.${timestamp}.`), rawBody]);
const expected = secrets.map((secret) => {
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
return Buffer.from(crypto.createHmac('sha256', key).update(signed).digest('base64'));
});
for (const part of signatures.split(' ')) {
const [version, value = ''] = part.split(',');
if (version !== 'v1') continue;
const received = Buffer.from(value);
if (expected.some((e) => e.length === received.length && crypto.timingSafeEqual(e, received))) {
return JSON.parse(rawBody.toString('utf8'));
}
}
throw new Error('no matching signature');
}
const app = express();
app.post('/meliora/webhook', express.raw({ type: 'application/json', limit: '1mb' }), async (req, res) => {
let event;
try {
event = verifyWebhook(req.body, req.headers, SECRETS);
} catch {
return res.status(400).end();
}
await saveEvent(event); // insert with a unique event.id; a duplicate is simply skipped
res.status(200).end(); // apply to the CRM after responding, in a separate job
});import base64
import hashlib
import hmac
import json
import os
import time
from fastapi import FastAPI, Request, Response
TOLERANCE_SECONDS = 5 * 60
# The current secret; during rotation you can list two, separated by a comma
SECRETS = os.environ["MELIORA_WEBHOOK_SECRETS"].split(",")
class InvalidWebhook(Exception):
pass
def verify_webhook(raw_body: bytes, headers, secrets: list[str]) -> dict:
msg_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signatures = headers.get("webhook-signature")
if not msg_id or not timestamp or not signatures:
raise InvalidWebhook("missing headers")
try:
sent_at = int(timestamp)
except ValueError:
raise InvalidWebhook("bad timestamp")
if abs(time.time() - sent_at) > TOLERANCE_SECONDS:
raise InvalidWebhook("timestamp outside tolerance")
signed = f"{msg_id}.{timestamp}.".encode() + raw_body
expected = []
for secret in secrets:
key = base64.b64decode(secret.removeprefix("whsec_"))
digest = hmac.new(key, signed, hashlib.sha256).digest()
expected.append(base64.b64encode(digest))
for part in signatures.split(" "):
version, _, value = part.partition(",")
if version != "v1":
continue
if any(hmac.compare_digest(value.encode(), e) for e in expected):
return json.loads(raw_body)
raise InvalidWebhook("no matching signature")
app = FastAPI()
@app.post("/meliora/webhook")
async def meliora_webhook(request: Request):
raw_body = await request.body()
try:
event = verify_webhook(raw_body, request.headers, SECRETS)
except InvalidWebhook:
return Response(status_code=400)
save_event(event) # insert with a unique event["id"]; a duplicate is simply skipped
return Response(status_code=200) # apply to the CRM after respondingReplace saveEvent and save_event with your own code: for example, an insert into a table with a unique key on id, plus a separate worker that applies events to your CRM.
#Test your implementation
Compute the signature for this data. Turn off the timestamp check for this example: the timestamp is in the past.
| Value | |
|---|---|
| Secret | whsec_AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8= |
webhook-id | evt_aab3db461d0542ff88c3124491aa2cc4 |
webhook-timestamp | 1793092509 |
| Body, no trailing newline | {"id":"evt_aab3db461d0542ff88c3124491aa2cc4","type":"lead.replied","test":false} |
| Expected signature | v1,kt8BoIJwYXnW8z5nUWwQitssRUMxKam8Dv+TgAhDV0s= |
The body in this example is shortened, which is enough to test the algorithm.
#Respond to the request
Respond with a 2xx status within 10 seconds, and the event counts as delivered. No response body is needed, and Meliora doesn't store it.
Everything else counts as a failed attempt:
- 3xx, 4xx and 5xx statuses; redirects are not followed;
- no response within 10 seconds;
- the connection and TLS handshake not completing within 5 seconds;
- a DNS, connection or certificate error.
A single attempt is limited to 30 seconds in total. Respond as soon as the event is stored, and do CRM updates and other slow work after responding.
Special responses:
| Response | What Meliora does |
|---|---|
410 Gone | Stops retrying this event and disables the endpoint right away. Return 410 only if the endpoint really is no longer needed. |
413 Payload Too Large | Stops retrying this event. The endpoint keeps working. |
429 or 503 with Retry-After | The wait before the next attempt is the longer of the scheduled delay and Retry-After in seconds. Values above one hour count as one hour. An HTTP date in Retry-After isn't supported, and the schedule applies instead. |
#Retries
If an attempt fails, Meliora retries with the same webhook-id and the same body: up to 10 attempts over about 3 days. Each delay varies randomly by up to 10% either way.
| Attempt | Delay after the previous one | Approximate time since the first attempt |
|---|---|---|
| 1 | Right after the event is collected | |
| 2 | 5 seconds | 5 seconds |
| 3 | 5 minutes | 5 minutes |
| 4 | 30 minutes | 35 minutes |
| 5 | 2 hours | 2 h 35 min |
| 6 | 5 hours | 7 h 35 min |
| 7 | 10 hours | 17 h 35 min |
| 8 | 14 hours | 31 h 35 min |
| 9 | 20 hours | 51 h 35 min |
| 10 | 24 hours | 75 h 35 min |
After 10 failed attempts, the event is marked as not delivered. You can send it again from the endpoint's log: use "Retry" on the event, or "Retry failed in the last 7 days". A manual retry starts a new cycle of 10 attempts with the same id.
An event that isn't delivered within 7 days of being created, for example while the endpoint is paused, is canceled as not delivered within 7 days. You can retry it from the log too. The log keeps deliveries for 30 days.
#Automatic disabling
While an endpoint is failing, its card shows that there are errors. The endpoint is disabled automatically:
- right after a
410response; - if failures continue for more than 3 days and include at least 10 failed attempts in a row. Any 2xx response resets the count.
A disabled endpoint doesn't queue new events, and they won't be delivered. Fix the receiving side and click "Enable". Collect events from the downtime through the feed or by reconciling leads.
#Delivery guarantees
- At least once. The same event can arrive more than once, for example if your response was lost on the network. Deduplicate by
id(the same aswebhook-id) and answer a duplicate with 2xx. - No ordering guarantee.
lead.repliedcan arrive beforelead.first_message_sent. Rely ontimestampand only move CRM statuses forward, as described in CRM mapping. - No delivery time guarantee. Events are collected every few seconds and sent right after collection. Some events are found later, during reconciliation, and arrive late with an earlier
timestamp. - Currently, an endpoint receives at most one request at a time. Don't rely on this for ordering: retries are interleaved with new events.
#Rotate the signing secret
Rotate the secret if it may have leaked, or on your own schedule.
- On the endpoint card, click "Signing secret", then "Rotate secret". Copy the new secret.
- The previous secret stays valid for 24 hours. During that time, every request carries two signatures: one with the new secret and one with the previous secret. A receiver that still has the previous secret keeps working.
- Replace the secret in your system within 24 hours. For a switch without a single error, you can accept both secrets during that time: the code above takes a list.
You can rotate again right away. The oldest secret then stops working immediately, the current one becomes the previous one, and the 24 hours start over. At most two secrets are valid at once.
#Test events
The "Test" button on an active endpoint's card sends a test event. You can press it at most once every 10 seconds. The result appears in the log.
A test event is signed and retried like a real one. The differences:
typeiswebhook.testandtestistrue;datahas the shape oflead.replied, anddata.example_ofis"lead.replied";- IDs in
dataare random, and the username isexample_lead; data.lead.fieldscontains the column names of your recent leads with a placeholder value; real values are never sent;data.reply.textwith sample text is present only when "Send reply text" is on.
A test event is handy for mapping fields, for example in an automation service. In production processing, skip events with test: true.
Sample values in a test event, such as workspace and campaign names, can be in Russian. Don't rely on them: they are placeholders.
#Pause, edit and delete
- Pause. The "Pause" button stops delivery. New events for the endpoint keep queuing and are sent after you click "Resume". An event that waits longer than 7 days is canceled, and you can retry it from the log.
- Edit. The "Edit" button changes the URL, events, scope, reply text and headers. If you narrow the events or the scope, queued events that no longer match are canceled.
- Delete. Click "Edit", then "Delete endpoint", and confirm. Queued events won't be sent, and the signing secrets are deleted.
#Delivery log
The endpoint card shows how many events were delivered and how many failed in the last 24 hours, how many are waiting, and how the last delivery ended. The log button opens the list of deliveries with their status (waiting, sending, delivered, not delivered or canceled), response code, attempt number and the time of the next attempt. A filter keeps only failed and waiting deliveries.
A 2xx status in the log means your endpoint accepted the request. What happened to the event after that is visible only in your own system's logs.
#Troubleshooting
| In the log | Cause and what to do |
|---|---|
| The URL isn't valid | Not https, a non-443 port, or an IP address instead of a hostname. Fix the URL. |
| The server wasn't found by name | The hostname doesn't resolve in DNS. Check your domain's records. |
| The URL points to an internal network | The hostname resolves to a private or reserved address. Use a public address. |
| The server refused the connection | Port 443 is closed or a firewall drops the requests. Open the port: Meliora server addresses aren't published, so you can't allowlist them. |
| A secure connection error | The certificate has expired, is self-signed, was issued for another name, or the chain is incomplete. |
| The server didn't respond in time | The response took longer than 10 seconds. Respond right after storing the event. |
| The server returned an error, code 3xx | A redirect, for example from http to https or to a URL with a trailing slash. Use the final URL. |
| The server returned an error, code 401 or 403 | Your service didn't accept the key. Check the additional headers. |
| The server returned an error, code 400 | Often this is your signature check. See below. |
| The event is too large | The body exceeds 256 KiB even without the reply text and lead fields. Contact Support. |
If the signature doesn't match, check in this order: the signature is computed over the raw body, not re-serialized JSON; you use this endpoint's secret and decode the whole part after whsec_ from base64; nobody rotated the secret; your server clock is correct. Compare your code with the test data.
If the log is empty although messages and replies are happening: the endpoint was created after those facts, the event type isn't selected, the campaign is outside the endpoint's scope, or the endpoint is paused or disabled.