Skip to main content

Webhook request signing and JSON delivery

When you create a webhook, you can choose to have Elastic Email sign every request it sends you, and to receive event data as a POST request with a JSON body. This article explains what both options do, how to turn them on and how to verify signatures on.

Written by Support Team

What signing and JSON delivery are

Request signing. Every request Elastic Email sends to your endpoint carries an HMAC-SHA256 signature. Your endpoint can use it to confirm two things: that the request really came from Elastic Email, and that nothing in it was changed on the way. Without signing, anyone who learns your webhook URL can send your endpoint fake events, and your endpoint has no way to tell them apart from real ones.

POST and JSON delivery. Events are sent as a POST request with a JSON body, instead of a GET request with the event in the query string. The body is a flat JSON object that your application can parse directly, with no URL decoding. The field names and the values are the same as the GET query parameters, so moving an existing receiver over takes few changes. For signed webhooks this is also the more reliable choice, because the signed data sits in the body rather than in the URL, where proxies and frameworks can re-encode it.

A few things to know before you start:

  • Both options are optional. If you do not turn them on, your webhooks work exactly as before: unsigned GET requests.

  • Both options are set only when you create a webhook, and cannot be changed afterwards. This is deliberate: it means a receiver that is already running in production cannot stop working because someone changed a setting.

  • The two options are independent. You can sign a GET webhook, or send an unsigned POST.

  • Your existing webhooks are not affected. To move one of them to the new settings, see "Switching an existing webhook to the new settings" below.

Turning the options on when you create a webhook

In your dashboard

The options are available when you create a webhook in both Email Marketing and Email API.

  1. Open the form for creating a new webhook, then enter the webhook name, the URL of your endpoint, which should be an https:// URL, and the events you want to receive.

  2. In the "Delivery and security" section, choose how events should be delivered: as a GET request with a query string, or as a POST request with a JSON body.

  3. In the same section, turn on "Sign requests" if you want every request to carry a signature.

  4. Save the webhook.

  5. Copy the signing secret that is shown after the webhook is created, and store it somewhere safe. It is shown only once.

Signed webhooks and webhooks that use POST do not follow redirects, so the URL you enter has to be the final one. A 3xx answer counts as a failed delivery.

Using the API

You can set both options when you create a webhook through the API, with the EnableSigning and DeliveryMethod fields:

POST /v4/webhook

{

"Name": "Deliveries",

"URL": "https://example.com/hooks/elastic?v=2",

"NotificationForSent": true,

"NotificationForError": true,

"EnableSigning": true,

"DeliveryMethod": "PostJson"

}

  • DeliveryMethod is either Get, which is the default, or PostJson. Any other value is rejected with a 400 response.

  • EnableSigning defaults to false.

  • The response contains the webhook, including SigningEnabled, DeliveryMethod and, only in this one response, SigningSecret. The secret starts with whsec_.

  • When you read the webhook later, SigningEnabled and DeliveryMethod are returned as set, and SigningSecret is always null.

  • PUT /v4/webhook/{id} does not accept EnableSigning or DeliveryMethod, because neither can be changed after the webhook is created.

The test request sent when a webhook is created

When you create a webhook, or change the URL of an existing one, Elastic Email sends a test request with sample data to that URL, using the webhook's delivery method and signing. The URL is accepted when your endpoint answers with a 2xx status code.

  • When a signed webhook is created, 401 and 403 are accepted as well. Your endpoint cannot know the new secret yet, so an endpoint that verifies signatures correctly will reject this first request.

  • When you change the URL of an existing webhook, your endpoint already has the secret. It has to verify the request and answer 2xx, and 401 and 403 are not accepted.

The test request carries sample data: an "opened" event for test@test.com. It is signed in the same way as a real request, so do not treat it as a real event.

Your signing secret

The signing secret is the key your endpoint uses to check signatures, so it has to stay private. It is shown only once: in your dashboard right after the webhook is created, and in the API response that creates the webhook. Elastic Email never shows it again. If you lose it, rotate it.

  • Store the secret in a secret manager or an environment variable. Never put it in source control, in logs, or in client-side code.

  • Only one secret is active per webhook at a time. If you need different secrets, create several webhooks.

  • If several webhooks send events to the same endpoint, use the X-ElasticEmail-Webhook-Id header to pick the right secret.

Rotating the secret

Rotating replaces the current secret with a new one. Use it when the secret may have leaked, when you lost it, or as part of regular key rotation. It works only for webhooks created with signing, and it needs the same access level as editing webhooks.

  • In your dashboard, use "Rotate secret" on the webhook.

  • Through the API, call POST /v4/webhook/{id}/secret/rotate. For a webhook that was not created with signing, the call returns a 400 response.

The new secret is returned only in that one response, in the same way as when the webhook was created. What to expect after rotating:

  • There is no grace period. The old secret stops working immediately, and only one secret is active at a time. It can take up to about a minute until all Elastic Email servers sign with the new secret.

  • Update the secret stored on your side as soon as you rotate. Until you do, your endpoint rejects incoming requests.

  • You do not lose events in between. Requests your endpoint rejects with 401 while it still has the old secret are retried for up to 24 hours, and every retry is signed with the current secret.

  • Rotating does not stop a leaked secret from being used elsewhere. Only your endpoint can do that, by switching to the new secret straight after you rotate.

