Developer Support

Complete documentation for the Logistics Platform API, including order ingestion, management, and SDKs.

Order Ingestion API

Base URL

https://api.logisai.com/api/v1

Authentication

All API requests require a valid Bearer token in the Authorization header.

Authorization: Bearer <your-api-token>

API Summary

EndpointMethodPermissionDescription
/orders/ingestPOSTorders:createSubmit orders (async, max 500)
/orders/batch/{batchId}/statusGETorders:readCheck batch status
/orders/rejectedGETorders:readQuery rejected orders
/orders/{orderId}/cancelPOSTorders:cancelCancel single order by ID
/orders/cancel/bulkPOSTorders:cancelBulk cancel by order_no (max 100)

Cancel Order

Cancel an existing order. If assigned, the route will be automatically modified.

POST/api/v1/orders/{orderId}/cancel

Request Body

{
  "reason": "Customer requested cancellation"
}

Bulk Cancel Orders

Cancel multiple orders in a single request using order numbers.

POST/api/v1/orders/cancel/bulk

Request Body

{
  "orders": [
    { "order_no": "ORD-001", "order_status": "cancelled" },
    { "order_no": "ORD-002", "order_status": "customer_cancelled" }
  ]
}

Submit Orders

Submit one or more service orders for asynchronous processing.

POST/api/v1/orders/ingest

Request Body (Simplified)

{
  "orders": [
    {
      "company_id": "uuid",
      "service_group_id": "uuid",
      "order_no": "ORD-123",
      "order_date": "2025-01-15T10:00:00Z",
      "to_location": { ... },
      "to_contact": { ... },
      "service_tasks": [ ... ]
    }
  ]
}

Batch Status

Check the processing status of a submitted batch.

GET/api/v1/orders/batch/{batchId}/status

Rejected Orders

Query orders that failed validation during ingestion.

GET/api/v1/orders/rejected

Async Processing Workflow

  1. 1

    Submit Batch

    POST /orders/ingest returns a batch_id and "queued" status.

  2. 2

    Poll Status

    Poll GET /orders/batch/{id}/status until "completed" or "failed".

  3. 3

    Handle Rejections

    If rejected_count > 0, fetch details via GET /orders/rejected.

SDKs & Libraries

TypeScript SDK

client.ts
/**
 * Logistics Platform API Client
 * TypeScript SDK for order ingestion
 */

// ============================================================================
// Type Definitions (matching Go models)
// ============================================================================

export interface LocationInput {
  street_address1: string;
  street_address2?: string;
  city: string;
  state: string;
  zip: string;
  country_code: string;
  latitude?: number;
  longitude?: number;
}

export interface ContactInput {
  entity_identity?: string;
  entity_name?: string;
  contact_name?: string;
  contact_phone?: string;
  contact_email?: string;
  unit?: string;
  floor?: string;
  instructions?: string;
}

export interface ProductInput {
  identity: string;
  description: string;
  is_included?: boolean;
  metadata?: Record<string, unknown>;
}

export interface PackageInput {
  tracking_number?: string;
  description?: string;
  weight_kg?: number;
  volume_cubic_meters?: number;
  products?: ProductInput[];
  metadata?: Record<string, unknown>;
}

export interface ServiceTaskInput {
  service_type_id: string; // UUID
  direction: 'from' | 'to';
  action_type: string; // 'DELIVER', 'PICKUP', 'INSTALL', etc.
  instructions?: string;
  notes?: string;
  package_index?: number; // 0-based index into packages array
}

export interface OrderInput {
  company_id: string; // UUID
  service_group_id: string; // UUID
  order_no: string;
  order_date: string; // ISO 8601 format
  from_location?: LocationInput;
  to_location: LocationInput;
  from_contact?: ContactInput;
  to_contact: ContactInput;
  is_depot_based_route?: boolean;
  process_automatically?: boolean;
  priority?: number; // 1-10
  category?: 'single' | 'multiple';
  service_tasks: ServiceTaskInput[]; // Required, min 1
  packages?: PackageInput[];
  metadata?: Record<string, unknown>;
}

export interface IngestRequest {
  orders: OrderInput[]; // Required, min 1, max 500
}

export interface IngestResponse {
  batch_id: string;
  order_count: number;
  status: string; // 'queued', 'processing', 'completed', 'failed'
  queued_at: string;
}

