Signature verification
Your webhook endpoint is public and can trigger changes in your system. Anyone who finds the URL can send it a request, so you need to verify that a webhook actually came from AletaPlanet before doing anything with it.
Signature verification is required for every webhook.
The signature header
Each delivery includes an Aleta-Signature header:
Aleta-Signature: t=1785508332,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
The header contains two values:
| Element | Meaning |
|---|---|
t | Unix timestamp, in seconds, when the signature was generated. |
v1 | The HMAC-SHA256 signature, encoded as hexadecimal. |
You may see more than one v1 value while a signing secret is being rotated.
Accept the request if any of the signatures matches.
How the signature is computed
The signed value is the timestamp, followed by a literal . and the raw request body:
signed_payload = t + "." + raw_body
signature = hex(hmac_sha256(signing_secret, signed_payload))
The signature is calculated from the exact request body that was sent.
Verify the signature against the original bytes. Don't parse the JSON and then serialize it again before verifying it.
Changing whitespace, key order, escaping, or formatting changes the bytes and therefore changes the signature.
If you're using Express, use express.raw() on the webhook route and make sure
it runs before any global express.json() middleware.
Verification steps
The basic flow is:
- Read
tand everyv1value from theAleta-Signatureheader. - Reject the request if
tis more than 5 minutes away from your current time. - Calculate the HMAC using
t + "." + raw_bodyand your webhook signing secret. - Compare the calculated signature with the received signatures using a constant-time comparison.
- Only after verification succeeds should you parse the body and process the event.
The timestamp check prevents replay attacks. Without it, someone who captures a valid webhook could send the same request to your endpoint over and over.
The constant-time comparison helps prevent timing attacks that could otherwise reveal the expected signature one byte at a time. Don't use normal string comparisons such as === or == for this.
Your signing secret is created with the endpoint in Cockpit under Developers → Webhook endpoints. It's shown only once.
The signing secret is different from your API access token. Don't use one in place of the other.
Implementations
- Node.js
- Python
- Go
- Java
- PHP
import crypto from 'node:crypto';
import express from 'express';
const TOLERANCE_SECONDS = 300;
function verify(rawBody, header, secret) {
const parts = Object.fromEntries(
header.split(',').map((kv) => kv.split('=').map((s) => s.trim())),
);
const timestamp = Number(parts.t);
if (!timestamp) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) {
return false;
}
const expected = crypto
.createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`, 'utf8')
.digest('hex');
const received = Buffer.from(parts.v1 ?? '', 'utf8');
const computed = Buffer.from(expected, 'utf8');
// timingSafeEqual throws on length mismatch, so check first.
return (
received.length === computed.length &&
crypto.timingSafeEqual(received, computed)
);
}
const app = express();
app.post(
'/webhooks/aleta',
express.raw({ type: 'application/json' }),
(req, res) => {
const raw = req.body.toString('utf8');
if (
!verify(
raw,
req.get('Aleta-Signature') ?? '',
process.env.ALETA_WEBHOOK_SECRET,
)
) {
return res.sendStatus(400);
}
queue.enqueue(JSON.parse(raw));
res.sendStatus(200);
},
);
import hashlib
import hmac
import os
import time
from flask import Flask, request
TOLERANCE_SECONDS = 300
app = Flask(__name__)
def verify(raw_body: bytes, header: str, secret: str) -> bool:
try:
parts = dict(kv.strip().split("=", 1) for kv in header.split(","))
timestamp = int(parts["t"])
except (ValueError, KeyError):
return False
if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
return False
expected = hmac.new(
secret.encode("utf-8"),
f"{parts['t']}.".encode("utf-8") + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
@app.post("/webhooks/aleta")
def handle():
raw = request.get_data()
header = request.headers.get("Aleta-Signature", "")
if not verify(raw, header, os.environ["ALETA_WEBHOOK_SECRET"]):
return "", 400
queue.enqueue(request.get_json())
return "", 200
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"math"
"net/http"
"os"
"strconv"
"strings"
"time"
)
const toleranceSeconds = 300
func verify(rawBody []byte, header, secret string) bool {
var ts, sig string
for _, part := range strings.Split(header, ",") {
kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
if len(kv) != 2 {
continue
}
switch kv[0] {
case "t":
ts = kv[1]
case "v1":
sig = kv[1]
}
}
timestamp, err := strconv.ParseInt(ts, 10, 64)
if err != nil {
return false
}
if math.Abs(float64(time.Now().Unix()-timestamp)) > toleranceSeconds {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(ts + "."))
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(sig))
}
func handler(w http.ResponseWriter, r *http.Request) {
raw, err := io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
if !verify(
raw,
r.Header.Get("Aleta-Signature"),
os.Getenv("ALETA_WEBHOOK_SECRET"),
) {
w.WriteHeader(http.StatusBadRequest)
return
}
enqueue(raw)
w.WriteHeader(http.StatusOK)
}
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.Arrays;
import java.util.HexFormat;
import java.util.Map;
import java.util.stream.Collectors;
public final class AletaWebhookVerifier {
private static final long TOLERANCE_SECONDS = 300;
public static boolean verify(
byte[] rawBody,
String header,
String secret
) throws Exception {
Map<String, String> parts = Arrays.stream(header.split(","))
.map(String::trim)
.map(kv -> kv.split("=", 2))
.filter(kv -> kv.length == 2)
.collect(Collectors.toMap(
kv -> kv[0],
kv -> kv[1],
(a, b) -> a
));
String ts = parts.get("t");
String signature = parts.get("v1");
if (ts == null || signature == null) {
return false;
}
long timestamp = Long.parseLong(ts);
if (Math.abs(Instant.now().getEpochSecond() - timestamp)
> TOLERANCE_SECONDS) {
return false;
}
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(
secret.getBytes(StandardCharsets.UTF_8),
"HmacSHA256"
));
mac.update((ts + ".").getBytes(StandardCharsets.UTF_8));
String expected = HexFormat.of().formatHex(mac.doFinal(rawBody));
return MessageDigest.isEqual(
expected.getBytes(StandardCharsets.UTF_8),
signature.getBytes(StandardCharsets.UTF_8)
);
}
}
<?php
const TOLERANCE_SECONDS = 300;
function aleta_verify(
string $rawBody,
string $header,
string $secret
): bool {
$parts = [];
foreach (explode(',', $header) as $piece) {
$kv = explode('=', trim($piece), 2);
if (count($kv) === 2) {
$parts[$kv[0]] = $kv[1];
}
}
if (!isset($parts['t'], $parts['v1'])) {
return false;
}
if (abs(time() - (int) $parts['t']) > TOLERANCE_SECONDS) {
return false;
}
$expected = hash_hmac(
'sha256',
$parts['t'] . '.' . $rawBody,
$secret
);
return hash_equals($expected, $parts['v1']);
}
$raw = file_get_contents('php://input');
$header = $_SERVER['HTTP_ALETA_SIGNATURE'] ?? '';
if (!aleta_verify(
$raw,
$header,
getenv('ALETA_WEBHOOK_SECRET')
)) {
http_response_code(400);
exit;
}
enqueue(json_decode($raw, true));
http_response_code(200);
Rotating the signing secret
You can rotate a signing secret without taking your webhook endpoint offline:
- Generate a second secret in Cockpit. Both secrets become active.
- During the overlap, deliveries are signed with both secrets and the header contains two
v1values. - Deploy code that accepts either signature.
- Retire the old secret.
The overlap period is what allows you to rotate safely without missing events.
Because the header can contain multiple v1 values, your parser should treat them as a list rather than assuming there will only ever be one.
Common mistakes
| Mistake | Symptom |
|---|---|
| Verifying against a parsed and re-serialized body | Every signature fails. |
| Mounting a global JSON parser before the webhook route | Every signature fails. |
Using == or === for the comparison | The comparison works, but isn't constant-time. |
| Skipping the timestamp check | Captured deliveries can be replayed. |
Treating v1 as a single value | Verification breaks during secret rotation. |
| Using the API access token as the signing secret | Every signature fails. |
Next
- Card issuing events — every event type and its payload.