Starship Rewards API
Integration Guides

Idempotency Guide

Prevent duplicate orders and enable safe retries with idempotency keys

Idempotency Guide

Idempotency ensures that multiple identical requests produce the same result as a single request. This is critical for financial operations like order creation and payouts, where duplicate requests could result in double charges.

Why Idempotency Matters

Without idempotency protection, these scenarios can cause duplicate charges:

ScenarioRisk
Network timeout during orderClient retries, order placed twice
User double-clicks submitTwo orders created
Application crash during processingRetry creates duplicate
Load balancer retrySame request sent twice

With idempotency keys, all retries safely return the original response.

Quick Start

Add the Idempotency-Key header to all order and payout requests:

curl -X POST "{{host}}/api/v1/orders" \
  -H "X-API-Key: sk_live_abc123def456" \
  -H "X-API-Secret: your-secret-here" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: ord_abc123_1705689660" \
  -d '{
    "product_id": 123,
    "denomination": 100,
    "quantity": 5
  }'

Requirements

RuleValue
HeaderIdempotency-Key
Required ForPOST /api/v1/orders, POST /api/v1/payouts, POST /client/api/cart/checkout
Length8-256 characters
TTL24 hours
ScopePer client (your keys are isolated from other clients)

Key Generation Strategies

Choose a strategy that ensures uniqueness within your system:

# Strategy 1: UUID
IDEMPOTENCY_KEY=$(uuidgen)
# Result: "550e8400-e29b-41d4-a716-446655440000"

# Strategy 2: Order ID + Timestamp (recommended for traceability)
IDEMPOTENCY_KEY="ord_${ORDER_ID}_$(date +%s)"
# Result: "ord_12345_1705689660"

# Use in request
curl -X POST "{{host}}/api/v1/orders" \
  -H "X-API-Key: $API_KEY" \
  -H "X-API-Secret: $API_SECRET" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $IDEMPOTENCY_KEY" \
  -d '{"product_id": 123, "denomination": 100, "quantity": 5}'
<?php
// Strategy 1: UUID
function generateUuidKey(): string {
    return sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
        mt_rand(0, 0xffff), mt_rand(0, 0xffff),
        mt_rand(0, 0xffff),
        mt_rand(0, 0x0fff) | 0x4000,
        mt_rand(0, 0x3fff) | 0x8000,
        mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
    );
}

// Strategy 2: Order ID + Timestamp (recommended)
function generateTraceableKey(string $orderId): string {
    return "ord_{$orderId}_" . time();
}

// Strategy 3: Hash of parameters (deterministic)
function generateDeterministicKey(int $productId, float $amount, string $userId): string {
    return 'ord_' . substr(hash('sha256', "{$userId}_{$productId}_{$amount}_" . time()), 0, 32);
}

// Usage
$idempotencyKey = generateTraceableKey('INV-2024-001');

$ch = curl_init('{{host}}/api/v1/orders');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'X-API-Key: ' . $apiKey,
        'X-API-Secret: ' . $apiSecret,
        'Content-Type: application/json',
        'Idempotency-Key: ' . $idempotencyKey
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'product_id' => 123,
        'denomination' => 100,
        'quantity' => 5
    ])
]);

$response = curl_exec($ch);
curl_close($ch);
?>

Response Headers

When idempotency is active, these headers are included in responses:

HeaderDescriptionExample
Idempotency-KeyEcho of your keyord_abc123_1705689660
Idempotency-Replayedtrue if response is from cachetrue
Idempotency-Created-AtWhen original request was processed2026-01-21T10:30:00Z

Detecting Cached Responses

<?php
// Check if response was replayed from cache
$headers = [];
curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($curl, $header) use (&$headers) {
    $len = strlen($header);
    $header = explode(':', $header, 2);
    if (count($header) < 2) return $len;
    $headers[trim($header[0])] = trim($header[1]);
    return $len;
});

$response = curl_exec($ch);

if (isset($headers['Idempotency-Replayed']) && $headers['Idempotency-Replayed'] === 'true') {
    echo "Response retrieved from cache (original request time: {$headers['Idempotency-Created-At']})\n";
}
?>

Error Responses

Missing Idempotency Key (400)

{
  "error": {
    "code": "IDEMPOTENCY_KEY_REQUIRED",
    "message": "Idempotency-Key header is required for this operation",
    "details": "Include a unique Idempotency-Key header (8-256 characters)"
  }
}

Key Too Short (400)

{
  "error": {
    "code": "IDEMPOTENCY_KEY_TOO_SHORT",
    "message": "Idempotency-Key must be at least 8 characters"
  }
}

Key Reused with Different Body (422)

This occurs when you use the same key but send different request parameters:

{
  "error": "IdempotencyKeyReused",
  "code": "E_IDEMPOTENCY_KEY_REUSED",
  "message": "Idempotency-Key has already been used with a different request body"
}

Common causes:

  • Reusing the same key for different orders
  • Changing order parameters on retry (e.g., quantity)
  • Using the same key across different endpoints

Safe Retry Pattern

#!/bin/bash
# Safe retry pattern with idempotency

MAX_RETRIES=3
IDEMPOTENCY_KEY="ord_$(uuidgen)"  # Generate ONCE before retries