export interface BatchStatus {
  batch_id: string;
  status: 'queued' | 'processing' | 'completed' | 'failed';
  total_orders: number;
  accepted_count: number;
  rejected_count: number;
  queued_at: string;
  started_at?: string;
  completed_at?: string;
}

export interface RejectedOrder {
  rejected_order_id: string;
  batch_id: string;
  company_id: string;
  original_payload: Record<string, unknown>;
  rejection_code: string;
  rejection_reason: string;
  rejection_details?: Record<string, unknown>;
  received_at: string;
  processed_at: string;
  reprocess_status: 'pending' | 'reprocessed' | 'abandoned';
}

export interface RejectedOrdersQuery {
  batch_id?: string;
  rejection_code?: string;
  limit?: number;
  offset?: number;
}

export interface CancelOrderRequest {
  reason: string; // Required, 1-500 characters
}

export interface CancelOrderResponse {
  order_id: string;
  status: string;
  was_assigned: boolean;
  route_id?: string;
  route_modified: boolean;
}

export interface BulkCancelOrderInput {
  order_no: string;
  order_status: 'cancelled' | 'customer_cancelled';
}

export interface BulkCancelRequest {
  orders: BulkCancelOrderInput[];
}

export interface BulkCancelResult {
  order_no: string;
  order_id: string;
  status: string;
  success: boolean;
  was_assigned: boolean;
  route_id?: string;
  route_modified: boolean;
}

export interface BulkCancelFailure {
  order_no: string;
  error_code: string;
  error_message: string;
}

export interface BulkCancelResponse {
  total_count: number;
  cancelled_count: number;
  failed_count: number;
  results: BulkCancelResult[];
  failures: BulkCancelFailure[];
}

export interface APIError {
  code: string;
  msg: string;
  details?: Record<string, unknown>;
}

export interface APIResponse<T> {
  success: boolean;
  payload?: T[];
  error?: APIError;
  meta?: Record<string, unknown>;
}

// ============================================================================
// Custom Error Class
// ============================================================================

export class LogisticsAPIError extends Error {
  code: string;
  details?: Record<string, unknown>;

  constructor(code: string, message: string, details?: Record<string, unknown>) {
    super(message);
    this.name = 'LogisticsAPIError';
    this.code = code;
    this.details = details;
  }
}

// ============================================================================
// Client Class
// ============================================================================

export class LogisticsClient {
  private baseURL: string;
  private token: string;

