Starship Rewards API

HMAC Request Signing

Cryptographic request signing with HMAC-SHA256 for tamper-proof API requests

HMAC Request Signing

HMAC request signing provides cryptographic proof that an API request was sent by the holder of the signing secret and has not been tampered with in transit. When enabled, the server rejects any request with an invalid or missing signature.

Overview

  • What it is: Each request includes an HMAC-SHA256 signature computed from the request method, path, query parameters, body, and a timestamp
  • Why: Prevents request tampering and replay attacks -- even if an attacker intercepts a request, they cannot modify it or replay it after 5 minutes
  • Opt-in: An admin enables HMAC signing per API credential. Once enabled, all requests using that credential must include a valid signature

How It Works

  1. Admin generates a signing secret for your API credential (separate from the API secret)
  2. For each request, you build a canonical string from the request components
  3. You compute an HMAC-SHA256 of the canonical string using your signing secret
  4. You send the signature and timestamp in the X-Signature header
  5. The server rebuilds the canonical string, computes its own HMAC, and compares signatures
  6. Requests with timestamps older than 5 minutes are rejected (replay protection)

Header Format

X-Signature: t=1740000000,v1=a1b2c3d4e5f6...
ComponentDescription
t=Current Unix timestamp in seconds (e.g., 1740000000)
v1=Lowercase hex-encoded HMAC-SHA256 signature

The t= and v1= components are separated by a comma with no spaces.

Building the Canonical Request

The canonical request is a newline-separated string of five components:

METHOD\nPATH\nSORTED_QUERY\nBODY_HASH\nTIMESTAMP

Step 1: HTTP Method (uppercase)

The HTTP method in uppercase: GET, POST, PUT, DELETE.

Step 2: Request Path

The path portion of the URL, without host or query string.

/api/v1/orders
/api/v1/products

Step 3: Sorted Query Parameters

Sort query parameters alphabetically by key and join as key=value pairs separated by &.

  • If there are no query parameters, use an empty string
  • Multiple values for the same key are included as separate key=value pairs
  • Keys are sorted lexicographically (standard string sort)
# URL: /api/v1/products?page=1&per_page=20&category=travel
# Sorted result:
category=travel&page=1&per_page=20

Step 4: SHA-256 Body Hash

Compute the SHA-256 hash of the raw request body and encode it as a lowercase hex string.

  • For GET and DELETE requests (no body), hash the empty string
  • SHA-256 of empty string: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
  • For POST/PUT requests, hash the exact bytes you send as the request body

Step 5: Timestamp

The same Unix timestamp (in seconds) used in the t= component of the header.

Assembled Example

For a POST /api/v1/orders request with body {"product_id":42,"quantity":1} at timestamp 1740000000:

POST
/api/v1/orders

a]sha256_of_body_bytes]
1740000000

The third line (query parameters) is an empty string for requests without query parameters. It still occupies a line in the canonical string.

Computing the Signature

signature = HMAC-SHA256(signing_secret, canonical_string)

The result is encoded as a lowercase hex string.

Code Examples

All examples below sign the same request so you can verify your implementation produces matching output:

  • Method: POST
  • Path: /api/v1/orders
  • Query: (none)
  • Body: {"product_id":42,"denomination":100,"quantity":1}
  • Timestamp: 1740000000
  • Signing Secret: whsec_test_secret_key_123
import hashlib
import hmac
import time
import requests