for i in $(seq 1 $MAX_RETRIES); do
  RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "{{host}}/api/v1/orders" \
    -H "X-API-Key: $API_KEY" \
    -H "X-API-Secret: $API_SECRET" \
    -H "Content-Type: application/json" \
    -H "Idempotency-Key: $IDEMPOTENCY_KEY" \
    -d '{"product_id": 123, "denomination": 100, "quantity": 5}')

  HTTP_CODE=$(echo "$RESPONSE" | tail -1)
  BODY=$(echo "$RESPONSE" | sed '$d')

  if [[ "$HTTP_CODE" -ge 200 && "$HTTP_CODE" -lt 300 ]]; then
    echo "Success: $BODY"
    exit 0
  elif [[ "$HTTP_CODE" -ge 400 && "$HTTP_CODE" -lt 500 ]]; then
    echo "Client error (no retry): $BODY"
    exit 1
  else
    echo "Attempt $i failed with HTTP $HTTP_CODE, retrying..."
    sleep $((2 ** i))  # Exponential backoff
  fi
done

echo "All retries exhausted"
exit 1
<?php
class OrderClient {
    private string $baseUrl;
    private string $apiKey;
    private string $apiSecret;

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

    public function createOrderSafely(array $orderData, int $maxRetries = 3): array {
        // Generate idempotency key ONCE before any retries
        $idempotencyKey = 'ord_' . uniqid() . '_' . time();

        for ($attempt = 1; $attempt <= $maxRetries; $attempt++) {
            try {
                $response = $this->doRequest($orderData, $idempotencyKey);
                return $response;

            } catch (ClientException $e) {
                // 4xx errors - don't retry, fix the request
                throw $e;

            } catch (ServerException $e) {
                // 5xx errors - retry with backoff
                if ($attempt < $maxRetries) {
                    sleep(pow(2, $attempt)); // 2, 4, 8 seconds
                    continue;
                }
                throw $e;

            } catch (NetworkException $e) {
                // Network errors - retry with backoff
                if ($attempt < $maxRetries) {
                    sleep(pow(2, $attempt));
                    continue;
                }
                throw $e;
            }
        }

        throw new Exception('All retries exhausted');
    }

    private function doRequest(array $data, string $idempotencyKey): array {
        $ch = curl_init($this->baseUrl . '/api/v1/orders');
        curl_setopt_array($ch, [
            CURLOPT_POST => true,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT => 30,
            CURLOPT_HTTPHEADER => [
                'X-API-Key: ' . $this->apiKey,
                'X-API-Secret: ' . $this->apiSecret,
                'Content-Type: application/json',
                'Idempotency-Key: ' . $idempotencyKey
            ],
            CURLOPT_POSTFIELDS => json_encode($data)
        ]);

        $response = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $error = curl_error($ch);
        curl_close($ch);

        if ($error) {
            throw new NetworkException($error);
        }

        $result = json_decode($response, true);

        if ($httpCode >= 400 && $httpCode < 500) {
            throw new ClientException($result['message'] ?? 'Client error', $httpCode);
        }

        if ($httpCode >= 500) {
            throw new ServerException($result['message'] ?? 'Server error', $httpCode);
        }

        return $result;
    }
}

// Usage
$client = new OrderClient('{{host}}', 'sk_live_abc123def456', 'your-secret-here');

try {
    $order = $client->createOrderSafely([
        'product_id' => 123,
        'denomination' => 100,
        'quantity' => 5
    ]);
    echo "Order created: {$order['id']}\n";
} catch (Exception $e) {
    echo "Failed: {$e->getMessage()}\n";
}
?>

Best Practices

DO

PracticeWhy
Generate key before making requestDon't generate on retry
Store the key with your orderEnables debugging and reconciliation
Use meaningful prefixes (ord_, pay_)Makes logs easier to read
Include timestampsHelps debugging and prevents collisions
Log every request with its keyMakes troubleshooting easier

DON'T

Anti-PatternRisk
Reuse keys for different operationsCauses "key reused" errors
Change request body on retryBreaks idempotency protection
Use sequential IDs alone (1, 2, 3)Too predictable, potential conflicts
Use user input as keyUsers may submit same data intentionally
Generate new key on each retryDefeats the purpose of idempotency

Endpoints Requiring Idempotency

EndpointMethodReason
/api/v1/ordersPOSTCreates orders, deducts wallet
/api/v1/payoutsPOSTCreates withdrawals, deducts wallet
/client/api/ordersPOSTClient portal order creation
/client/api/payoutsPOSTClient portal payout creation
/client/api/cart/checkoutPOSTCart checkout (creates orders)

FAQ

What happens if I don't send an Idempotency-Key?

For /api/v1/orders and /api/v1/payouts, you'll receive a 400 Bad Request error. These endpoints require idempotency keys due to their financial nature.

How long are idempotency keys valid?

Keys are valid for 24 hours. After that, the same key can be reused (though we recommend always using fresh keys).

Can I use the same key for different endpoints?

Yes, keys are scoped per endpoint and client. However, we recommend using distinct prefixes (ord_, pay_) for clarity.

What if my request times out?

Retry with the same idempotency key. If the original request completed, you'll get the cached response. If it didn't, your retry will be processed normally.

Do GET requests need idempotency keys?

No. Idempotency is only for write operations (POST, PUT, PATCH) that modify state.