Verifying a signature

Signed requests carry three extra headers. Unsigned webhooks do not get them.

  • X-ElasticEmail-Timestamp: Unix time in seconds when the request was sent.

  • X-ElasticEmail-Signature: the signature, in the form v1=<hex>, in lowercase hexadecimal. Treat the value as a comma-separated list and accept the request if any v1= entry matches. The version prefix leaves room for new algorithms later, so ignore any entry you do not recognize.

  • X-ElasticEmail-Webhook-Id: the ID of the webhook. Use it to pick the right secret when several webhooks send events to the same endpoint.

A signed request with JSON delivery looks like this:

POST /hooks/elastic?v=2 HTTP/1.1

Host: example.com

Content-Type: application/json; charset=utf-8

X-ElasticEmail-Timestamp: 1790000000

X-ElasticEmail-Signature: v1=5c2e...9af1

X-ElasticEmail-Webhook-Id: 8a0f3a52-4b8e-4c8e-9a53-1f7c2a3b4c5d

{"transaction":"d4f5...","to":"jane@example.com","from":"news@yourbrand.com","date":"9/29/2026 1:02:03 PM","status":"Opened","channel":"September newsletter","account":"you@yourbrand.com","category":"Unknown","ip":"203.0.113.7","country":"Poland","subject":"Our September news","messageid":"Xy2a..."}

Any query string you put in the webhook URL, for example ?v=2, is kept as it is, and the event fields are only in the body.

How the signature is built

The signature is computed over a string made of four parts, joined by a line feed, that is \n, byte 0x0A:

{timestamp}\n{METHOD}\n{request target}\n{raw body}

  • timestamp: the value of the X-ElasticEmail-Timestamp header.

  • METHOD: GET or POST.

  • request target: the path and query string exactly as received on the request line, still URL-encoded, for example /hooks/elastic?v=2. No host, and no username or password. Most frameworks hand you a decoded or rebuilt URL by default, so use the raw one, as in the examples below.

  • raw body: the body bytes exactly as received. For a GET request the body is empty, so the string ends with the last line feed.

The signature itself is:

signature = hex(HMAC-SHA256(key = UTF-8 bytes of your secret, message = the string above)), in lowercase

Steps

  1. Read the raw body before you parse it as JSON. Parsing and re-serializing changes the bytes and breaks the signature. A GET request has no body, so reject a GET that arrives with one.

  2. Check the timestamp. Reject the request if it is more than 5 minutes away from your server's clock, and keep that clock synchronized with NTP, because a clock that is off makes valid requests look stale. This also limits replays to a 5-minute window.

  3. Compute the expected signature from the string above and your secret.

  4. Compare it in constant time against every v1= entry of the X-ElasticEmail-Signature header. Do not use a normal string comparison.

  5. If nothing matches, answer 401. The notification is retried, so nothing is lost while you fix a secret. Every retry is signed again with a fresh timestamp, so a request that waits in the retry queue for hours still passes the timestamp check.

Always verify before you trust or act on the content of a request, and answer quickly with 2xx, processing the event afterwards, so slow processing does not turn into retries. For signed webhooks we recommend POST delivery, because with GET the signed data sits in the query string, where proxies or frameworks that re-encode URLs can break verification.

Test vectors

Use these to check your implementation before you go live. The secret is whsec_test.

For POST, with timestamp 1700000000, method POST, request target /hook?x=1 and body {"transaction":"t1"}, the signature is:

3eddf396ecc5cc3256d20d0f31ec0b3ac351078e0214db59a4089a805e156da3

For GET, with timestamp 1700000000, method GET, request target /hook?transaction=t1&to=a%40b.com and an empty body, so that the string ends with a line feed, the signature is:

675c068436a0b4b249a89bef940c764c71d0331050b48ad7bc8425106d14e33c

You can reproduce both in a shell:

printf '%b' '1700000000\nPOST\n/hook?x=1\n{"transaction":"t1"}' | openssl dgst -sha256 -hmac 'whsec_test'

printf '%b' '1700000000\nGET\n/hook?transaction=t1&to=a%40b.com\n' | openssl dgst -sha256 -hmac 'whsec_test'

Example in C# (ASP.NET Core)

app.MapMethods("/hooks/elastic", new[] { "GET", "POST" }, async (HttpContext context) =>

{

string secret = config["ElasticEmail:WebhookSecret"];

string target = context.Features.Get<IHttpRequestFeature>()!.RawTarget; // path + query as received

string body = await new StreamReader(context.Request.Body, Encoding.UTF8).ReadToEndAsync();

if (!long.TryParse(context.Request.Headers["X-ElasticEmail-Timestamp"], out long timestamp)

|| Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - timestamp) > 300)

return Results.Unauthorized();

byte[] expected = Encoding.ASCII.GetBytes(Convert.ToHexStringLower(HMACSHA256.HashData(

Encoding.UTF8.GetBytes(secret),

Encoding.UTF8.GetBytes($"{timestamp}\n{context.Request.Method}\n{target}\n{body}"))));

bool valid = context.Request.Headers["X-ElasticEmail-Signature"].ToString().Split(',')

.Select(part => part.Trim())

.Any(part => part.StartsWith("v1=") && CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(part[3..]), expected));

