KDA API Logo
API Docs
Guide

Error Handling

How KDA API returns errors.

All API requests must be made over HTTPS to: https://kda-turbo-web.vercel.app

Error Response Format

All errors follow a consistent structure. When a request fails, the API responds with a non-200 HTTP status code and a JSON body containing the error details:

{
  "success": false,
  "error": {
    "code": "ERROR_CODE",
    "message": "A human-readable description of what went wrong.",
    "transactionId": "KDS-AIR-A1B2C3D4",
    "details": []
  }
}

The transactionId field is included inside the error object when a purchase transaction fails (TRANSACTION_FAILED) or when a duplicate idempotencyKey is detected (CONFLICT). This allows you to reference the failed transaction for support purposes.

Error Codes

Prop

Type

Validation Errors

When a VALIDATION_ERROR occurs, the details array pinpoints exactly which fields are invalid:

{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "One or more validation errors occurred.",
    "details": [
      {
        "field": "identifier",
        "message": "Invalid input: expected string, received undefined"
      }
    ]
  }
}

Rate Limits

Every API key has a per-key rate limit, enforced by the API server on each request:

Key typeLimitWindow
LIVE1,000 requests1 hour
SANDBOX500 requests1 hour

If your base rate limit is too small for your expected traffic, contact support or an administrator to request a limit extension for your key.

The limit applies to all endpoints under /api/services. When the limit for your key is exhausted, subsequent requests are rejected with 429 - RATE_LIMITED until the window resets. The response includes retryAfterMs (time in milliseconds until the window resets) plus both the standard Retry-After and X-Retry-After HTTP headers (in seconds), so you can schedule retries accurately:

{
  "success": false,
  "error": {
    "code": "RATE_LIMITED",
    "message": "Rate limit exceeded for this API key. Please try again later.",
    "retryAfterMs": 1800000
  }
}

Do not retry immediately on a RATE_LIMITED response — every request against the limit is rejected. Wait for the Retry-After duration (or retryAfterMs) before making another call.

Error Examples by Code

{
  "success": false,
  "error": {
    "code": "UNAUTHORIZED",
    "message": "API key is missing or invalid."
  }
}
{
  "success": false,
  "error": {
    "code": "INSUFFICIENT_BALANCE",
    "message": "Your wallet balance is too low to complete the requested transaction."
  }
}
{
  "success": false,
  "error": {
    "code": "FORBIDDEN",
    "message": "The API key scope does not permit access to this endpoint."
  }
}
{
  "success": false,
  "error": {
    "code": "NOT_FOUND",
    "message": "Unsupported endpoint"
  }
}
{
  "success": false,
  "error": {
    "code": "CONFLICT",
    "message": "This transaction has already been completed successfully.",
    "transactionId": "DAT-A1B2C3D4E5F6G7H8I9J0K",
    "idempotencyKey": "IDEMP-abc123def456"
  }
}
{
  "success": false,
  "error": {
    "code": "TRANSACTION_FAILED",
    "message": "Network issue with service provider. Please try again in a few minutes.",
    "transactionId": "ELC-A1B2C3D4E5F6G7H8I9J0K"
  }
}
{
  "success": false,
  "error": {
    "code": "RATE_LIMITED",
    "message": "Rate limit exceeded for this API key. Please try again later.",
    "retryAfterMs": 1800000
  }
}
{
  "success": false,
  "error": {
    "code": "INTERNAL_ERROR",
    "message": "An unexpected server error occurred.",
    "transactionId": "KDS-DC-A1B2C3D4E5F6G7H8I9J0K"
  }
}

TRANSACTION_FAILED always includes the transactionId field, which the provider generates at the start of every purchase for tracing and refunds. CONFLICT errors also include transactionId and idempotencyKey from the previously processed transaction so you can identify which duplicate it refers to.

Handling Errors in Code

When building your integration, always check the success boolean in the response. If a transaction fails, parsing the error.code allows you to trigger specific fallback logic in your application. For validation errors, mapping over the error.details array is the best way to provide precise, field-level feedback directly to your end users.

const response = await fetch("https://kda-turbo-web.vercel.app/api/services/data", {
  method: "POST",
  headers: {
    "Authorization": "Bearer kds_test_YOUR_API_KEY",
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    planId: "kds-data-mtn-sme-v1-003",
    number: "08012345678",
    idempotencyKey: crypto.randomUUID()
  })
});

const data = await response.json();