class StarshipHMAC:
    def __init__(self, api_key: str, api_secret: str, signing_secret: str,
                 base_url: str = "{{host}}"):
        self.api_key = api_key
        self.api_secret = api_secret
        self.signing_secret = signing_secret
        self.base_url = base_url

    def _build_canonical(self, method: str, path: str, query: str,
                         body: bytes, timestamp: str) -> str:
        body_hash = hashlib.sha256(body).hexdigest()
        return "\n".join([method, path, query, body_hash, timestamp])

    def _sign(self, method: str, path: str, query: str, body: bytes) -> dict:
        timestamp = str(int(time.time()))
        canonical = self._build_canonical(method, path, query, body, timestamp)
        signature = hmac.new(
            self.signing_secret.encode(),
            canonical.encode(),
            hashlib.sha256,
        ).hexdigest()
        return {
            "X-API-Key": self.api_key,
            "X-API-Secret": self.api_secret,
            "X-Signature": f"t={timestamp},v1={signature}",
            "Content-Type": "application/json",
        }

    def _sorted_query(self, params: dict) -> str:
        if not params:
            return ""
        pairs = []
        for key in sorted(params.keys()):
            val = params[key]
            if isinstance(val, list):
                for v in val:
                    pairs.append(f"{key}={v}")
            else:
                pairs.append(f"{key}={val}")
        return "&".join(pairs)

    def get(self, path: str, params: dict = None):
        query = self._sorted_query(params or {})
        full_path = f"{path}?{query}" if query else path
        headers = self._sign("GET", path, query, b"")
        return requests.get(f"{self.base_url}{full_path}", headers=headers)

    def post(self, path: str, json_body: dict, params: dict = None):
        import json
        query = self._sorted_query(params or {})
        body = json.dumps(json_body, separators=(",", ":")).encode()
        headers = self._sign("POST", path, query, body)
        return requests.post(f"{self.base_url}{path}", data=body, headers=headers)


# Usage
client = StarshipHMAC(
    api_key="sk_live_abc123def456",
    api_secret="your-api-secret",
    signing_secret="whsec_test_secret_key_123",
)

# GET with query params
products = client.get("/api/v1/products", {"page": "1", "per_page": "20"})

# POST with JSON body
order = client.post("/api/v1/orders", {
    "product_id": 42,
    "denomination": 100,
    "quantity": 1,
})
const crypto = require('crypto');

class StarshipHMAC {
  constructor({ apiKey, apiSecret, signingSecret, baseUrl = '{{host}}' }) {
    this.apiKey = apiKey;
    this.apiSecret = apiSecret;
    this.signingSecret = signingSecret;
    this.baseUrl = baseUrl;
  }

  _buildCanonical(method, path, query, body, timestamp) {
    const bodyHash = crypto.createHash('sha256').update(body).digest('hex');
    return [method, path, query, bodyHash, timestamp].join('\n');
  }

  _sortedQuery(params) {
    if (!params || Object.keys(params).length === 0) return '';
    return Object.keys(params)
      .sort()
      .flatMap((key) => {
        const val = params[key];
        return Array.isArray(val)
          ? val.map((v) => `${key}=${v}`)
          : [`${key}=${val}`];
      })
      .join('&');
  }

  _sign(method, path, query, body) {
    const timestamp = Math.floor(Date.now() / 1000).toString();
    const canonical = this._buildCanonical(method, path, query, body, timestamp);
    const signature = crypto
      .createHmac('sha256', this.signingSecret)
      .update(canonical)
      .digest('hex');

    return {
      'X-API-Key': this.apiKey,
      'X-API-Secret': this.apiSecret,
      'X-Signature': `t=${timestamp},v1=${signature}`,
      'Content-Type': 'application/json',
    };
  }

  async get(path, params) {
    const query = this._sortedQuery(params);
    const url = query ? `${this.baseUrl}${path}?${query}` : `${this.baseUrl}${path}`;
    const headers = this._sign('GET', path, query, '');

    const res = await fetch(url, { method: 'GET', headers });
    return res.json();
  }

  async post(path, jsonBody) {
    const body = JSON.stringify(jsonBody);
    const headers = this._sign('POST', path, '', body);

    const res = await fetch(`${this.baseUrl}${path}`, {
      method: 'POST',
      headers,
      body,
    });
    return res.json();
  }
}

// Usage
const client = new StarshipHMAC({
  apiKey: 'sk_live_abc123def456',
  apiSecret: 'your-api-secret',
  signingSecret: 'whsec_test_secret_key_123',
});

// GET with query params
const products = await client.get('/api/v1/products', { page: '1', per_page: '20' });