  constructor(baseURL: string, token: string) {
    this.baseURL = baseURL.replace(//$/, '');
    this.token = token;
  }

  /** Update JWT token (for token refresh) */
  setToken(token: string): void {
    this.token = token;
  }

  /** Submit orders for ingestion (max 500 per request) */
  async ingestOrders(orders: OrderInput[]): Promise<IngestResponse> {
    if (orders.length > 500) {
      throw new LogisticsAPIError(
        'VALIDATION_ERROR',
        `Max 500 orders per request, got ${orders.length}`
      );
    }

    const response = await fetch(`${this.baseURL}/api/v1/orders/ingest`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${this.token}`,
      },
      body: JSON.stringify({ orders } as IngestRequest),
    });

    const data: APIResponse<IngestResponse> = await response.json();

    if (!data.success) {
      throw new LogisticsAPIError(
        data.error?.code ?? 'UNKNOWN_ERROR',
        data.error?.msg ?? 'An unknown error occurred',
        data.error?.details
      );
    }

    if (!data.payload || data.payload.length === 0) {
      throw new LogisticsAPIError('EMPTY_RESPONSE', 'Empty payload in response');
    }

    return data.payload[0];
  }

  /** Submit a single order (convenience method) */
  async ingestOrder(order: OrderInput): Promise<IngestResponse> {
    return this.ingestOrders([order]);
  }

  /** Get batch processing status */
  async getBatchStatus(batchId: string): Promise<BatchStatus> {
    const response = await fetch(
      `${this.baseURL}/api/v1/orders/batch/${batchId}/status`,
      {
        method: 'GET',
        headers: { Authorization: `Bearer ${this.token}` },
      }
    );

    const data: APIResponse<BatchStatus> = await response.json();

    if (!data.success) {
      throw new LogisticsAPIError(
        data.error?.code ?? 'UNKNOWN_ERROR',
        data.error?.msg ?? 'Failed to get batch status',
        data.error?.details
      );
    }

    if (!data.payload || data.payload.length === 0) {
      throw new LogisticsAPIError('NOT_FOUND', 'Batch not found');
    }

    return data.payload[0];
  }

  /** Get rejected orders with optional filters */
  async getRejectedOrders(query: RejectedOrdersQuery = {}): Promise<RejectedOrder[]> {
    const params = new URLSearchParams();
    if (query.batch_id) params.set('batch_id', query.batch_id);
    if (query.rejection_code) params.set('rejection_code', query.rejection_code);
    params.set('limit', String(query.limit ?? 100));
    params.set('offset', String(query.offset ?? 0));

    const response = await fetch(
      `${this.baseURL}/api/v1/orders/rejected?${params.toString()}`,
      {
        method: 'GET',
        headers: { Authorization: `Bearer ${this.token}` },
      }
    );

    const data: APIResponse<RejectedOrder> = await response.json();

    if (!data.success) {
      throw new LogisticsAPIError(
        data.error?.code ?? 'UNKNOWN_ERROR',
        data.error?.msg ?? 'Failed to get rejected orders',
        data.error?.details
      );
    }

    return data.payload ?? [];
  }

  /** Wait for batch completion with polling */
  async waitForBatchCompletion(
    batchId: string,
    options: { pollIntervalMs?: number; timeoutMs?: number } = {}
  ): Promise<BatchStatus> {
    const { pollIntervalMs = 5000, timeoutMs = 300000 } = options;
    const startTime = Date.now();

    while (Date.now() - startTime < timeoutMs) {
      const status = await this.getBatchStatus(batchId);

      if (status.status === 'completed' || status.status === 'failed') {
        return status;
      }

      await new Promise((r) => setTimeout(r, pollIntervalMs));
    }

    throw new LogisticsAPIError('TIMEOUT', 'Batch processing timeout');
  }

  /** Cancel an order by ID */
  async cancelOrder(orderId: string, reason: string): Promise<CancelOrderResponse> {
    if (!orderId) {
      throw new LogisticsAPIError('VALIDATION_ERROR', 'Order ID is required');
    }
    if (!reason || reason.length < 1 || reason.length > 500) {
      throw new LogisticsAPIError('VALIDATION_ERROR', 'Reason is required (1-500 characters)');
    }

    const response = await fetch(`${this.baseURL}/api/v1/orders/${orderId}/cancel`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${this.token}`,
      },
      body: JSON.stringify({ reason } as CancelOrderRequest),
    });

    const data: APIResponse<CancelOrderResponse> = await response.json();

    if (!data.success) {
      throw new LogisticsAPIError(
        data.error?.code ?? 'UNKNOWN_ERROR',
        data.error?.msg ?? 'Failed to cancel order',
        data.error?.details
      );
    }

    if (!data.payload || data.payload.length === 0) {
      throw new LogisticsAPIError('EMPTY_RESPONSE', 'Empty response');
    }

