T
TOMO
Developer Docs
BETA These docs are under partner review. Some features described are roadmap items, not yet shipped. Verify against your sandbox before relying on any contract.

TOMO Webhook Signing — HMAC-SHA256 Specification

Audience: every TOMO partner. Every closed-intent webhook MUST be signed. No exceptions in production.

Source-of-truth code (server side): server/lib/cpcWebhookClient.ts + server/routes/cpc.ts.


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_partner_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, ... }

Three things must be true:

  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)

Get any of these wrong → TOMO returns 401 SIGNATURE_INVALID and the webhook is rejected. Your CPC ledger entry is NOT created.


2. Where do I get the signing key?

Your webhook_signing_key is issued once when you save your webhook URL on the Tier 1 dashboard:

https://www.automobnxt.com/?dev=tier1-creds  →  CPC completion webhook section  →  Save webhook URL

After save:

webhook_url:          (the one you entered)
webhook_signing_key:  <hex string, shown ONCE — copy now>

Like an API secret, the signing key is never displayed again. If you lose it, click Rotate webhook key to issue a fresh one (old key keeps working for 24h to allow rotation).

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 exact bytes you send in the request body. Don't pretty-print, don't reorder keys, don't add whitespace after stringify. The signature is byte-exact.
  • 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 lowercase hex. Uppercase will fail.
  • 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;
  intent_version: string;
  external_id: string;
  amount_inr: number;
  closed_at: string;
  status: string;
  request_id: string;
  // ... intent-specific fields per spec §7
}

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]:
    body_text = json.dumps(payload, separators=(",", ":"))   # byte-exact
    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,
        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"`
    IntentVersion string `json:"intent_version"`
    ExternalID    string `json:"external_id"`
    AmountInr     int    `json:"amount_inr"`
    ClosedAt      string `json:"closed_at"`
    Status        string `json:"status"`
    RequestID     string `json:"request_id"`
    // ... intent-specific fields
}

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) {
    bodyBytes, err := json.Marshal(payload)
    if err != nil {
        return CpcResponse{}, fmt.Errorf("marshal payload: %w", err)
    }

    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 uses the same scheme when it sends you outbound notifications (e.g., user-cancelled-the-intent, dispute-raised). Verify them on your end:

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 stringification differs. Frameworks like FastAPI re-serialize bodies; if you call JSON.stringify(payload) and then your HTTP library re-serializes, the bytes diverge. Pin the body bytes once and reuse.

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 mismatch

sha256=AB12CD34... (uppercase) fails. Always lowercase.

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

Click Rotate webhook key on the Tier 1 dashboard. New key shown ONCE.

Old key keeps working for 24h to give you time to swap in your secrets manager. After 24h, old key is invalidated.

If you suspect the key is compromised:

  1. Rotate immediately
  2. Audit your CPC ledger entries from the last 24h
  3. Email security@automobnxt.com with the audit window — TOMO can flag suspect entries

8. Idempotency

CPC webhooks are idempotent on external_id. If you send the same external_id twice for the same intent, TOMO writes the closed_intent row only once.

This means it's safe to retry on network failures. Recommended retry policy:

Retry on:    HTTP 5xx, network timeout, connection refused
Backoff:     1s, 2s, 4s, 8s, 16s (exponential, max 5 retries)
Stop on:     2xx (success), 401 (signature invalid — fix and resubmit), 4xx other

9. Reference: The full server/lib/cpcWebhookClient.ts

This is the canonical TOMO server-side helper. It's also what TOMO's Tier 2 wrappers use internally to fire signed CPC events. You can mirror its logic exactly — that's the contract.

import crypto from 'crypto';
import { getFirestore } from '../services/firestoreService';

const DEFAULT_BASE = process.env.TOMO_API_BASE_URL || 'http://localhost:3004';

export async function postSignedCpcEvent(
  params: SignedCpcPostParams,
): Promise<{ ok: boolean; status: number; body: any }> {
  const baseUrl = params.baseUrl || DEFAULT_BASE;
  const body = {
    intent:      params.intent,
    external_id: params.externalId,
    amount_inr:  params.amountInr,
    closed_at:   params.closedAt,
    notes:       params.notes,
  };
  const bodyText = JSON.stringify(body);
  const ts = Date.now().toString();

  const headers: Record<string, string> = {
    'Content-Type':     'application/json',
    'X-TOMO-Timestamp': ts,
  };

  const signingKey = await fetchSigningKey(params.supplierType, params.supplierId);
  if (signingKey) {
    const payload = `${ts}.${bodyText}`;
    const sig = crypto.createHmac('sha256', signingKey).update(payload).digest('hex');
    headers['X-TOMO-Signature'] = `sha256=${sig}`;
  }

  const url = `${baseUrl}/api/v1/cpc/${params.supplierType}/${params.supplierId}`;
  const res = await fetch(url, { method: 'POST', headers, body: bodyText });
  return { ok: res.ok, status: res.status, body: await res.json().catch(() => null) };
}

10. References

  • TOMO completion contract: COMPLETION_CONTRACT.md
  • Per-intent §7 in every intent spec
  • TOMO ingest verifier: server/routes/cpc.ts verifyHmac()

Built by AUTOMOBNXT · DPIIT Recognised Startup · 2026.