Maly Maly Payments v1 Changelog Partners
Base URL Applied to every example. Stored only in this browser.
Operate

Verifying webhooks

Check the signature before you act on an event.

Every webhook carries a signature header. Your signing secret is provided during onboarding and is separate from your API key.

Signature header
X-Maly-Signature: t=1751020200,v1=<signature>

What the parts mean#

PartMeaning
tUnix timestamp of when the event was signed
v1The signature itself, for scheme version 1

How the signature is computed#

The signature is an HMAC-SHA256, keyed with your webhook signing secret, over the timestamp and the raw body joined by a dot. In other words the signed string is:

Signed payload
signed_payload = t + "." + raw_body
  • t is the timestamp from the X-Maly-Signature header (the value after t=).
  • raw_body is the exact bytes of the request body, before any JSON parsing or re-serialisation.
  • The two are joined by a single literal dot.

v1 in the header is that HMAC-SHA256, hex-encoded in lowercase. To verify, recompute it with your secret and compare it to the v1 value using a constant-time comparison.

Verification procedure#

  1. Capture the raw request body. Do not parse and re-serialise it first; any reordering or whitespace change breaks the signature.
  2. Parse t and v1 from the X-Maly-Signature header.
  3. Reject the event if t is more than 5 minutes old, to protect against replay.
  4. Compute HMAC-SHA256(secret, t + "." + raw_body) and hex-encode it.
  5. Compare your value to v1 with a constant-time comparison. Act on the payload only if they match.

Example#

Verify a webhook
const crypto = require("crypto");

// secret: your webhook signing secret (whsec_...)
// header: the raw value of the X-Maly-Signature header
// rawBody: the exact request body bytes, before any JSON parsing
function verifyWebhook(secret, header, rawBody) {
  const parts = Object.fromEntries(
    header.split(",").map(kv => kv.split("=").map(s => s.trim()))
  );
  const t = parts.t;
  const received = parts.v1;
  if (!t || !received) return false;

  // reject anything older than 5 minutes
  const age = Math.floor(Date.now() / 1000) - Number(t);
  if (!Number.isFinite(age) || age > 300) return false;

  const signedPayload = `${t}.${rawBody}`;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(signedPayload, "utf8")
    .digest("hex");

  // constant-time compare
  const a = Buffer.from(expected);
  const b = Buffer.from(received);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
import hmac, hashlib, time

def verify_webhook(secret: str, header: str, raw_body: bytes) -> bool:
    parts = dict(
        kv.strip().split("=", 1) for kv in header.split(",") if "=" in kv
    )
    t = parts.get("t")
    received = parts.get("v1")
    if not t or not received:
        return False

    # reject anything older than 5 minutes
    if abs(time.time() - int(t)) > 300:
        return False

    signed_payload = f"{t}.".encode() + raw_body
    expected = hmac.new(
        secret.encode(), signed_payload, hashlib.sha256
    ).hexdigest()

    # constant-time compare
    return hmac.compare_digest(expected, received)
<?php
// $secret:  your webhook signing secret (whsec_...)
// $header:  raw value of the X-Maly-Signature header
// $rawBody: exact request body string, before json_decode
function verify_webhook(string $secret, string $header, string $rawBody): bool {
    $parts = [];
    foreach (explode(",", $header) as $kv) {
        [$k, $v] = array_map("trim", explode("=", $kv, 2));
        $parts[$k] = $v;
    }
    $t = $parts["t"] ?? null;
    $received = $parts["v1"] ?? null;
    if (!$t || !$received) return false;

    // reject anything older than 5 minutes
    if (abs(time() - (int)$t) > 300) return false;

    $signedPayload = $t . "." . $rawBody;
    $expected = hash_hmac("sha256", $signedPayload, $secret);

    // constant-time compare
    return hash_equals($expected, $received);
}
!

Use the raw body. Most frameworks hand you a parsed object. You need the untouched bytes as received. In Express use express.raw() for the webhook route; in Flask use request.get_data(); in PHP read php://input. Verifying against a re-serialised body will fail even when the webhook is genuine.

Maly Payments API v1 · Documentation 1.1 · July 2026
Maly Tech Ltd. This guide is provided for information. Where it differs from your signed agreement, the agreement applies.