> ## Documentation Index
> Fetch the complete documentation index at: https://docs.zerodash.studio/llms.txt
> Use this file to discover all available pages before exploring further.

# Request signature

> One HMAC-SHA512 algorithm secures both directions of the integration. Implement it once, use it to sign and to verify.

Every request — the ones you send to the [Customer API](/api-reference/customer/introduction) and the ones we send to your [wallet callbacks](/api-reference/callbacks/introduction) — carries an HMAC-SHA512 signature.

<Card title="The algorithm" icon="lock" horizontal>
  `X-Zd-Signature = hex( HMAC-SHA512( apiPath + "|" + timestamp + "|" + data, secretKey ) )`
</Card>

## The three parts

<ParamField path="apiPath" type="string" required>
  The path of the request, with no host, no query string and no fragment. For `https://api.zerodash.studio/api/v1/games?x=1` that is `/api/v1/games`.
</ParamField>

<ParamField path="timestamp" type="string" required>
  Unix time in **milliseconds**. The exact same string goes into the `X-Zd-Timestamp` header — sign what you send, byte for byte. A timestamp more than **5 minutes** old is rejected.
</ParamField>

<ParamField path="data" type="string" required>
  Depends on the request:

  | Request                      | `data`                                           |
  | ---------------------------- | ------------------------------------------------ |
  | Has a body (`POST`, `PUT`)   | The **raw body bytes**, exactly as transmitted   |
  | No body, has a query (`GET`) | The **raw query string without the leading `?`** |
  | Neither                      | The empty string                                 |
</ParamField>

The three parts are joined with a vertical bar `|`. The separator after `timestamp` is always present, even when `data` is empty.

<Warning>
  **Sign the bytes you actually send.** Serialize your JSON once, sign that buffer, send that buffer. Re-serializing between signing and sending — a pretty-printer, a middleware, a different key order — changes the bytes and breaks the signature. The same applies to the query string: sign the encoded string you put on the wire, not a re-encoding of it.
</Warning>

## Headers

<CodeGroup>
  ```http Customer API — you → Zero-Dash theme={null}
  X-Operator: <provided-by-zerodash>
  X-Zd-Signature: <hex hmac-sha512>
  X-Zd-Timestamp: <unix millis>
  Content-Type: application/json   # only when there is a body
  ```

  ```http Wallet callbacks — Zero-Dash → you theme={null}
  X-Zd-Signature: <hex hmac-sha512>
  X-Zd-Timestamp: <unix millis>
  Content-Type: application/json   # only when there is a body
  ```
</CodeGroup>

`application/json` is the only accepted content type for request bodies. Nothing else will be processed.

## Worked examples

Every value below was produced with the secret key `secretKey` and is reproducible with the snippets on this page.

<Tabs>
  <Tab title="Canonical vector">
    ```yaml theme={null}
    apiPath:   /api/v1/test
    timestamp: 1778920901644
    data:      HelloWorld
    key:       secretKey

    signed string: /api/v1/test|1778920901644|HelloWorld
    signature:     be77138b8e9f8a9d0b644e7ce4c2ff2d95df69151fb42c8917a2d26d3e8fe79c
                   680c70dafd2a7c17d5b40e293d11aebad9e4ccd3293914b5b9b08945305d1b4e
    ```

    Use this one first. If your implementation reproduces it, the algorithm is right.
  </Tab>

  <Tab title="GET with query">
    ```yaml theme={null}
    request:   GET /api/v1/games/lucky-duck?ipAddress=1.2.3.4&freeToPlay=false&currency=USD&token=user-token&lang=en
    timestamp: 1778920901644

    signed string: /api/v1/games/lucky-duck|1778920901644|ipAddress=1.2.3.4&freeToPlay=false&currency=USD&token=user-token&lang=en
    signature:     0c8857b40faa035e9d2946b8a6004dbefbd13d9d4ed4b0d96584e369b2a7b77d
                   b5145a130fbaa7824f894a561f5b9aedeaad87f3bcfe60971b0cff74cd624444
    ```

    The `?` is dropped. Parameter order is whatever you send — sign that exact string.
  </Tab>

  <Tab title="POST with body">
    ```yaml theme={null}
    request:   POST /api/v1/free-rounds/campaigns
    timestamp: 1778920901644
    body:      {"campaignCode":"XMAS-2026","amount":2.5}

    signed string: /api/v1/free-rounds/campaigns|1778920901644|{"campaignCode":"XMAS-2026","amount":2.5}
    signature:     0ebeaf99a18e710caa19564636ba7061c47c0944b6755bc707e6db7770077e38
                   4c0c93987fc02efd02a9ca454535c964084c407f5c07d0436c33166555e60bfd
    ```

    No whitespace was added to the JSON. Add a single space anywhere and the signature changes.
  </Tab>

  <Tab title="GET without query">
    ```yaml theme={null}
    request:   GET /api/v1/games
    timestamp: 1778920901644
    data:      (empty)

    signed string: /api/v1/games|1778920901644|
    signature:     f1e8b13c68f7d68469025160f2b8d5de2376b89dd47b825ae52df8974b76381d
                   ea32d7184e2567b7bcb95f45642cb1756386c23cc31a34224b572b549df61a4d
    ```

    The trailing `|` stays. `data` is the empty string, not an absent segment.
  </Tab>