if (!valid)

return Results.Unauthorized();

// body is safe to parse now

return Results.Ok();

});

Example in Node.js (Express)

const crypto = require('crypto');

const express = require('express');

const app = express();

// keep the raw body - don't use express.json() on this route

app.all('/hooks/elastic', express.raw({ type: '*/*' }), (req, res) => {

const secret = process.env.ELASTICEMAIL_WEBHOOK_SECRET;

const timestamp = Number(req.get('X-ElasticEmail-Timestamp'));

const body = Buffer.isBuffer(req.body) ? req.body.toString('utf8') : '';

if (!Number.isInteger(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > 300) return res.sendStatus(401);

// req.originalUrl is the path + query as received

const expected = Buffer.from(crypto.createHmac('sha256', secret)

.update(`${timestamp}\n${req.method}\n${req.originalUrl}\n${body}`)

.digest('hex'));

const valid = (req.get('X-ElasticEmail-Signature') || '').split(',')

.map(part => part.trim())

.filter(part => part.startsWith('v1='))

.some(part => {

const received = Buffer.from(part.slice(3));

return received.length === expected.length && crypto.timingSafeEqual(received, expected);

});

if (!valid) return res.sendStatus(401);

const event = req.method === 'POST' ? JSON.parse(body) : req.query;

res.sendStatus(200);

});

Example in Python (Flask)

import hashlib, hmac, os, time

from flask import Flask, request, abort

app = Flask(__name__)

@app.route("/hooks/elastic", methods=["GET", "POST"])

def elastic_webhook():

secret = os.environ["ELASTICEMAIL_WEBHOOK_SECRET"].encode()

timestamp = request.headers.get("X-ElasticEmail-Timestamp", "")

body = request.get_data(as_text=True) # raw body, read before parsing

# path + query as received (Werkzeug/Gunicorn provide RAW_URI, many servers REQUEST_URI)

target = request.environ.get("RAW_URI") or request.environ.get("REQUEST_URI")

if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:

abort(401)

expected = hmac.new(secret, f"{timestamp}\n{request.method}\n{target}\n{body}".encode(), hashlib.sha256).hexdigest()

received = [p.strip()[3:] for p in request.headers.get("X-ElasticEmail-Signature", "").split(",") if p.strip().startswith("v1=")]

if not any(hmac.compare_digest(expected, r) for r in received):

abort(401)

event = request.get_json() if request.method == "POST" else request.args

return "", 200

Example in PHP

<?php

$secret = getenv('ELASTICEMAIL_WEBHOOK_SECRET');

$timestamp = $_SERVER['HTTP_X_ELASTICEMAIL_TIMESTAMP'] ?? '';

$body = file_get_contents('php://input'); // raw body

$target = $_SERVER['REQUEST_URI']; // path + query as received

if (!ctype_digit($timestamp) || abs(time() - (int)$timestamp) > 300) {

http_response_code(401);

exit;

}

$expected = hash_hmac('sha256', "{$timestamp}\n{$_SERVER['REQUEST_METHOD']}\n{$target}\n{$body}", $secret);

$valid = false;

foreach (explode(',', $_SERVER['HTTP_X_ELASTICEMAIL_SIGNATURE'] ?? '') as $part) {

$part = trim($part);

if (str_starts_with($part, 'v1=') && hash_equals($expected, substr($part, 3))) {

$valid = true;

}

}

if (!$valid) {

http_response_code(401);

exit;

}

$event = $_SERVER['REQUEST_METHOD'] === 'POST' ? json_decode($body, true) : $_GET;

http_response_code(200);

Switching an existing webhook to the new settings

Signing and the delivery method cannot be switched on a webhook that already exists, so a receiver that is already running in production cannot break. To move an existing webhook over, create a new one next to it and remove the old one when the new one works:

  1. Create a new webhook with the settings you want and a different URL. An account cannot have two webhooks with the same URL, and only one webhook per URL receives events. The same endpoint with an extra query parameter works well, for example https://example.com/hooks/elastic?v=2.

  2. Save the secret shown after the webhook is created, and deploy verification on your endpoint. Keep accepting the old, unsigned requests on the old URL for now.

  3. Check that events arrive on the new webhook and that they verify. For a while you receive every event twice, once per webhook, so deduplicate by transaction and status if that matters to you.

  4. Delete the old webhook, then stop accepting unsigned requests on your endpoint.

For the basics of webhooks, including how to create one, which events you can be notified about, the full list of parameters we send with each notification, and how to turn a webhook off or delete it, see the article “Notification Settings”. If you are setting up your first webhook, start there and come back here for signing and JSON delivery.

If you need any further assistance, feel free to reach out to our Customer Support Team, available 24/7. We're always happy to help!

Did this answer your question?