Skip to main content

Headers and Signatures

Request headers​

Every delivery attempt includes the canonical identification headers below. Signature headers are present when the Application has a webhook signing secret.

HeaderDescription
X-MiniVoice-Event-IDStable logical event ID. It matches the body id.
X-MiniVoice-Event-TypeEvent type. It matches the body type.
X-MiniVoice-AttemptOne-based attempt number for this event.
X-MiniVoice-Attempt-IDUnique ID for this delivery attempt. Do not use it for business idempotency.
X-MiniVoice-TimestampUnix timestamp used in the signature input. Generated for each signed attempt.
X-MiniVoice-Signaturev1= followed by the lowercase hexadecimal HMAC-SHA256 digest.

Requests also use Content-Type: application/json.

Deprecated compatibility headers​

X-MiniVoice-Event mirrors X-MiniVoice-Event-Type, and X-MiniVoice-Delivery-Id mirrors X-MiniVoice-Attempt-ID. They remain for compatibility with pilot integrations. New integrations should use the canonical headers.

Signing contract​

MiniVoice signs these exact bytes:

timestamp + "." + exact_raw_body

using HMAC-SHA256 and the webhook signing secret configured for the Application.

Read timestamp from X-MiniVoice-Timestamp and the received signature from X-MiniVoice-Signature. Verify the exact raw request body before parsing JSON. Parsing and reserializing JSON can change whitespace or key ordering and invalidate the signature.

The examples below also reject timestamps more than five minutes from the receiver's clock as an application-level replay defense.

Node.js / JavaScript​

import crypto from "node:crypto";

export function verifyMiniVoice(rawBody, headers, secret) {
const timestamp = headers["x-minivoice-timestamp"];
const received = headers["x-minivoice-signature"];
if (!timestamp || !received?.startsWith("v1=")) return false;

const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > 300) return false;

const expected = "v1=" + crypto
.createHmac("sha256", secret)
.update(timestamp)
.update(".")
.update(rawBody)
.digest("hex");

const actualBytes = Buffer.from(received);
const expectedBytes = Buffer.from(expected);
return actualBytes.length === expectedBytes.length &&
crypto.timingSafeEqual(actualBytes, expectedBytes);
}

Pass rawBody as a Buffer, not parsed JSON.

Python​

import hashlib
import hmac
import time


def verify_minivoice(raw_body: bytes, headers, secret: str) -> bool:
timestamp = headers.get("X-MiniVoice-Timestamp")
received = headers.get("X-MiniVoice-Signature")
if not timestamp or not received or not received.startswith("v1="):
return False

try:
if abs(time.time() - int(timestamp)) > 300:
return False
except ValueError:
return False

signed = timestamp.encode() + b"." + raw_body
digest = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(received, "v1=" + digest)

Go​

package webhooks

import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"net/http"
"strconv"
"strings"
"time"
)

func VerifyMiniVoice(rawBody []byte, r *http.Request, secret string) bool {
timestamp := r.Header.Get("X-MiniVoice-Timestamp")
received := r.Header.Get("X-MiniVoice-Signature")
if timestamp == "" || !strings.HasPrefix(received, "v1=") {
return false
}

seconds, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil || time.Since(time.Unix(seconds, 0)) > 5*time.Minute ||
time.Until(time.Unix(seconds, 0)) > 5*time.Minute {
return false
}

mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp))
mac.Write([]byte("."))
mac.Write(rawBody)
expected := "v1=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(received), []byte(expected))
}

Return a non-success status for a missing or invalid signature, and do not process the JSON body. See Retries and Idempotency for the resulting delivery behavior.