</Tabs>

## Reference implementations

Each `sign` function below returns the `X-Zd-Signature` value. The same function verifies inbound callbacks: recompute over the received path, timestamp and body, then compare in constant time.

<CodeGroup>
  ```go Go theme={null}
  package zdsig

  import (
  	"crypto/hmac"
  	"crypto/sha512"
  	"encoding/hex"
  	"strconv"
  	"time"
  )

  // Sign returns the value for the X-Zd-Signature header.
  // payload is the raw request body for POST/PUT, or the raw query string
  // (without the leading "?") for GET. Pass nil when there is neither.
  func Sign(secretKey, apiPath, timestamp string, payload []byte) string {
  	h := hmac.New(sha512.New, []byte(secretKey))
  	h.Write([]byte(apiPath + "|" + timestamp + "|"))
  	h.Write(payload)
  	return hex.EncodeToString(h.Sum(nil))
  }

  func Timestamp() string { return strconv.FormatInt(time.Now().UnixMilli(), 10) }

  // Verify compares signatures in constant time.
  func Verify(expectedHex, receivedHex string) bool {
  	return hmac.Equal([]byte(expectedHex), []byte(receivedHex))
  }
  ```

  ```javascript Node.js theme={null}
  import { createHmac, timingSafeEqual } from 'node:crypto';

  /**
   * Value for the X-Zd-Signature header.
   * @param {string} apiPath   request path, e.g. "/api/v1/games"
   * @param {string} timestamp same value sent in X-Zd-Timestamp
   * @param {string|Buffer} payload raw body (POST) or raw query without "?" (GET)
   */
  export function sign(secretKey, apiPath, timestamp, payload = '') {
    return createHmac('sha512', secretKey)
      .update(`${apiPath}|${timestamp}|`)
      .update(payload)
      .digest('hex');
  }

  export const timestamp = () => Date.now().toString();

  /** Constant-time comparison for verifying inbound callbacks. */
  export function verify(expectedHex, receivedHex) {
    const a = Buffer.from(expectedHex, 'hex');
    const b = Buffer.from(receivedHex ?? '', 'hex');
    return a.length === b.length && timingSafeEqual(a, b);
  }
  ```

  ```python Python theme={null}
  import hashlib
  import hmac
  import time


  def sign(secret_key: str, api_path: str, timestamp: str, payload: bytes = b"") -> str:
      """Value for the X-Zd-Signature header.

      payload is the raw request body (POST) or the raw query string without the
      leading "?" (GET). Pass b"" when there is neither.
      """
      mac = hmac.new(secret_key.encode(), digestmod=hashlib.sha512)
      mac.update(f"{api_path}|{timestamp}|".encode())
      mac.update(payload)
      return mac.hexdigest()


  def timestamp() -> str:
      return str(int(time.time() * 1000))


  def verify(expected_hex: str, received_hex: str) -> bool:
      """Constant-time comparison for verifying inbound callbacks."""
      return hmac.compare_digest(expected_hex, received_hex or "")
  ```

  ```java Java theme={null}
  import java.nio.charset.StandardCharsets;
  import java.security.MessageDigest;
  import java.util.HexFormat;
  import javax.crypto.Mac;
  import javax.crypto.spec.SecretKeySpec;

  public final class ZdSignature {

      /** Value for the X-Zd-Signature header. */
      public static String sign(String secretKey, String apiPath, String timestamp, byte[] payload) {
          try {
              Mac mac = Mac.getInstance("HmacSHA512");
              mac.init(new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "HmacSHA512"));
              mac.update((apiPath + "|" + timestamp + "|").getBytes(StandardCharsets.UTF_8));
              if (payload != null) mac.update(payload);
              return HexFormat.of().formatHex(mac.doFinal());
          } catch (Exception e) {
              throw new IllegalStateException("unable to sign request", e);
          }
      }

      public static String timestamp() {
          return Long.toString(System.currentTimeMillis());
      }

      /** Constant-time comparison for verifying inbound callbacks. */
      public static boolean verify(String expectedHex, String receivedHex) {
          return MessageDigest.isEqual(
                  expectedHex.getBytes(StandardCharsets.UTF_8),
                  (receivedHex == null ? "" : receivedHex).getBytes(StandardCharsets.UTF_8));
      }
  }
  ```

  ```php PHP theme={null}
  <?php

  final class ZdSignature
  {
      /** Value for the X-Zd-Signature header. */
      public static function sign(
          string $secretKey,
          string $apiPath,
          string $timestamp,
          string $payload = ''
      ): string {
          return hash_hmac('sha512', $apiPath . '|' . $timestamp . '|' . $payload, $secretKey);
      }

      public static function timestamp(): string
      {
          return (string) (int) (microtime(true) * 1000);
      }

      /** Constant-time comparison for verifying inbound callbacks. */
      public static function verify(string $expectedHex, string $receivedHex): bool
      {
          return hash_equals($expectedHex, $receivedHex);
      }
  }
  ```

  ```csharp C# theme={null}
  using System.Security.Cryptography;
  using System.Text;

  public static class ZdSignature
  {
      /// <summary>Value for the X-Zd-Signature header.</summary>
      public static string Sign(string secretKey, string apiPath, string timestamp, byte[]? payload)
      {
          var prefix = Encoding.UTF8.GetBytes($"{apiPath}|{timestamp}|");
          var body = payload ?? Array.Empty<byte>();

          var data = new byte[prefix.Length + body.Length];
          prefix.CopyTo(data, 0);
          body.CopyTo(data, prefix.Length);

          using var hmac = new HMACSHA512(Encoding.UTF8.GetBytes(secretKey));
          return Convert.ToHexString(hmac.ComputeHash(data)).ToLowerInvariant();
      }

      public static string Timestamp() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString();

      /// <summary>Constant-time comparison for verifying inbound callbacks.</summary>
      public static bool Verify(string expectedHex, string receivedHex) =>
          CryptographicOperations.FixedTimeEquals(
              Encoding.UTF8.GetBytes(expectedHex),
              Encoding.UTF8.GetBytes(receivedHex ?? string.Empty));
  }
  ```

  ```bash Shell theme={null}
  #!/usr/bin/env bash
  # usage: zd_sign <secretKey> <apiPath> <timestamp> [data]
  zd_sign() {
    printf '%s|%s|%s' "$2" "$3" "${4-}" \
      | openssl dgst -sha512 -hmac "$1" -hex \
      | awk '{print $NF}'
  }

  zd_sign secretKey /api/v1/test 1778920901644 HelloWorld
  # be77138b8e9f8a9d0b644e7ce4c2ff2d95df69151fb42c8917a2d26d3e8fe79c680c70dafd2a7c17d5b40e293d11aebad9e4ccd3293914b5b9b08945305d1b4e
  ```
