Webhook signatures
Lantfly signs every webhook delivery, so your endpoint can reject requests that didn't come from Lantfly or were changed on the way. To set up webhooks and choose events, see Webhooks.
Headers
Each delivery is an HTTPS POST with a JSON body and these headers:
| Header | Example | Description |
|---|---|---|
Content-Type | application/json | Always JSON. |
User-Agent | Lantfly-Webhooks/1 | Identifies Lantfly. |
X-Lantfly-Event | ACTOR.RUN.SUCCEEDED | The event type. |
X-Lantfly-Webhook-Dispatch-Id | 9b2e… | ID of this delivery. Retries of the same delivery keep it, so use it to drop duplicates. |
X-Lantfly-Signature | sha256=5f0c… | HMAC-SHA256 of the raw body with the webhook's secret, as lowercase hex, after sha256=. |
Headers you added to the webhook are sent too, but they can't replace the ones above.
The signing secret
Each webhook has its own secret. It's shown once: in the answer that creates the webhook (secret), and in the
answer to POST /webhooks/{id}/rotate-secret. Store it with your other secrets.
curl -X POST https://lantfly.com/api/v3/webhooks/$WEBHOOK_ID/rotate-secret \
-H "Authorization: Bearer $LANTFLY_TOKEN"
Rotation takes effect at once: deliveries after it are signed with the new secret. REST hook subscriptions (Zapier, Make, n8n) also get a secret when they subscribe and are signed the same way.
Webhooks attached to a single run (the webhooks field when you start a run) don't return their secret. To
authenticate those, add your own header, such as a shared token, in the webhook's headers.
Verify a delivery
- Read the raw request body as bytes, before any JSON parsing.
- Compute HMAC-SHA256 of those bytes with the secret, as lowercase hex.
- Compare
sha256=plus your hex withX-Lantfly-Signaturein constant time. - Reject the request (for example with
401) if they differ. Otherwise answer2xxquickly and do slow work after.
Sign the bytes you received. Parsing and re-serializing the JSON changes spacing and escaping, and the signature won't match.
- Node.js
- Python
- PHP
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const secret = process.env.LANTFLY_WEBHOOK_SECRET;
function isValid(rawBody, header) {
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(header ?? '');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// express.raw keeps the body as a Buffer, exactly as sent.
app.post('/lantfly/webhook', express.raw({ type: 'application/json' }), (req, res) => {
if (!isValid(req.body, req.get('X-Lantfly-Signature'))) {
return res.status(401).send('bad signature');
}
const event = JSON.parse(req.body.toString('utf8'));
res.sendStatus(204); // answer first
console.log(req.get('X-Lantfly-Event'), req.get('X-Lantfly-Webhook-Dispatch-Id'), event.resource?.id);
});
app.listen(3000);
import hashlib
import hmac
import os
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["LANTFLY_WEBHOOK_SECRET"].encode()
def is_valid(raw_body: bytes, header: str | None) -> bool:
expected = "sha256=" + hmac.new(SECRET, raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header or "")
@app.post("/lantfly/webhook")
def lantfly_webhook():
raw = request.get_data() # raw bytes, before parsing
if not is_valid(raw, request.headers.get("X-Lantfly-Signature")):
abort(401)
event = request.get_json()
print(request.headers["X-Lantfly-Event"], event.get("event_type"))
return "", 204
<?php
$secret = getenv('LANTFLY_WEBHOOK_SECRET');
$raw = file_get_contents('php://input'); // raw bytes, before json_decode
$header = $_SERVER['HTTP_X_LANTFLY_SIGNATURE'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $raw, $secret);
if (! hash_equals($expected, $header)) {
http_response_code(401);
exit('bad signature');
}
$event = json_decode($raw, true);
http_response_code(204);
error_log(($_SERVER['HTTP_X_LANTFLY_EVENT'] ?? '') . ' ' . ($event['event_type'] ?? ''));
Payload
Unless the webhook has a payload template, the body is:
{
"user_id": 4821,
"created_at": "2026-11-03T08:15:30.123Z",
"event_type": "ACTOR.RUN.SUCCEEDED",
"event_data": {
"actor_id": "1a7d…",
"task_id": null,
"run_id": "6f1c…"
},
"resource": {
"id": "6f1c…",
"actor_id": "1a7d…",
"status": "SUCCEEDED",
"default_dataset_id": "c2f3…",
"default_key_value_store_id": "e91a…"
}
}
resource is the run as GET /runs/{id} returns it (shortened above).
| Run event | Sent when |
|---|---|
ACTOR.RUN.CREATED | A run was created. |
ACTOR.RUN.SUCCEEDED | A run finished successfully. |
ACTOR.RUN.FAILED | A run failed. |
ACTOR.RUN.TIMED_OUT | A run hit its timeout. |
ACTOR.RUN.ABORTED | A run was aborted. |
ACTOR.RUN.RESURRECTED | A finished run was resurrected. |
Social listening events
Webhooks for a listening project (condition project_id) send:
{
"id": "918273",
"created_at": "2026-11-03T08:16:02.000Z",
"event_type": "LISTENING.MENTION.CREATED",
"event_data": {
"project_id": "0b6e…",
"mention_id": 918273
},
"resource": {
"id": 918273,
"platform": "tiktok",
"project_id": "0b6e…",
"project": "Brand monitoring"
}
}
| Listening event | event_data | Sent when |
|---|---|---|
LISTENING.MENTION.CREATED | project_id, mention_id | A new mention was collected (hidden and irrelevant ones are skipped). |
LISTENING.ALERT.FIRED | project_id, alert_id (the alert event), rule_id, mention_id | An alert rule fired. |
resource is the mention or alert event as the listening API shows it, plus project_id and project (the project's
name). Listening events go out once a minute, one delivery per mention or alert, at most 25 per webhook each minute,
once the mention is about two minutes old. A new subscription starts after the latest mention or alert, so it doesn't
replay history. See Social listening.
Test deliveries
POST /webhooks/{id}/test sends one delivery now, with event type TEST: the account's latest run as the sample
resource, or a bare event_type, created_at and message when the account has no run yet. Tests are signed like
real deliveries and are tried once, without retries.
Payload templates
A webhook can set a payload template. Lantfly fills each {{path}} from the default body above, for example
{{resource.default_dataset_id}} or {{event_data.run_id}}, and signs the body it sends.
Retries and redelivery
-
A delivery succeeds when your endpoint answers any
2xxwithin 30 seconds. Redirects aren't followed. -
Otherwise Lantfly retries up to 5 more times, after 1 s, 5 s, 25 s, 125 s and 625 s (about 13 minutes in all).
-
After the last attempt the delivery is
FAILED. The webhook showslast_deliveryandfailed_deliveries. -
Send a
FAILEDdelivery again with:Redelivercurl -X POST https://lantfly.com/api/v3/webhooks/$WEBHOOK_ID/dispatches/$DISPATCH_ID/redeliver \-H "Authorization: Bearer $LANTFLY_TOKEN"It answers
202. A delivery that isn'tFAILEDanswers422. Limit: 30 a minute.
Because of retries, your endpoint can receive the same delivery more than once. Make the handler idempotent: store
X-Lantfly-Webhook-Dispatch-Id and skip IDs you've already handled.
Webhook URLs must be public. Addresses on private or local networks are refused at delivery time.