    return data.payload[0];
  }

  /** Bulk cancel multiple orders by order_no */
  async bulkCancelOrders(orders: BulkCancelOrderInput[]): Promise<BulkCancelResponse> {
    if (orders.length === 0) {
      throw new LogisticsAPIError('VALIDATION_ERROR', 'At least one order is required');
    }
    if (orders.length > 100) {
      throw new LogisticsAPIError('VALIDATION_ERROR', `Max 100 orders per request, got ${orders.length}`);
    }

    // Validate inputs
    for (let i = 0; i < orders.length; i++) {
      const o = orders[i];
      if (!o.order_no) {
        throw new LogisticsAPIError('VALIDATION_ERROR', `order_no is required at index ${i}`);
      }
      if (o.order_status !== 'cancelled' && o.order_status !== 'customer_cancelled') {
        throw new LogisticsAPIError(
          'VALIDATION_ERROR',
          `Invalid order_status '${o.order_status}' at index ${i}: must be 'cancelled' or 'customer_cancelled'`
        );
      }
    }

    const response = await fetch(`${this.baseURL}/api/v1/orders/cancel/bulk`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${this.token}`,
      },
      body: JSON.stringify({ orders } as BulkCancelRequest),
    });

    const data: APIResponse<BulkCancelResponse> = await response.json();

    if (!data.success) {
      throw new LogisticsAPIError(
        data.error?.code ?? 'UNKNOWN_ERROR',
        data.error?.msg ?? 'Failed to bulk cancel orders',
        data.error?.details
      );
    }

    if (!data.payload || data.payload.length === 0) {
      throw new LogisticsAPIError('EMPTY_RESPONSE', 'Empty response');
    }

    return data.payload[0];
  }
}

Go SDK

main.go
package logistics

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"time"
)

// Client handles communication with the Logistics Platform API
type Client struct {
	baseURL    string
	token      string
	httpClient *http.Client
}

// NewClient creates a new API client with JWT token
func NewClient(baseURL, token string) *Client {
	return &Client{
		baseURL: baseURL,
		token:   token,
		httpClient: &http.Client{
			Timeout: 30 * time.Second,
		},
	}
}

// SetToken updates the JWT token (for token refresh)
func (c *Client) SetToken(token string) {
	c.token = token
}

// LocationInput represents location data from external system
type LocationInput struct {
	StreetAddress1 string  `json:"street_address1"`
	StreetAddress2 string  `json:"street_address2,omitempty"`
	City           string  `json:"city"`
	State          string  `json:"state"`
	Zip            string  `json:"zip"`
	CountryCode    string  `json:"country_code"`
	Latitude       float64 `json:"latitude,omitempty"`
	Longitude      float64 `json:"longitude,omitempty"`
}

// ContactInput represents contact/recipient data
type ContactInput struct {
	EntityIdentity string `json:"entity_identity,omitempty"`
	EntityName     string `json:"entity_name,omitempty"`
	ContactName    string `json:"contact_name,omitempty"`
	ContactPhone   string `json:"contact_phone,omitempty"`
	ContactEmail   string `json:"contact_email,omitempty"`
	Unit           string `json:"unit,omitempty"`
	Floor          string `json:"floor,omitempty"`
	Instructions   string `json:"instructions,omitempty"`
}

// ProductInput represents a product from external system
type ProductInput struct {
	Identity    string                 `json:"identity"`
	Description string                 `json:"description"`
	IsIncluded  bool                   `json:"is_included,omitempty"`
	Metadata    map[string]interface{} `json:"metadata,omitempty"`
}

// PackageInput represents a package from external system
type PackageInput struct {
	TrackingNumber    string                 `json:"tracking_number,omitempty"`
	Description       string                 `json:"description,omitempty"`
	WeightKg          *float64               `json:"weight_kg,omitempty"`
	VolumeCubicMeters *float64               `json:"volume_cubic_meters,omitempty"`
	Products          []ProductInput         `json:"products,omitempty"`
	Metadata          map[string]interface{} `json:"metadata,omitempty"`
}

// ServiceTaskInput represents a service task from external system
type ServiceTaskInput struct {
	ServiceTypeID string `json:"service_type_id"`
	Direction     string `json:"direction"` // "from" or "to"
	ActionType    string `json:"action_type"`
	Instructions  string `json:"instructions,omitempty"`
	Notes         string `json:"notes,omitempty"`
	PackageIndex  *int   `json:"package_index,omitempty"`
}

// OrderInput represents a single order from external system
type OrderInput struct {
	CompanyID            string                 `json:"company_id"`
	ServiceGroupID       string                 `json:"service_group_id"`
	OrderNo              string                 `json:"order_no"`
	OrderDate            string                 `json:"order_date"`
	FromLocation         *LocationInput         `json:"from_location,omitempty"`
	ToLocation           *LocationInput         `json:"to_location"`
	FromContact          *ContactInput          `json:"from_contact,omitempty"`
	ToContact            *ContactInput          `json:"to_contact"`
	IsDepotBasedRoute    bool                   `json:"is_depot_based_route,omitempty"`
	ProcessAutomatically bool                   `json:"process_automatically,omitempty"`
	Priority             int                    `json:"priority,omitempty"`
	Category             string                 `json:"category,omitempty"`
	ServiceTasks         []ServiceTaskInput     `json:"service_tasks"`
	Packages             []PackageInput         `json:"packages,omitempty"`
	Metadata             map[string]interface{} `json:"metadata,omitempty"`
}

// IngestRequest represents the incoming order ingestion request
type IngestRequest struct {
	Orders []OrderInput `json:"orders"`
}

// IngestResponse represents the API response for order ingestion
type IngestResponse struct {
	BatchID    string `json:"batch_id"`
	OrderCount int    `json:"order_count"`
	Status     string `json:"status"`
	QueuedAt   string `json:"queued_at"`
}

// APIError represents an API error response
type APIError struct {
	Code    string                 `json:"code"`
	Message string                 `json:"msg"`
	Details map[string]interface{} `json:"details,omitempty"`
}

// APIResponse is the standard API response wrapper
type APIResponse struct {
	Success bool                   `json:"success"`
	Payload []IngestResponse       `json:"payload,omitempty"`
	Error   *APIError              `json:"error,omitempty"`
	Meta    map[string]interface{} `json:"meta,omitempty"`
}

// BatchStatus represents the status of a processing batch
type BatchStatus struct {
	BatchID       string `json:"batch_id"`
	Status        string `json:"status"`
	TotalOrders   int    `json:"total_orders"`
	AcceptedCount int    `json:"accepted_count"`
	RejectedCount int    `json:"rejected_count"`
	QueuedAt      string `json:"queued_at"`
	StartedAt     string `json:"started_at,omitempty"`
	CompletedAt   string `json:"completed_at,omitempty"`
}

// RejectedOrder represents a rejected order from ingestion
type RejectedOrder struct {
	RejectedOrderID  string                 `json:"rejected_order_id"`
	BatchID          string                 `json:"batch_id"`
	CompanyID        string                 `json:"company_id"`
	OriginalPayload  map[string]interface{} `json:"original_payload"`
	RejectionCode    string                 `json:"rejection_code"`
	RejectionReason  string                 `json:"rejection_reason"`
	RejectionDetails map[string]interface{} `json:"rejection_details,omitempty"`
	ReceivedAt       string                 `json:"received_at"`
	ProcessedAt      string                 `json:"processed_at"`
	ReprocessStatus  string                 `json:"reprocess_status"`
}

// RejectedOrdersQuery contains query parameters for rejected orders
type RejectedOrdersQuery struct {
	BatchID       string
	RejectionCode string
	Limit         int
	Offset        int
}

// CancelOrderRequest request to cancel an order
type CancelOrderRequest struct {
	Reason string `json:"reason"`
}

// CancelOrderResponse response from cancelling an order
type CancelOrderResponse struct {
	OrderID       string  `json:"order_id"`
	Status        string  `json:"status"`
	WasAssigned   bool    `json:"was_assigned"`
	RouteID       *string `json:"route_id,omitempty"`
	RouteModified bool    `json:"route_modified"`
}

// BulkCancelOrderInput represents a single order to cancel in bulk
type BulkCancelOrderInput struct {
	OrderNo     string `json:"order_no"`
	OrderStatus string `json:"order_status"` // "cancelled" or "customer_cancelled"
}

// BulkCancelRequest request for bulk order cancellation
type BulkCancelRequest struct {
	Orders []BulkCancelOrderInput `json:"orders"`
}

// BulkCancelResult represents the result for a single cancelled order
type BulkCancelResult struct {
	OrderNo       string  `json:"order_no"`
	OrderID       string  `json:"order_id"`
	Status        string  `json:"status"`
	Success       bool    `json:"success"`
	WasAssigned   bool    `json:"was_assigned"`
	RouteID       *string `json:"route_id,omitempty"`
	RouteModified bool    `json:"route_modified"`
}

// BulkCancelFailure represents a failed cancellation
type BulkCancelFailure struct {
	OrderNo      string `json:"order_no"`
	ErrorCode    string `json:"error_code"`
	ErrorMessage string `json:"error_message"`
}

// BulkCancelResponse response from bulk cancellation
type BulkCancelResponse struct {
	TotalCount     int                 `json:"total_count"`
	CancelledCount int                 `json:"cancelled_count"`
	FailedCount    int                 `json:"failed_count"`
	Results        []BulkCancelResult  `json:"results"`
	Failures       []BulkCancelFailure `json:"failures"`
}

// IngestOrders submits orders to the logistics platform
func (c *Client) IngestOrders(ctx context.Context, orders []OrderInput) (*IngestResponse, error) {
	if len(orders) > 500 {
		return nil, fmt.Errorf("max 500 orders per request, got %d", len(orders))
	}

	req := IngestRequest{Orders: orders}
	body, err := json.Marshal(req)
	if err != nil {
		return nil, fmt.Errorf("marshal request: %w", err)
	}

	httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/orders/ingest", bytes.NewReader(body))
	if err != nil {
		return nil, fmt.Errorf("create request: %w", err)
	}

	httpReq.Header.Set("Content-Type", "application/json")
	httpReq.Header.Set("Authorization", "Bearer "+c.token)

	resp, err := c.httpClient.Do(httpReq)
	if err != nil {
		return nil, fmt.Errorf("execute request: %w", err)
	}
	defer resp.Body.Close()

	respBody, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, fmt.Errorf("read response: %w", err)
	}

	var apiResp APIResponse
	if err := json.Unmarshal(respBody, &apiResp); err != nil {
		return nil, fmt.Errorf("unmarshal response: %w", err)
	}

	if !apiResp.Success {
		if apiResp.Error != nil {
			return nil, fmt.Errorf("API error [%s]: %s", apiResp.Error.Code, apiResp.Error.Message)
		}
		return nil, fmt.Errorf("API error: unknown")
	}

	if len(apiResp.Payload) == 0 {
		return nil, fmt.Errorf("empty payload in response")
	}

	return &apiResp.Payload[0], nil
}

// GetBatchStatus retrieves the processing status of a batch
func (c *Client) GetBatchStatus(ctx context.Context, batchID string) (*BatchStatus, error) {
	url := fmt.Sprintf("%s/api/v1/orders/batch/%s/status", c.baseURL, batchID)

	httpReq, err := http.NewRequestWithContext(ctx, "GET", url, nil)
	if err != nil {
		return nil, fmt.Errorf("create request: %w", err)
	}

	httpReq.Header.Set("Authorization", "Bearer "+c.token)

	resp, err := c.httpClient.Do(httpReq)
	if err != nil {
		return nil, fmt.Errorf("execute request: %w", err)
	}
	defer resp.Body.Close()

	respBody, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, fmt.Errorf("read response: %w", err)
	}

	var apiResp struct {
		Success bool           `json:"success"`
		Payload []BatchStatus  `json:"payload,omitempty"`
		Error   *APIError      `json:"error,omitempty"`
	}
	if err := json.Unmarshal(respBody, &apiResp); err != nil {
		return nil, fmt.Errorf("unmarshal response: %w", err)
	}

	if !apiResp.Success {
		if apiResp.Error != nil {
			return nil, fmt.Errorf("API error [%s]: %s", apiResp.Error.Code, apiResp.Error.Message)
		}
		return nil, fmt.Errorf("API error: unknown")
	}

	if len(apiResp.Payload) == 0 {
		return nil, fmt.Errorf("batch not found")
	}

	return &apiResp.Payload[0], nil
}

// GetRejectedOrders retrieves rejected orders with optional filters
func (c *Client) GetRejectedOrders(ctx context.Context, query RejectedOrdersQuery) ([]RejectedOrder, error) {
	url := fmt.Sprintf("%s/api/v1/orders/rejected?limit=%d&offset=%d", c.baseURL, query.Limit, query.Offset)
	if query.BatchID != "" {
		url += "&batch_id=" + query.BatchID
	}
	if query.RejectionCode != "" {
		url += "&rejection_code=" + query.RejectionCode
	}

	httpReq, err := http.NewRequestWithContext(ctx, "GET", url, nil)
	if err != nil {
		return nil, fmt.Errorf("create request: %w", err)
	}

	httpReq.Header.Set("Authorization", "Bearer "+c.token)

	resp, err := c.httpClient.Do(httpReq)
	if err != nil {
		return nil, fmt.Errorf("execute request: %w", err)
	}
	defer resp.Body.Close()

	respBody, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, fmt.Errorf("read response: %w", err)
	}

	var apiResp struct {
		Success bool            `json:"success"`
		Payload []RejectedOrder `json:"payload,omitempty"`
		Error   *APIError       `json:"error,omitempty"`
	}
	if err := json.Unmarshal(respBody, &apiResp); err != nil {
		return nil, fmt.Errorf("unmarshal response: %w", err)
	}

	if !apiResp.Success {
		if apiResp.Error != nil {
			return nil, fmt.Errorf("API error [%s]: %s", apiResp.Error.Code, apiResp.Error.Message)
		}
		return nil, fmt.Errorf("API error: unknown")
	}

	return apiResp.Payload, nil
}

// CancelOrder cancels an order by ID with a reason
func (c *Client) CancelOrder(ctx context.Context, orderID string, reason string) (*CancelOrderResponse, error) {
	if orderID == "" {
		return nil, fmt.Errorf("order ID is required")
	}
	if reason == "" {
		return nil, fmt.Errorf("cancellation reason is required")
	}

	req := CancelOrderRequest{Reason: reason}
	body, err := json.Marshal(req)
	if err != nil {
		return nil, fmt.Errorf("marshal request: %w", err)
	}

	url := fmt.Sprintf("%s/api/v1/orders/%s/cancel", c.baseURL, orderID)
	httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
	if err != nil {
		return nil, fmt.Errorf("create request: %w", err)
	}

	httpReq.Header.Set("Content-Type", "application/json")
	httpReq.Header.Set("Authorization", "Bearer "+c.token)

	resp, err := c.httpClient.Do(httpReq)
	if err != nil {
		return nil, fmt.Errorf("execute request: %w", err)
	}
	defer resp.Body.Close()

	respBody, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, fmt.Errorf("read response: %w", err)
	}

	var apiResp struct {
		Success bool                    `json:"success"`
		Payload []CancelOrderResponse   `json:"payload,omitempty"`
		Error   *APIError               `json:"error,omitempty"`
	}
	if err := json.Unmarshal(respBody, &apiResp); err != nil {
		return nil, fmt.Errorf("unmarshal response: %w", err)
	}

	if !apiResp.Success {
		if apiResp.Error != nil {
			return nil, fmt.Errorf("API error [%s]: %s", apiResp.Error.Code, apiResp.Error.Message)
		}
		return nil, fmt.Errorf("API error: unknown")
	}

	if len(apiResp.Payload) == 0 {
		return nil, fmt.Errorf("empty response")
	}

	return &apiResp.Payload[0], nil
}

// BulkCancelOrders cancels multiple orders by order_no with specified status
func (c *Client) BulkCancelOrders(ctx context.Context, orders []BulkCancelOrderInput) (*BulkCancelResponse, error) {
	if len(orders) == 0 {
		return nil, fmt.Errorf("at least one order is required")
	}
	if len(orders) > 100 {
		return nil, fmt.Errorf("max 100 orders per bulk cancel request, got %d", len(orders))
	}

	// Validate order statuses
	for i, o := range orders {
		if o.OrderNo == "" {
			return nil, fmt.Errorf("order_no is required at index %d", i)
		}
		if o.OrderStatus != "cancelled" && o.OrderStatus != "customer_cancelled" {
			return nil, fmt.Errorf("invalid order_status '%s' at index %d: must be 'cancelled' or 'customer_cancelled'", o.OrderStatus, i)
		}
	}

	req := BulkCancelRequest{Orders: orders}
	body, err := json.Marshal(req)
	if err != nil {
		return nil, fmt.Errorf("marshal request: %w", err)
	}

	url := fmt.Sprintf("%s/api/v1/orders/cancel/bulk", c.baseURL)
	httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
	if err != nil {
		return nil, fmt.Errorf("create request: %w", err)
	}

	httpReq.Header.Set("Content-Type", "application/json")
	httpReq.Header.Set("Authorization", "Bearer "+c.token)

	resp, err := c.httpClient.Do(httpReq)
	if err != nil {
		return nil, fmt.Errorf("execute request: %w", err)
	}
	defer resp.Body.Close()

	respBody, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, fmt.Errorf("read response: %w", err)
	}

	var apiResp struct {
		Success bool                 `json:"success"`
		Payload []BulkCancelResponse `json:"payload,omitempty"`
		Error   *APIError            `json:"error,omitempty"`
	}
	if err := json.Unmarshal(respBody, &apiResp); err != nil {
		return nil, fmt.Errorf("unmarshal response: %w", err)
	}

	if !apiResp.Success {
		if apiResp.Error != nil {
			return nil, fmt.Errorf("API error [%s]: %s", apiResp.Error.Code, apiResp.Error.Message)
		}
		return nil, fmt.Errorf("API error: unknown")
	}

	if len(apiResp.Payload) == 0 {
		return nil, fmt.Errorf("empty response")
	}

	return &apiResp.Payload[0], nil
}

// WaitForBatchCompletion polls batch status until completed or timeout
func (c *Client) WaitForBatchCompletion(ctx context.Context, batchID string, pollInterval time.Duration) (*BatchStatus, error) {
	for {
		select {
		case <-ctx.Done():
			return nil, ctx.Err()
		default:
		}

		status, err := c.GetBatchStatus(ctx, batchID)
		if err != nil {
			return nil, err
		}

		switch status.Status {
		case "completed", "failed":
			return status, nil
		case "queued", "processing":
			time.Sleep(pollInterval)
		default:
			return nil, fmt.Errorf("unknown batch status: %s", status.Status)
		}
	}
}