</CodeGroup>

## Verifying inbound callbacks

Do this **before** parsing the body or touching the wallet.

<Steps>
  <Step title="Read the raw body" icon="file-code">
    Capture the untouched bytes. Most frameworks let a JSON body parser consume the stream first — if yours does, buffer the raw payload before the parser runs, or you will verify a re-serialization and fail every time.
  </Step>

  <Step title="Check the timestamp" icon="clock">
    Reject anything where `|now − X-Zd-Timestamp| > 5 minutes`. This is what stops a captured request from being replayed later.
  </Step>

  <Step title="Recompute and compare" icon="equals">
    Rebuild `path|timestamp|data` with the path **as you registered it** — including any prefix such as `/zerodash/v1` — and compare against `X-Zd-Signature` in constant time.
  </Step>

  <Step title="Reject with 401" icon="ban">
    On any mismatch, stop. Do not create a transaction, do not move money.
  </Step>
</Steps>

<Tip>
  Add the [IP allowlist](/security/ip-allowlist) in front of this. Signature verification proves the message is authentic; the allowlist keeps unauthenticated traffic away from your wallet in the first place.
</Tip>

## Troubleshooting

<AccordionGroup>
  <Accordion title="401 on every request" icon="circle-xmark">
    Compare the exact string you signed against the one on the wire. The usual causes, in order of frequency: the host slipped into `apiPath`; the query string still has its `?`; the trailing `|` was dropped when `data` is empty; the body was re-serialized after signing; the timestamp you signed differs from the header.
  </Accordion>

  <Accordion title="Works on GET, fails on POST" icon="file-circle-xmark">
    Your HTTP client is serializing the object itself instead of sending the buffer you signed. Build the body string once, sign it, then send it as a raw string or byte array with `Content-Type: application/json`.
  </Accordion>

  <Accordion title="Works locally, fails behind a proxy" icon="server">
    A proxy or gateway is rewriting the path (stripping a prefix, adding a trailing slash) or the body (re-encoding, gzip). Sign the path as the origin server sees it, and verify the callback path as **you** registered it with Zero-Dash.
  </Accordion>

  <Accordion title="Intermittent 401s" icon="clock-rotate-left">
    Clock drift. Your timestamp must be within 5 minutes of ours. Run NTP; a container with a drifting clock will fail a small, growing fraction of requests.
  </Accordion>
</AccordionGroup>

<Warning>
  The secret key never leaves your server. It is not a bearer token: never place it in a URL, a browser, a mobile app, a log line or a stack trace.
</Warning>
