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
- Admin generates a signing secret for your API credential (separate from the API secret)
- For each request, you build a canonical string from the request components
- You compute an HMAC-SHA256 of the canonical string using your signing secret
- You send the signature and timestamp in the
X-Signatureheader - The server rebuilds the canonical string, computes its own HMAC, and compares signatures
- Requests with timestamps older than 5 minutes are rejected (replay protection)
Header Format
X-Signature: t=1740000000,v1=a1b2c3d4e5f6...| Component | Description |
|---|---|
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\nTIMESTAMPStep 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/productsStep 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=valuepairs - 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=20Step 4: SHA-256 Body Hash
Compute the SHA-256 hash of the raw request body and encode it as a lowercase hex string.
- For
GETandDELETErequests (no body), hash the empty string - SHA-256 of empty string:
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 - For
POST/PUTrequests, 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]
1740000000The 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:
| Component | Value |
|---|---|
| Signing Secret | whsec_test_secret_key_123 |
| Method | POST |
| Path | /api/v1/orders |
| Query | (empty string) |
| Body | {"product_id":42,"denomination":100,"quantity":1} |
| Timestamp | 1740000000 |
Expected canonical string (with \n as literal newline):
POST
/api/v1/orders
[sha256 of body]
1740000000To verify, compute:
SHA-256("{"product_id":42,"denomination":100,"quantity":1}")for the body hash- Join all five lines with newlines
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
| Status | Error Message | Cause |
|---|---|---|
| 401 | hmac signature required | HMAC is enabled for your credential but the X-Signature header is missing |
| 401 | request timestamp expired | The t= timestamp is more than 5 minutes from the server's current time |
| 401 | invalid hmac signature | The computed signature does not match -- check your canonical string construction |
| 401 | invalid signature header format | The 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:
- Method is uppercase (
POST, notpost) - Path has no trailing slash and no query string
- Query parameters are sorted alphabetically by key
- Body hash is the SHA-256 hex digest of the exact bytes you send (encoding, whitespace, and key order matter)
- 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:
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855Do 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=1Can 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-Signatureon 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.