Tether
DocsGitHub

The tool endpoint

Verifying signatures

Your tool endpoint is on the public internet, and it changes production data. Verify the signature before you parse the body, and refuse anything that fails.

Every POST to your endpoint is signed this way, including the lifecycle events described in the request envelope. Verify first, then look at the body.

The scheme

Every request carries two headers:

X-Tether-Timestamp: 1755712867
X-Tether-Signature: sha256=4f8b1c…

The signature is a hex-encoded HMAC-SHA256, keyed with your tenant's signing secret, over the string:

{timestamp}.{raw request body}

A literal dot joins them. The body is the exact bytes on the wire — not a pretty-printed version, not a re-serialized object, not a parsed-and-stringified round trip.

Node

import crypto from 'node:crypto'
import express from 'express'

const app = express()
// Raw, not express.json(). This must be the FIRST body parser on the route.
app.use('/tether/invoke', express.raw({ type: 'application/json' }))

function verify(req) {
  const timestamp = req.get('X-Tether-Timestamp')
  const signature = req.get('X-Tether-Signature')
  if (!timestamp || !signature) return false

  // Reject anything older than five minutes: a captured request should not be
  // replayable tomorrow.
  const age = Math.abs(Date.now() / 1000 - Number(timestamp))
  if (!Number.isFinite(age) || age > 300) return false

  const expected =
    'sha256=' +
    crypto
      .createHmac('sha256', process.env.TETHER_SECRET)
      .update(`${timestamp}.${req.body}`)
      .digest('hex')

  const a = Buffer.from(signature)
  const b = Buffer.from(expected)
  // Length check first — timingSafeEqual throws on a length mismatch.
  return a.length === b.length && crypto.timingSafeEqual(a, b)
}

Python

import hmac, hashlib, time
from flask import Flask, request, jsonify

app = Flask(__name__)

def verify(req) -> bool:
    timestamp = req.headers.get("X-Tether-Timestamp", "")
    signature = req.headers.get("X-Tether-Signature", "")
    if not timestamp or not signature:
        return False

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

    # get_data() is the raw body; do NOT use request.json here.
    payload = f"{timestamp}.".encode() + req.get_data()
    expected = "sha256=" + hmac.new(SECRET.encode(), payload, hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature, expected)

Go

func verify(r *http.Request, secret string, body []byte) bool {
    timestamp := r.Header.Get("X-Tether-Timestamp")
    signature := r.Header.Get("X-Tether-Signature")
    if timestamp == "" || signature == "" {
        return false
    }

    ts, err := strconv.ParseInt(timestamp, 10, 64)
    if err != nil || math.Abs(float64(time.Now().Unix()-ts)) > 300 {
        return false
    }

    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(timestamp + "."))
    mac.Write(body)
    expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))

    return hmac.Equal([]byte(signature), []byte(expected))
}

C#

static bool Verify(HttpRequest request, string secret, string rawBody)
{
    var timestamp = request.Headers["X-Tether-Timestamp"].ToString();
    var signature = request.Headers["X-Tether-Signature"].ToString();
    if (string.IsNullOrEmpty(timestamp) || string.IsNullOrEmpty(signature)) return false;

    if (!long.TryParse(timestamp, out var ts) ||
        Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) > 300) return false;

    using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
    var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes($"{timestamp}.{rawBody}"));
    var expected = "sha256=" + Convert.ToHexString(hash).ToLowerInvariant();

    return CryptographicOperations.FixedTimeEquals(
        Encoding.UTF8.GetBytes(signature), Encoding.UTF8.GetBytes(expected));
}

The four rules

Compare in constant time. A byte-by-byte comparison that returns early leaks how much of a forged signature was right. Every language above has a fixed-time comparison; use it.

Check the timestamp. The signature alone proves authenticity, not freshness. Without a window, a captured request replays forever. Five minutes is a sane default; the timestamp is inside the signed payload, so an attacker cannot move it.

Verify before parsing. Parsing untrusted JSON is a smaller attack surface than most things, but it is not zero, and it is free to do the check first.

Fail closed. Missing header, unparseable timestamp, wrong length, no secret configured — all of these are 401. Never fall through to "well, it looked fine otherwise".

Rotating the secret

Debugging a mismatch

In order of how often each is the cause:

  1. The body was re-serialized. By far the most common. Check that your framework hands you raw bytes on this route, and that no global JSON middleware runs first.
  2. The wrong secret. The signing secret is not the API key. The API key authenticates you to Tether; the signing secret authenticates Tether to you.
  3. The dot is missing. The payload is timestamp, then a literal ., then the body.
  4. A proxy rewrote the body. Some gateways normalize JSON or strip charset. Log the exact bytes you hashed and compare their length to Content-Length.