TOMO Webhook Signing — HMAC-SHA256 Specification
Audience: every TOMO partner. Every closed-intent webhook MUST be signed. No exceptions in production.
Source-of-truth (server side): TOMO's CPC webhook layer — fully specified by this document. Partners do not need access to TOMO's source.
1. The contract
When the user completes the intent your tools served (delivery delivered, ride completed, hotel checked in, booking redeemed, etc.), you POST:
POST https://www.automobnxt.com/api/v1/cpc/mcp_provider/<your_account_id>
Host: www.automobnxt.com
Content-Type: application/json
X-TOMO-Timestamp: 1715257923000
X-TOMO-Signature: sha256=<64-char-hex>
{ "intent": "...", "external_id": "...", "amount_inr": 8400, ... }
Send all three:
X-TOMO-Timestamp— Unix epoch milliseconds at the moment you sent the requestX-TOMO-Signature— HMAC-SHA256 over${timestamp}.${rawBodyJSON}using yourwebhook_signing_key- Replay window — the timestamp must be within 5 minutes of TOMO's server clock (300_000 ms drift max)
Today TOMO still accepts a request without X-TOMO-Timestamp if the signature covers the body alone. That path has no replay check and will be removed — always send the timestamp.
Get the signature or the timestamp wrong → TOMO returns 401 with {"error":"signature_invalid","reason":"…"}. The reason is one of no_signing_key_on_file, missing_signature_header, invalid_timestamp, timestamp_outside_window, malformed_signature_header, signature_mismatch. No ledger entry is created.
2. Where do I get the signing key?
Your webhook_signing_key is issued once, by our team, for the webhook URL you give us after your application is approved (there is no self-serve partner dashboard yet):
webhook_url: (the one you gave us)
webhook_signing_key: tomo_whk_<random letters and digits, shown ONCE — copy now>
Like an API secret, the signing key is never displayed again. If you lose it, ask us for a fresh one (the old key stops working immediately).
Store it in your secrets manager (AWS Secrets Manager, GCP Secret Manager, Vault, etc.). Never commit it to source control.
3. The signing algorithm
plaintext = X-TOMO-Timestamp + "." + rawBodyJSON
signature = hex(HMAC-SHA256(webhook_signing_key, plaintext))
header = "sha256=" + signature
Key rules:
rawBodyJSONis the body you send. TOMO checks the signature against your parsed body re-serialised as compact JSON (the way JavaScript'sJSON.stringifywrites it), so send exactly that: no spaces, no pretty-printing, no trailing newline, keys in the order you send them, characters such as₹,<,>and&left unescaped, and whole numbers written without a decimal point (240, not240.0).X-TOMO-Timestampis the Unix epoch in milliseconds (not seconds, not ISO).- The
.between timestamp and body is a literal dot character. Do not URL-encode. signatureis hex — send it lowercase.- The
sha256=prefix is literal. Don't substitute or omit.
4. Examples in 3 languages
Node.js (TypeScript)
import crypto from 'node:crypto';
const TOMO_WEBHOOK_BASE = 'https://www.automobnxt.com/api/v1/cpc/mcp_provider';
interface CompletionPayload {
intent: string;
external_id: string;
amount_inr: number; // whole rupees
closed_at?: string;
notes?: string;
}
export async function postSignedCpcEvent(
partnerId: string,
signingKey: string,
payload: CompletionPayload,
): Promise<{ ok: boolean; status: number; body: any }> {
const bodyText = JSON.stringify(payload); // byte-exact, no pretty-print
const ts = Date.now().toString(); // Unix ms
const plaintext = `${ts}.${bodyText}`;
const signature = crypto
.createHmac('sha256', signingKey)
.update(plaintext)
.digest('hex'); // lowercase hex
const url = `${TOMO_WEBHOOK_BASE}/${partnerId}`;
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-TOMO-Timestamp': ts,
'X-TOMO-Signature': `sha256=${signature}`,
},
body: bodyText,
});
return {
ok: res.ok,
status: res.status,
body: await res.json().catch(() => null),
};
}
Python
import hmac
import hashlib
import json
import time
from typing import Any, Dict
import requests
TOMO_WEBHOOK_BASE = "https://www.automobnxt.com/api/v1/cpc/mcp_provider"
def post_signed_cpc_event(
partner_id: str,
signing_key: str,
payload: Dict[str, Any],
) -> Dict[str, Any]:
# Compact JSON, non-ASCII left as is — matches how TOMO re-serialises the body
body_text = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
ts = str(int(time.time() * 1000)) # Unix ms
plaintext = f"{ts}.{body_text}".encode("utf-8")
signature = hmac.new(
signing_key.encode("utf-8"),
plaintext,
hashlib.sha256,
).hexdigest() # lowercase hex
url = f"{TOMO_WEBHOOK_BASE}/{partner_id}"
response = requests.post(
url,
headers={
"Content-Type": "application/json",
"X-TOMO-Timestamp": ts,
"X-TOMO-Signature": f"sha256={signature}",
},
data=body_text.encode("utf-8"), # send the same UTF-8 bytes you signed
timeout=30,
)
return {
"ok": response.ok,
"status": response.status_code,
"body": response.json() if response.headers.get("Content-Type", "").startswith("application/json") else None,
}
Go
package tomo
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
const TomoWebhookBase = "https://www.automobnxt.com/api/v1/cpc/mcp_provider"
type CompletionPayload struct {
Intent string `json:"intent"`
ExternalID string `json:"external_id"`
AmountInr int `json:"amount_inr"`
ClosedAt string `json:"closed_at,omitempty"`
Notes string `json:"notes,omitempty"`
}
type CpcResponse struct {
OK bool `json:"ok"`
Status int `json:"status"`
Body interface{} `json:"body,omitempty"`
}
func PostSignedCpcEvent(partnerID, signingKey string, payload CompletionPayload) (CpcResponse, error) {
// Compact JSON with <, > and & left unescaped — matches how TOMO
// re-serialises the body (json.Marshal would escape them and break the signature)
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(payload); err != nil {
return CpcResponse{}, fmt.Errorf("marshal payload: %w", err)
}
bodyBytes := bytes.TrimRight(buf.Bytes(), "\n")
ts := fmt.Sprintf("%d", time.Now().UnixMilli())
plaintext := []byte(ts + "." + string(bodyBytes))
h := hmac.New(sha256.New, []byte(signingKey))
h.Write(plaintext)
signature := hex.EncodeToString(h.Sum(nil))
url := fmt.Sprintf("%s/%s", TomoWebhookBase, partnerID)
req, err := http.NewRequest("POST", url, bytes.NewReader(bodyBytes))
if err != nil {
return CpcResponse{}, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-TOMO-Timestamp", ts)
req.Header.Set("X-TOMO-Signature", "sha256="+signature)
client := &http.Client{Timeout: 30 * time.Second}
res, err := client.Do(req)
if err != nil {
return CpcResponse{}, fmt.Errorf("send request: %w", err)
}
defer res.Body.Close()
respBytes, _ := io.ReadAll(res.Body)
var bodyJSON interface{}
json.Unmarshal(respBytes, &bodyJSON)
return CpcResponse{
OK: res.StatusCode >= 200 && res.StatusCode < 300,
Status: res.StatusCode,
Body: bodyJSON,
}, nil
}
5. Verifying the signature on YOUR webhook (when TOMO calls you)
TOMO does not send notifications to your webhook URL yet (planned), so you do not need a verifier today. When it does, it will use the same scheme. A verifier for reference:
Node.js verifier
import crypto from 'node:crypto';
export function verifyTomoSignature(
rawBody: string, // exact bytes received
timestampHeader: string, // X-TOMO-Timestamp value
signatureHeader: string, // X-TOMO-Signature value (with sha256= prefix)
signingKey: string,
): { valid: boolean; reason: string } {
const timestampMs = parseInt(timestampHeader, 10);
if (!Number.isFinite(timestampMs)) {
return { valid: false, reason: 'invalid_timestamp' };
}
// 5-minute replay window
if (Math.abs(Date.now() - timestampMs) > 5 * 60 * 1000) {
return { valid: false, reason: 'timestamp_outside_window' };
}
const match = signatureHeader.match(/^sha256=([0-9a-f]{64})$/);
if (!match) return { valid: false, reason: 'malformed_signature_header' };
const provided = match[1];
const plaintext = `${timestampHeader}.${rawBody}`;
const expected = crypto.createHmac('sha256', signingKey).update(plaintext).digest('hex');
// Constant-time comparison to prevent timing attacks
if (!crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(provided, 'hex'))) {
return { valid: false, reason: 'signature_mismatch' };
}
return { valid: true, reason: 'ok' };
}
Critical: read the raw body bytes (e.g., via express.raw() middleware), not a re-stringified body. JSON parsing + restringifying changes whitespace and breaks the signature.
Express middleware example
import express from 'express';
const app = express();
// MUST come before express.json() for the webhook route
app.use('/tomo/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body.toString('utf-8');
const result = verifyTomoSignature(
rawBody,
req.header('X-TOMO-Timestamp') || '',
req.header('X-TOMO-Signature') || '',
process.env.TOMO_SIGNING_KEY!,
);
if (!result.valid) {
return res.status(401).json({ error: 'invalid_signature', reason: result.reason });
}
const payload = JSON.parse(rawBody);
// process payload...
res.json({ ok: true });
});
6. Common pitfalls
Signature passes locally, fails on TOMO
Most likely your JSON differs from the compact form TOMO re-serialises to (see §3): spaces after : or ,, escaped ₹ (Python's default ensure_ascii=True), escaped <, >, & (Go's json.Marshal), or 240.0 instead of 240. Pin the body bytes once and reuse them for signing and sending.
Replay window too tight
Servers with clock drift > 5 min get rejected. Sync via NTP. Cloud Run / AWS Lambda / GCP Functions are usually fine; bare-metal cron jobs may drift.
Hex case
TOMO lowercases the hex before it compares, so case is not the cause of a mismatch — look at the body bytes (above) first. Send lowercase anyway.
Missing sha256= prefix
Header value ab12cd34... (no prefix) fails. Always sha256=ab12cd34....
Trailing newline
Some HTTP clients add a trailing \n to body. The signature is byte-exact — adding bytes between your sign step and your send step breaks signing. Inspect with curl -v or Wireshark if unsure.
Re-signing on retry
If you retry a failed webhook (network blip, 5xx response), re-sign with a fresh timestamp. Reusing the old timestamp + signature works only if you're inside the 5-min window.
Forgetting to URL-encode partner_id in the URL
Partner IDs are usually simple strings. If yours has special characters, URL-encode them in the path. The body and signing are unaffected.
7. Rotation
To get a new key, ask us — our team issues it for your webhook URL. The new key is shown ONCE, and the old key stops working immediately — there is no overlap window yet, so swap it in your secrets manager right away.
If you suspect the key is compromised:
- Ask us for a new key
- Note the time window you are worried about
- Email krishna.gamasany@automobnxt.com with that window — our team can flag suspect entries
8. Retries and duplicates
TOMO does not de-duplicate on external_id yet: every accepted POST records a new completed order and a new fee line. Retry only when you got no response at all or a 5xx. Never resend after a 201. If you think you posted twice, email krishna.gamasany@automobnxt.com with the external_id and we will correct the ledger. (planned: idempotency on external_id.)
9. References
- TOMO completion contract:
COMPLETION_CONTRACT.md - Per-intent §7 in every intent spec
- TOMO ingest verifier: TOMO's CPC HMAC verification layer (canonical algorithm shown in §3)
Built by AUTOMOBNXT · DPIIT Recognised Startup · 2026.