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_webhook over MCP, or POST /v1/webhooks. It needs an https:// URL on a public address, and the event types it receives. An owner or an admin may, or a key with the admin scope.
  • 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_webhook or POST /v1/webhooks/{id}/test. It sends one signed webhook.test event 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:

A webhook.test body
{"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:

verify.mjsChecked in CI against the test vectors below
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:

Your server
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:

verify.pyChecked in CI against the test vectors below
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:

Your server
@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 "", 204

C#

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)).

MelesWebhooks.csChecked in CI against the test vectors below
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:

Your server
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.

VectorSecretwebhook-idwebhook-timestampwebhook-signature
specificationwhsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSwmsg_p5jXN8AQM9LWM0D4loKWxJek1614265330v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=
test-eventwhsec_TWVsZXMgZXhhbXBsZSB3ZWJob29rIHNlY3JldCAwMDE=msg_0199a0b9c3d47e8f9a0b1c2d3e4f5a6b1790856000v1,f6ewB/i3/NRM6G/8QO8JQHEZoA10OPzs69nUR5JC47I=
rotationwhsec_TWVsZXMgZXhhbXBsZSB3ZWJob29rIHNlY3JldCAwMDI=
whsec_TWVsZXMgZXhhbXBsZSB3ZWJob29rIHNlY3JldCAwMDE=
msg_0199a0b9c3d47e8f9a0b1c2d3e4f5a6b1790856000v1,0qhul7yj+SY+NsJD4SGLi5DDn0OLxVAbQG3JnMlzM2o=
v1,f6ewB/i3/NRM6G/8QO8JQHEZoA10OPzs69nUR5JC47I=
non-asciiwhsec_TWVsZXMgZXhhbXBsZSB3ZWJob29rIHNlY3JldCAwMDE=msg_0199a0c17a3b7c2d9e4f1a2b3c4d5e6f1790846048v1,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. A Retry-After header 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 Gone turns 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, and replay_delivery sends one again.