TOMO
BETA Partner connections are in beta and these docs are under review. Anything marked planned is not built yet — we confirm with you what is live before your service goes live.

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:

  1. X-TOMO-Timestamp — Unix epoch milliseconds at the moment you sent the request
  2. X-TOMO-Signature — HMAC-SHA256 over ${timestamp}.${rawBodyJSON} using your webhook_signing_key
  3. 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:

  • rawBodyJSON is the body you send. TOMO checks the signature against your parsed body re-serialised as compact JSON (the way JavaScript's JSON.stringify writes 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, not 240.0).
  • X-TOMO-Timestamp is the Unix epoch in milliseconds (not seconds, not ISO).
  • The . between timestamp and body is a literal dot character. Do not URL-encode.
  • signature is 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:

  1. Ask us for a new key
  2. Note the time window you are worried about
  3. 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.