// POST with JSON body
const order = await client.post('/api/v1/orders', {
  product_id: 42,
  denomination: 100,
  quantity: 1,
});
package main

import (
    "bytes"
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "sort"
    "strconv"
    "strings"
    "time"
)

type StarshipHMAC struct {
    APIKey        string
    APISecret     string
    SigningSecret string
    BaseURL       string
    Client        *http.Client
}

func NewStarshipHMAC(apiKey, apiSecret, signingSecret, baseURL string) *StarshipHMAC {
    return &StarshipHMAC{
        APIKey:        apiKey,
        APISecret:     apiSecret,
        SigningSecret: signingSecret,
        BaseURL:       baseURL,
        Client:        &http.Client{},
    }
}

func (s *StarshipHMAC) buildCanonical(method, path, query string, body []byte, timestamp string) string {
    bodyHash := sha256.Sum256(body)
    return strings.Join([]string{
        method,
        path,
        query,
        hex.EncodeToString(bodyHash[:]),
        timestamp,
    }, "\n")
}

func (s *StarshipHMAC) sortedQuery(params map[string]string) string {
    if len(params) == 0 {
        return ""
    }
    keys := make([]string, 0, len(params))
    for k := range params {
        keys = append(keys, k)
    }
    sort.Strings(keys)

    parts := make([]string, 0, len(keys))
    for _, k := range keys {
        parts = append(parts, k+"="+params[k])
    }
    return strings.Join(parts, "&")
}

func (s *StarshipHMAC) sign(method, path, query string, body []byte) http.Header {
    timestamp := strconv.FormatInt(time.Now().Unix(), 10)
    canonical := s.buildCanonical(method, path, query, body, timestamp)

    mac := hmac.New(sha256.New, []byte(s.SigningSecret))
    mac.Write([]byte(canonical))
    signature := hex.EncodeToString(mac.Sum(nil))

    h := http.Header{}
    h.Set("X-API-Key", s.APIKey)
    h.Set("X-API-Secret", s.APISecret)
    h.Set("X-Signature", fmt.Sprintf("t=%s,v1=%s", timestamp, signature))
    h.Set("Content-Type", "application/json")
    return h
}

func (s *StarshipHMAC) Get(path string, params map[string]string) ([]byte, error) {
    query := s.sortedQuery(params)
    url := s.BaseURL + path
    if query != "" {
        url += "?" + query
    }

    req, err := http.NewRequest("GET", url, nil)
    if err != nil {
        return nil, err
    }
    req.Header = s.sign("GET", path, query, []byte{})

    resp, err := s.Client.Do(req)
    if err != nil {
        return nil, err
    }
    defer func() { _ = resp.Body.Close() }()
    return io.ReadAll(resp.Body)
}

func (s *StarshipHMAC) Post(path string, jsonBody any) ([]byte, error) {
    body, err := json.Marshal(jsonBody)
    if err != nil {
        return nil, err
    }

    req, err := http.NewRequest("POST", s.BaseURL+path, bytes.NewReader(body))
    if err != nil {
        return nil, err
    }
    req.Header = s.sign("POST", path, "", body)

    resp, err := s.Client.Do(req)
    if err != nil {
        return nil, err
    }
    defer func() { _ = resp.Body.Close() }()
    return io.ReadAll(resp.Body)
}

func main() {
    client := NewStarshipHMAC(
        "sk_live_abc123def456",
        "your-api-secret",
        "whsec_test_secret_key_123",
        "{{host}}",
    )

    // GET with query params
    products, _ := client.Get("/api/v1/products", map[string]string{
        "page": "1", "per_page": "20",
    })
    fmt.Println(string(products))

    // POST with JSON body
    order, _ := client.Post("/api/v1/orders", map[string]any{
        "product_id":   42,
        "denomination": 100,
        "quantity":     1,
    })
    fmt.Println(string(order))
}
<?php

class StarshipHMAC {
    private string $apiKey;
    private string $apiSecret;
    private string $signingSecret;
    private string $baseUrl;