if (!data.success) {
  const error = data.error;
  console.error(`Error [${error.code}]: ${error.message}`);

  switch (error.code) {
    case "VALIDATION_ERROR":
      error.details.forEach(d => {
        console.error(`  - ${d.field}: ${d.message}`);
      });
      break;

    case "INSUFFICIENT_BALANCE":
      // Fund your wallet and try again
      console.error("Please fund your wallet and try again.");
      break;

    case "CONFLICT":
      // Idempotency key re-use — the transaction already exists
      console.error(`Existing transaction: ${error.transactionId}`);
      // Return the existing ID instead of retrying
      return { transactionId: error.transactionId };

    case "TRANSACTION_FAILED":
      console.error(`Purchase failed. Ref: ${error.transactionId}`);
      // Notify support with the transaction ID if needed
      break;

    case "RATE_LIMITED":
      // Wait for the window to reset before retrying
      const retryAfter = error.retryAfterMs ?? 60000;
      console.error(`Rate limited. Retrying in ${retryAfter}ms...`);
      setTimeout(() => {
        // Re-run your purchase logic here (same idempotencyKey is fine —
        // the original request was never processed)
      }, retryAfter);
      break;

    default:
      // Generic fallback for unexpected errors
      console.error(`Unexpected error: ${error.message}`);
  }
} else {
  console.log("Transaction ID:", data.transactionId);
}
import requests
import time
import uuid

headers = {
    "Authorization": "Bearer kds_test_YOUR_API_KEY",
    "Content-Type": "application/json"
}

payload = {
    "planId": "kds-data-mtn-sme-v1-003",
    "number": "08012345678",
    "idempotencyKey": str(uuid.uuid4())
}

response = requests.post(
    "https://kda-turbo-web.vercel.app/api/services/data",
    headers=headers,
    json=payload
)
data = response.json()

if not data.get("success"):
    error = data.get("error", {})
    code = error.get("code")
    print(f"Error [{code}]: {error.get('message')}")

    if code == "VALIDATION_ERROR":
        for d in error.get("details", []):
            print(f"  - {d.get('field')}: {d.get('message')}")

    elif code == "INSUFFICIENT_BALANCE":
        print("Please fund your wallet and try again.")

    elif code == "CONFLICT":
        print(f"Existing transaction: {error.get('transactionId')}")
        # Return the existing ID instead of retrying

    elif code == "TRANSACTION_FAILED":
        print(f"Purchase failed. Ref: {error.get('transactionId')}")
        # Notify support with the transaction ID if needed

    elif code == "RATE_LIMITED":
        # Wait for the window to reset before retrying
        retry_after = error.get("retryAfterMs", 60000) / 1000
        print(f"Rate limited. Retrying in {retry_after}s...")
        time.sleep(retry_after)

    else:
        print(f"Unexpected error: {error.get('message')}")
else:
    print("Transaction ID:", data.get("transactionId"))
<?php

$ch = curl_init("https://kda-turbo-web.vercel.app/api/services/data");
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer kds_test_YOUR_API_KEY",
        "Content-Type: application/json",
    ],
    CURLOPT_POSTFIELDS => json_encode([
        "planId" => "kds-data-mtn-sme-v1-003",
        "number" => "08012345678",
        "idempotencyKey" => bin2hex(random_bytes(16)),
    ]),
    CURLOPT_RETURNTRANSFER => true,
]);

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

$data = json_decode($response, true);

if (!$data['success']) {
    $error = $data['error'];
    $code = $error['code'];
    echo "Error [$code]: {$error['message']}\n";

    switch ($code) {
        case 'VALIDATION_ERROR':
            foreach ($error['details'] as $d) {
                echo "  - {$d['field']}: {$d['message']}\n";
            }
            break;

        case 'INSUFFICIENT_BALANCE':
            echo "Please fund your wallet and try again.\n";
            break;

        case 'CONFLICT':
            echo "Existing transaction: {$error['transactionId']}\n";
            // Return the existing ID instead of retrying
            break;

        case 'TRANSACTION_FAILED':
            echo "Purchase failed. Ref: {$error['transactionId']}\n";
            // Notify support with the transaction ID if needed
            break;

        case 'RATE_LIMITED':
            // Wait for the window to reset before retrying
            $retryAfter = $error['retryAfterMs'] ?? 60000;
            echo "Rate limited. Retrying in {$retryAfter}ms...\n";
            usleep($retryAfter * 1000);
            break;

        default:
            echo "Unexpected error: {$error['message']}\n";
    }
} else {
    echo "Transaction ID: {$data['transactionId']}\n";
}