Build on Meles
Webhooks
Meles sends events to an HTTPS endpoint of yours: a response completed, a campaign published, paused or archived. Every delivery is signed under the Standard Webhooks specification, version 1.0.0, symmetric (v1). Verify the signature before you trust the body: anyone can send your endpoint a request. A response Meles flags as likely a script’s, such as a crawler’s or one of too many new responses from one address in an hour, sends no response.completed; it stays in your results, marked Likely bot.
Setting up an endpoint
- Create it in the dashboard, Settings → Integrations, Add endpoint; or with
create_webhookover MCP, orPOST /v1/webhooks. It needs anhttps://URL on a public address, and the event types it receives. An owner or an admin may, or a key with theadminscope. - Store the secret it returns. It looks like
whsec_followed by base64, and it is shown this once. Meles keeps it encrypted and shows only its last four characters again. - Send a test with Send test event on the endpoint’s page,
test_webhookorPOST /v1/webhooks/{id}/test. It sends one signedwebhook.testevent and tells you the status code your endpoint answered and how long it took. - At most 10 endpoints per organisation, on every plan.
Meles never sends to http://, to localhost, or to a private, loopback, link-local or reserved address, whether it is written in the URL or is what the host name resolves to. It follows no redirect, sends no cookie, and gives up after 15 seconds.
What a delivery looks like
A POST with a JSON body and three headers:
| Header | What it holds |
|---|---|
webhook-id |
The message’s ID, msg_ and 32 hex characters. The same on every retry of the same event: deduplicate on it |
webhook-timestamp |
When this attempt was signed, in Unix seconds |
webhook-signature |
v1, and the base64 of an HMAC-SHA256, one per secret, separated by spaces |
The User-Agent is Meles-Webhooks/1.0. The body is an envelope:
{"type":"webhook.test","timestamp":"2026-10-01T12:00:00.000Z","data":{"webhookId":"0199a0b9-1f2e-7a3b-8c4d-5e6f7a8b9c0d"}}type |
data |
|---|---|
response.completed |
responseId, campaignId, campaignVersion, siteId, userId (yours, or null) and answers, keyed by question |
campaign.published |
campaignId, siteId, name, campaignType and the version now live |
campaign.paused |
The same, with the version that was live |
campaign.archived |
The same, the version null if it was never published |
webhook.test |
The webhookId it was sent to |
timestamp is when the event happened, not when it was sent. Tolerate a property you don’t know in data, and an event type you don’t know: Meles adds both without notice, and never renames or removes either within a major version.
Verifying a delivery
The signed content is <webhook-id>.<webhook-timestamp>.<body>: the body exactly as it arrived, before you parse it. The key is the part of your secret after whsec_, base64-decoded. A delivery verifies when any one signature in webhook-signature matches, and its timestamp is within five minutes of your clock either way. Compare in constant time.
Read the raw body. A framework that parses JSON first and serialises it again changes the bytes, and the signature no longer matches.
Node
With the specification’s reference library, npm install standardwebhooks:
import { Webhook } from 'standardwebhooks';
/**
* The event, parsed, when `rawBody` was signed by Meles with `secret` within five minutes;
* otherwise it throws WebhookVerificationError. Pass the body exactly as it arrived, before any
* JSON parsing, and the request's headers: webhook-id, webhook-timestamp and webhook-signature.
*/
export function verifyWebhook(rawBody, headers, secret) {
return new Webhook(secret).verify(rawBody, headers);
}In an Express receiver:
app.post('/webhooks/meles', express.raw({ type: 'application/json' }), (req, res) => {
let event;
try {
event = verifyWebhook(req.body, req.headers, process.env.MELES_WEBHOOK_SECRET);
} catch {
return res.sendStatus(400);
}
// Deduplicate on req.headers['webhook-id'], then handle event.type.
res.sendStatus(204);
});Python
With the reference library, pip install standardwebhooks:
from standardwebhooks import Webhook
def verify_webhook(raw_body: bytes, headers: dict, secret: str):
"""The event, parsed, when raw_body was signed by Meles with secret within five minutes.
Otherwise raises standardwebhooks.WebhookVerificationError. Pass the body exactly as it
arrived, before any JSON parsing, and the request's headers: webhook-id,
webhook-timestamp and webhook-signature.
"""
return Webhook(secret).verify(raw_body, headers)In a Flask receiver:
@app.post("/webhooks/meles")
def meles():
try:
event = verify_webhook(request.get_data(), dict(request.headers), os.environ["MELES_WEBHOOK_SECRET"])
except WebhookVerificationError:
return "", 400
# Deduplicate on request.headers["webhook-id"], then handle event["type"].
return "", 204C#
With the base class library alone. Svix’s package verifies the same headers if you’d rather take a dependency (dotnet add package Svix, then new Webhook(secret).Verify(body, headers)).
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
/// <summary>Verifies a Meles webhook: copy this class into your receiver.</summary>
internal static class MelesWebhooks
{
/// <summary>How far a webhook's timestamp may be from your clock, either way.</summary>
public static readonly TimeSpan Tolerance = TimeSpan.FromMinutes(5);
/// <summary>
/// True when <paramref name="body"/>, the request's body exactly as it arrived, was signed by
/// Meles with <paramref name="secret"/> (whsec_…) within <see cref="Tolerance"/> of
/// <paramref name="now"/>. Parse the JSON only after this returns true.
/// </summary>
public static bool Verify(byte[] body, IReadOnlyDictionary<string, string> headers, string secret, DateTimeOffset now)
{
if (!headers.TryGetValue("webhook-id", out var id)
|| !headers.TryGetValue("webhook-timestamp", out var sent)
|| !headers.TryGetValue("webhook-signature", out var signatures)
|| !long.TryParse(sent, NumberStyles.None, CultureInfo.InvariantCulture, out var seconds)
|| (now - DateTimeOffset.FromUnixTimeSeconds(seconds)).Duration() > Tolerance)
{
return false;
}
var key = Convert.FromBase64String(secret.StartsWith("whsec_", StringComparison.Ordinal) ? secret[6..] : secret);
var signed = Encoding.UTF8.GetBytes($"{id}.{sent}.").Concat(body).ToArray();
var expected = HMACSHA256.HashData(key, signed);
foreach (var signature in signatures.Split(' '))
{
if (signature.StartsWith("v1,", StringComparison.Ordinal)
&& TryDecode(signature[3..], out var given)
&& CryptographicOperations.FixedTimeEquals(expected, given))
{
return true;
}
}
return false;
}
private static bool TryDecode(string base64, out byte[] bytes)
{
bytes = new byte[base64.Length];
if (Convert.TryFromBase64String(base64, bytes, out var written))
{
bytes = bytes[..written];
return true;
}
return false;
}
}In a minimal API:
app.MapPost("/webhooks/meles", async (HttpRequest request) =>
{
using var buffer = new MemoryStream();
await request.Body.CopyToAsync(buffer);
var headers = request.Headers.ToDictionary(header => header.Key.ToLowerInvariant(), header => header.Value.ToString());
if (!MelesWebhooks.Verify(buffer.ToArray(), headers, secret, DateTimeOffset.UtcNow))
{
return Results.BadRequest();
}
// Deduplicate on headers["webhook-id"], then parse the JSON.
return Results.NoContent();
});Test vectors
HMAC-SHA256 is in every standard library, so any language can verify. The samples above are checked against these signed deliveries on every change to them: each verifies, and each fails after one changed body byte, with the wrong secret, or past the five-minute tolerance. The first is the specification’s own, and the rest are Meles’s; all of them, with their bodies, are in webhook-signing.json.
| Vector | Secret | webhook-id | webhook-timestamp | webhook-signature |
|---|---|---|---|---|
| specification | whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw | msg_p5jXN8AQM9LWM0D4loKWxJek | 1614265330 | v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE= |
| test-event | whsec_TWVsZXMgZXhhbXBsZSB3ZWJob29rIHNlY3JldCAwMDE= | msg_0199a0b9c3d47e8f9a0b1c2d3e4f5a6b | 1790856000 | v1,f6ewB/i3/NRM6G/8QO8JQHEZoA10OPzs69nUR5JC47I= |
| rotation | whsec_TWVsZXMgZXhhbXBsZSB3ZWJob29rIHNlY3JldCAwMDI=whsec_TWVsZXMgZXhhbXBsZSB3ZWJob29rIHNlY3JldCAwMDE= | msg_0199a0b9c3d47e8f9a0b1c2d3e4f5a6b | 1790856000 | v1,0qhul7yj+SY+NsJD4SGLi5DDn0OLxVAbQG3JnMlzM2o=v1,f6ewB/i3/NRM6G/8QO8JQHEZoA10OPzs69nUR5JC47I= |
| non-ascii | whsec_TWVsZXMgZXhhbXBsZSB3ZWJob29rIHNlY3JldCAwMDE= | msg_0199a0c17a3b7c2d9e4f1a2b3c4d5e6f | 1790846048 | v1,gTdo7xYAs8atHz9KjHnoJkY9/+xU/EE6EfVIJuGzPpE= |
Rotating a secret
Rotate secret on the endpoint’s page, rotate_webhook_secret (with confirm: true) or POST /v1/webhooks/{id}/rotate-secret issues a new secret, shown once. For the next 24 hours every delivery carries two signatures, the new secret’s and the old one’s, so your receiver verifies whichever it holds. Put the new secret in place within those 24 hours: after them only the new one signs. Rotating again inside the window ends the older secret at once.
Answering
Answer any 2xx within 15 seconds, and do the work afterwards. Deliveries arrive at least once, and in no particular order.
- A failure is retried with the same
webhook-id: ten attempts in all, the first at once and the last about three days later. ARetry-Afterheader on your answer can lengthen a wait, up to the next one in the schedule. A redirect is a failure, and is never followed. 410 Goneturns the endpoint off, and so do five days of failures without a success; deliveries still waiting then fail, and your organisation’s owner is emailed. An endpoint that is off stays off: fix it, then delete it and add it again, with a new secret.- Every attempt is in the endpoint’s delivery log, in the dashboard or with
list_deliveries, andreplay_deliverysends one again.