    public function __construct(
        string $apiKey,
        string $apiSecret,
        string $signingSecret,
        string $baseUrl = '{{host}}'
    ) {
        $this->apiKey = $apiKey;
        $this->apiSecret = $apiSecret;
        $this->signingSecret = $signingSecret;
        $this->baseUrl = $baseUrl;
    }

    private function buildCanonical(
        string $method, string $path, string $query,
        string $body, string $timestamp
    ): string {
        $bodyHash = hash('sha256', $body);
        return implode("\n", [$method, $path, $query, $bodyHash, $timestamp]);
    }

    private function sortedQuery(array $params): string {
        if (empty($params)) return '';
        ksort($params);
        $pairs = [];
        foreach ($params as $key => $value) {
            if (is_array($value)) {
                foreach ($value as $v) {
                    $pairs[] = "$key=$v";
                }
            } else {
                $pairs[] = "$key=$value";
            }
        }
        return implode('&', $pairs);
    }

    private function sign(string $method, string $path, string $query, string $body): array {
        $timestamp = (string) time();
        $canonical = $this->buildCanonical($method, $path, $query, $body, $timestamp);
        $signature = hash_hmac('sha256', $canonical, $this->signingSecret);

        return [
            'X-API-Key: ' . $this->apiKey,
            'X-API-Secret: ' . $this->apiSecret,
            'X-Signature: t=' . $timestamp . ',v1=' . $signature,
            'Content-Type: application/json',
        ];
    }

    public function get(string $path, array $params = []): array {
        $query = $this->sortedQuery($params);
        $url = $this->baseUrl . $path . ($query ? '?' . $query : '');
        $headers = $this->sign('GET', $path, $query, '');

        $context = stream_context_create([
            'http' => [
                'method' => 'GET',
                'header' => implode("\r\n", $headers),
            ],
        ]);
        $response = file_get_contents($url, false, $context);
        return json_decode($response, true);
    }

    public function post(string $path, array $jsonBody): array {
        $body = json_encode($jsonBody);
        $headers = $this->sign('POST', $path, '', $body);

        $context = stream_context_create([
            'http' => [
                'method' => 'POST',
                'header' => implode("\r\n", $headers),
                'content' => $body,
            ],
        ]);
        $response = file_get_contents($this->baseUrl . $path, false, $context);
        return json_decode($response, true);
    }
}

// Usage
$client = new StarshipHMAC(
    'sk_live_abc123def456',
    'your-api-secret',
    'whsec_test_secret_key_123'
);

$products = $client->get('/api/v1/products', ['page' => '1', 'per_page' => '20']);
$order = $client->post('/api/v1/orders', [
    'product_id' => 42,
    'denomination' => 100,
    'quantity' => 1,
]);
#!/bin/bash

# Configuration
API_KEY="sk_live_abc123def456"
API_SECRET="your-api-secret"
SIGNING_SECRET="whsec_test_secret_key_123"
BASE_URL="{{host}}"

# --- Helper function ---
starship_request() {
    local method="$1"
    local path="$2"
    local query="$3"    # pre-sorted: key1=val1&key2=val2
    local body="$4"     # empty string for GET/DELETE

    local timestamp
    timestamp=$(date +%s)

    # SHA-256 of body
    local body_hash
    body_hash=$(printf '%s' "$body" | openssl dgst -sha256 -hex 2>/dev/null | awk '{print $NF}')

    # Build canonical string
    local canonical
    canonical=$(printf '%s\n%s\n%s\n%s\n%s' "$method" "$path" "$query" "$body_hash" "$timestamp")

    # HMAC-SHA256
    local signature
    signature=$(printf '%s' "$canonical" | openssl dgst -sha256 -hmac "$SIGNING_SECRET" -hex 2>/dev/null | awk '{print $NF}')

    # Build URL
    local url="${BASE_URL}${path}"
    if [ -n "$query" ]; then
        url="${url}?${query}"
    fi

    # Execute request
    if [ "$method" = "GET" ] || [ "$method" = "DELETE" ]; then
        curl -s -X "$method" "$url" \
            -H "X-API-Key: $API_KEY" \
            -H "X-API-Secret: $API_SECRET" \
            -H "X-Signature: t=${timestamp},v1=${signature}" \
            -H "Content-Type: application/json"
    else
        curl -s -X "$method" "$url" \
            -H "X-API-Key: $API_KEY" \
            -H "X-API-Secret: $API_SECRET" \
            -H "X-Signature: t=${timestamp},v1=${signature}" \
            -H "Content-Type: application/json" \
            -d "$body"
    fi
}

# --- Examples ---

# GET request with query parameters (pre-sorted alphabetically)
starship_request "GET" "/api/v1/products" "page=1&per_page=20" ""

# POST request with JSON body
starship_request "POST" "/api/v1/orders" "" '{"product_id":42,"denomination":100,"quantity":1}'

Verification Test Vector

Use these values to verify your implementation produces the correct signature:

ComponentValue
Signing Secretwhsec_test_secret_key_123
MethodPOST
Path/api/v1/orders
Query(empty string)
Body{"product_id":42,"denomination":100,"quantity":1}
Timestamp1740000000

Expected canonical string (with \n as literal newline):

POST
/api/v1/orders

[sha256 of body]
1740000000

To verify, compute:

  1. SHA-256("{"product_id":42,"denomination":100,"quantity":1}") for the body hash
  2. Join all five lines with newlines
  3. HMAC-SHA256("whsec_test_secret_key_123", canonical) for the final signature

If your implementation produces the same hex string, your signing logic is correct.

Error Responses

StatusError MessageCause
401hmac signature requiredHMAC is enabled for your credential but the X-Signature header is missing
401request timestamp expiredThe t= timestamp is more than 5 minutes from the server's current time
401invalid hmac signatureThe computed signature does not match -- check your canonical string construction
401invalid signature header formatThe X-Signature header is malformed (missing t= or v1=)

FAQ

My signature keeps failing. How do I debug it?

Print the canonical string before signing it. Verify each of the five lines matches what the server expects:

  1. Method is uppercase (POST, not post)
  2. Path has no trailing slash and no query string
  3. Query parameters are sorted alphabetically by key
  4. Body hash is the SHA-256 hex digest of the exact bytes you send (encoding, whitespace, and key order matter)
  5. Timestamp matches the t= value in the header exactly

How do I handle clock drift?

Keep your server's clock synchronized with NTP. If you receive a "request timestamp expired" error, retry with a fresh timestamp. The server allows up to 5 minutes of drift in either direction.

What about GET requests with no body?

Hash the empty string. SHA-256 of an empty input is:

e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855

Do not skip the body hash line -- it must always be present in the canonical string.

How are query parameters sorted?

Parameters are sorted lexicographically (standard string sort) by key name. Values are included as-is. If a key has multiple values, each key=value pair is included separately.

# Input: ?z=1&a=2&m=3
# Sorted: a=2&m=3&z=1

Can I use HMAC signing with JWT authentication?

No. HMAC signing is only available with API Key authentication. The middleware checks the api_credential associated with your API key to determine whether HMAC is required.

Is HMAC signing required for all API credentials?

No. HMAC signing is opt-in. An admin enables it per API credential. If HMAC is not enabled for your credential, the X-Signature header is ignored (if present) and not required.

Is this the same signing secret used for webhooks?

Yes. When you link a webhook to a credential (via credential_id), the same signing secret is used to sign outbound webhook deliveries. This means:

  • You use the signing secret to compute X-Signature on your API requests (inbound)
  • We use the same signing secret to sign webhook payloads delivered to your endpoint (outbound)
  • You verify our webhook signatures using the same secret

This unified approach means one key rotation updates both your request signing and webhook verification. See Webhooks for webhook signature verification details.

How do I rotate the signing secret?

Use the Client Portal API to rotate the signing secret independently of your API key:

curl -X POST "{{host}}/client/api/api-keys/1/rotate-signing-secret" \
  -H "Cookie: session=your-portal-session"

The new secret takes effect immediately for both HMAC request signing and webhook delivery signatures. See Security Considerations for the full rotation procedure.