md2link

๐Ÿ“˜ AI Chat Backend API Documentation

DraftMay 27, 2026

๐Ÿ“˜ AI Chat Backend API Documentation

Version: 0.0.1 Last Updated: May 2026


Table of Contents

  1. Overview
  2. Getting Started
  3. Authentication
  4. API Flow
  5. API Endpoints
  6. Data Models
  7. Enums & Definitions
  8. Error Handling
  9. Best Practices

1. Overview

What is this API?

The AI Chat Backend API is a RESTful service that allows you to build chat applications powered by AI. It acts as a gateway between your application and AI models (like OpenAI GPT), handling all the complexity of:

  • Managing AI Assistants with custom behaviors
  • Creating and managing Conversations (chat sessions)
  • Sending Messages and receiving AI responses in real-time
  • Uploading files that can be used in conversations

Who should use this API?

  • Mobile app developers building chat features
  • Web developers creating customer support bots
  • Product teams integrating AI into their applications

Key Concepts

Term Description
Assistant An AI personality with specific instructions, like "You are a helpful math tutor"
Conversation A chat session between a user and an assistant
Message A single message in a conversation (from user or assistant)
Streaming (SSE) Real-time delivery of AI responses as they are generated
Device ID Unique identifier for the user's device, used for authentication

2. Getting Started

Development Environment

Base URL

https://api-chatbot-ai-aip568.dev.aperogroup.ai

API Key DEV:

sk-y6o8xn6GuEpqUXlQ9wfOxCmotXPmwdZlXfrhgaGIhXKoulsFwT

Public Key DEV:

-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvwFBgEPkjGpXZLrOSAj+
31+8jpQMuMnjvyZMSvCjlNPo9MPc6CsIcbJXpVc+LojM/mpysqpwJFk46ao61j3l
UgZIGOEJyDGKEF7KOKmKJOnfnzcrx0bewnJwco5k64Iw7JRcj3x92iKOjy3Dwib2
ncCQy5emukM63p8e7Ahl0KFoziERQYzGaRSzV+bhcSDkohZeh4GO1wtItgsLPNaA
Ix1vD4y/3S7RVwcEfZ2hiLmR2WBs7vEfFOpWhBcMH3abRjH8eajfM6NaKtuxUrFc
XTnGAm9qpeBeqzxCitiqTrmodTQjX5Y+myexa2HIRWGoSAOxE0TYE2aHk82iq7Gm
nQIDAQAB
-----END PUBLIC KEY-----

Quick Start Example

Here's a typical flow to get an AI conversation working:

# 1. Get available AI models
GET /api/ai-provider/models

# 2. Create an Assistant
POST /api/assistants

# 3. Create a Conversation with that Assistant
POST /api/conversations

# 4. Send Messages and get AI responses
POST /api/conversations/{conversationId}/messages

3. Authentication

Device ID Authentication

This API uses Device ID based authentication. Every request must include the x-device-id header to identify the user.

Required Headers

Header Value Required Description
x-device-id your-device-id โœ… Yes Unique identifier for the user/device
Content-Type application/json โœ… Yes (for POST/PATCH) Request body format
Accept text/event-stream โš ๏ธ For streaming endpoints Required for SSE responses

Example Request

// JavaScript Example
const deviceId = 'user-device-123';
const { signature, timestamp } = generateSignature(API_KEY, PUBLIC_KEY);

const response = await fetch('{{BASE_URL}}/api/assistants', {
  method: 'GET',
  headers: {
    'x-api-signature': signature, // โ† Required: API signature
    'x-api-timestamp': timestamp.toString(), // โ† Required: Timestamp
    'x-device-id': deviceId,
  },
});
# cURL Example
curl -X GET "{{BASE_URL}}/api/assistants" \
  -H "x-device-id: user-device-123"

Authentication Flow

sequenceDiagram
    participant Client
    participant API

    Note over Client: Generate or retrieve Device ID
    Client->>Client: Store deviceId in localStorage

    Client->>API: Any API Request with x-device-id header
    API->>API: Validate deviceId

    alt Valid Device ID
        API-->>Client: 200 OK + Response Data
    else Invalid/Missing Device ID
        API-->>Client: 401 Unauthorized
        Client->>Client: Redirect to login
    end

Implementation Example

// Recommended: Create a wrapper function for all API calls
const API_BASE_URL = '{{BASE_URL}}';

function authenticatedFetch(path, options = {}) {
  const deviceId = localStorage.getItem('deviceId');

  if (!deviceId) {
    throw new Error('Device ID not found. Please login first.');
  }

  // Generate fresh signature for each request
  const { signature, timestamp } = generateSignature(API_KEY, PUBLIC_KEY);

  return fetch(`${API_BASE_URL}${path}`, {
    ...options,
    headers: {
      ...options.headers,
      'x-api-signature': signature, // โ† Required: API signature
      'x-api-timestamp': timestamp.toString(), // โ† Required: Timestamp
      'x-device-id': deviceId,
    },
  });
}

// Usage
const response = await authenticatedFetch('/api/assistants');
const data = await response.json();

4. API Flow

Typical Conversation Flow

sequenceDiagram
    participant Client
    participant API
    participant AI Provider

    Note over Client,API: Authentication: x-device-id header

    Note over Client,AI Provider: Step 1: Get Available Models
    Client->>API: GET /api/ai-provider/models
    API-->>Client: ["gpt-4o-mini", "inhouse"]

    Note over Client,AI Provider: Step 2: Create Assistant (One-time)
    Client->>API: POST /api/assistants
    API-->>Client: Assistant created (assistantId)

    Note over Client,AI Provider: Step 3: Start Conversation
    Client->>API: POST /api/conversations (with assistantId + model)
    API->>AI Provider: Create thread
    AI Provider-->>API: threadId
    API-->>Client: Conversation created (conversationId)

    Note over Client,AI Provider: Step 4: Chat Loop
    loop Each Message
        Client->>API: POST /api/conversations/{id}/messages
        Note right of Client: Accept: text/event-stream
        API->>AI Provider: Send message to AI
        AI Provider-->>API: SSE: token stream
        API-->>Client: SSE: stream_start {threadId, runId}
        API-->>Client: SSE: stream_chunk {"Hello..."}
        API-->>Client: SSE: stream_chunk {"How can I..."}
        API-->>Client: SSE: stream_end {"Full message"}
    end

    Note over Client,AI Provider: Optional: Stop Generation
    Client->>API: POST /api/conversations/{id}/stop
    Note right of Client: Body: {threadId, runId}
    API->>AI Provider: Cancel run
    API-->>Client: Generation stopped

File Upload Flow

sequenceDiagram
    participant Client
    participant API
    participant S3

    Note over Client,API: x-device-id header required

    Client->>API: POST /api/upload/generate-url
    API-->>Client: { uploadUrl, fileUrl, expiresIn }
    Client->>S3: PUT uploadUrl (file binary data)
    Note right of Client: Content-Type must match requested type
    S3-->>Client: 200 OK
    Note over Client: Use fileUrl in messages

5. API Endpoints

5.1 AI Provider

Get information about available AI models.


Get Supported Models

Retrieves a list of all AI models available for use.

GET /api/ai-provider/models

Response: 200 OK

{
  "data": ["gpt-4o-mini", "inhouse"]
}

Submit TTS Job

Submit a text-to-speech job. Returns immediately with a runId to poll for the result.

POST /api/ai-provider/tts

Required Headers (forwarded from mobile client โ€” BE proxies these to the TTS workflow):

Header Description
x-api-signature API signature
x-api-timestamp Request timestamp
x-api-bundleid App bundle ID

Request Body:

{
  "input": "Hello, how can I help you today?",
  "model": "tts-1",
  "speed": 1.0,
  "language": "en",
  "refAudio": "https://cdn.example.com/voice-ref.wav"
}
Field Type Required Description
input string โœ… Text to synthesize
model string โŒ TTS model name
speed number โŒ Speech speed multiplier (0.5โ€“2.0)
numStep string โŒ Number of diffusion steps
language string โŒ Language code (e.g. en, vi)
refAudio string โŒ Reference audio URL for voice cloning

Response: 201 Created

{
  "data": {
    "runId": "run_abc123xyz",
    "status": "queued"
  },
  "timestamp": "2026-05-19T09:00:00.000Z",
  "path": "/api/ai-provider/tts"
}

Get TTS Job Status

Poll the status of a TTS job submitted via POST /api/ai-provider/tts.

GET /api/ai-provider/tts/:runId

Required Headers: Same as submit โ€” x-api-signature, x-api-timestamp, x-api-bundleid.

Path Parameters:

Parameter Type Description
runId string Run ID returned from the submit endpoint

Response: 200 OK

{
  "data": {
    "runId": "run_abc123xyz",
    "status": "success",
    "output": {
      "path": "tts/2026/05/19/run_abc123xyz.wav",
      "resultFile": "https://cdn.example.com/tts/2026/05/19/run_abc123xyz.wav",
      "resultStatus": "completed"
    },
    "error": null
  },
  "timestamp": "2026-05-19T09:00:05.000Z",
  "path": "/api/ai-provider/tts/run_abc123xyz"
}
status value Meaning
queued Job accepted, not yet started
running Job in progress
success Completed โ€” output.path contains the result
failed Job failed โ€” see error field
timeout Job exceeded processing time

5.2 Assistants

Assistants define how the AI behaves. Think of an assistant as a "personality" you configure.


Create Assistant

Creates a new AI assistant with custom instructions.

POST /api/assistants

Headers:

x-device-id: your-device-id
Content-Type: application/json

Request Body:

{
  "name": "Math Tutor",
  "description": "Helps students with math problems",
  "instructions": "You are a helpful math tutor. Explain concepts step by step.",
  "temperature": 0.7,
  "topP": 1.0,
  "maxTokens": 2048
}
Field Type Required Description
name string โœ… Name of the assistant
description string โŒ Optional description
instructions string โœ… System prompt that defines AI behavior
temperature number โŒ Creativity level (0-2). Default: 0.7
topP number โŒ Nucleus sampling (0-1). Default: 1.0
maxTokens number โŒ Max response length (1-128000). Default: 2048

Response: 201 Created

{
  "data": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "providerAssistantId": "asst_abc123",
    "name": "Math Tutor",
    "description": "Helps students with math problems",
    "instructions": "You are a helpful math tutor...",
    "temperature": 0.7,
    "topP": 1.0,
    "maxTokens": 2048,
    "createdAt": "2026-01-20T10:00:00.000Z",
    "updatedAt": "2026-01-20T10:00:00.000Z"
  }
}

List Assistants

Retrieves a paginated list of assistants.

GET /api/assistants?skip=0&take=10&search=tutor

Headers:

x-device-id: your-device-id
Parameter Type Default Description
skip number 0 Number of items to skip
take number 10 Number of items to return (max: 100)
search string - Filter by name or description

Response: 200 OK

{
  "data": {
    "items": [
      {
        "id": "550e8400-e29b-41d4-a716-446655440000",
        "name": "Math Tutor",
        "description": "Helps students with math problems",
        "createdAt": "2026-01-20T10:00:00.000Z"
      }
    ],
    "total": 1
  }
}

Get Assistant by ID

GET /api/assistants/{id}

Headers:

x-device-id: your-device-id

Response: 200 OK โ€” Returns the full assistant object.


Update Assistant

PATCH /api/assistants/{id}

Headers:

x-device-id: your-device-id
Content-Type: application/json

Request Body: (all fields optional)

{
  "name": "Advanced Math Tutor",
  "temperature": 0.5
}

Response: 200 OK โ€” Returns updated assistant.


Delete Assistant

DELETE /api/assistants/{id}

Headers:

x-device-id: your-device-id

Response: 204 No Content


5.3 Conversations

Conversations are chat sessions linked to a specific assistant.


Create Conversation

POST /api/conversations

Headers:

x-device-id: your-device-id
Content-Type: application/json

Request Body:

{
  "assistantId": "550e8400-e29b-41d4-a716-446655440000",
  "title": "Help with Algebra",
  "model": "gpt-4o-mini",
  "instructions": "Focus on quadratic equations",
  "temperature": 0.5,
  "topP": 0.9,
  "maxTokens": 4096
}
Field Type Required Description
assistantId string โœ… ID of the assistant to use
title string โŒ Conversation title (auto-generated if empty)
model enum โœ… Model to use: gpt-4o-mini or inhouse
instructions string โŒ Override assistant's instructions
temperature number โŒ Override temperature (0-2)
topP number โŒ Override topP (0-1)
maxTokens number โŒ Override max tokens

Response: 201 Created

{
  "data": {
    "id": "660e8400-e29b-41d4-a716-446655440001",
    "title": "Help with Algebra",
    "assistantId": "550e8400-e29b-41d4-a716-446655440000",
    "model": "gpt-4o-mini",
    "providerThreadId": "thread_abc123",
    "createdAt": "2026-01-20T10:30:00.000Z",
    "updatedAt": "2026-01-20T10:30:00.000Z"
  }
}

List Conversations

GET /api/conversations?skip=0&take=10&search=algebra&assistantId=xxx

Headers:

x-device-id: your-device-id
Parameter Type Default Description
skip number 0 Offset for pagination
take number 10 Limit (max: 100)
search string - Search in title
assistantId string - Filter by assistant

Response: 200 OK

{
  "data": {
    "items": [
      {
        "id": "660e8400-e29b-41d4-a716-446655440001",
        "title": "Help with Algebra",
        "assistantId": "550e8400-e29b-41d4-a716-446655440000",
        "updatedAt": "2026-01-20T10:30:00.000Z"
      }
    ],
    "total": 1
  }
}

Get Conversation Details

GET /api/conversations/{id}

Headers:

x-device-id: your-device-id

Response: 200 OK โ€” Returns full conversation with messages.


Update Conversation

PATCH /api/conversations/{id}

Headers:

x-device-id: your-device-id
Content-Type: application/json

Request Body:

{
  "title": "Algebra and Calculus"
}

Response: 200 OK


Delete Conversation

DELETE /api/conversations/{id}

Headers:

x-device-id: your-device-id

Response: 204 No Content

โš ๏ธ This will also delete all messages in the conversation.


5.4 Messages

Messages are the core of conversations. You send a message and receive the AI response as a stream.


Send Message (Streaming)

Send a message to the AI and receive the response as Server-Sent Events (SSE).

POST /api/conversations/{conversationId}/messages

Headers:

x-device-id: your-device-id
Content-Type: application/json
Accept: text/event-stream

Request Body:

{
  "content": "What is the quadratic formula?",
  "model": "gpt-4o-mini"
}
Field Type Required Description
content string โœ… The message text
model enum โŒ Model override: gpt-4o-mini or inhouse

Response: 200 OK โ€” SSE Stream

The response is a stream of events. Each event follows this format:

data: {"type": "<event-type>", "data": {...}}

Event Types:

Event Type Description Data Payload
stream_start Stream has started { threadId: "xxx", runId: "xxx" }
stream_chunk Token chunk received { content: "token" }
stream_end Message complete { content: "full message", ttsAudioUrl?: "https://cdn.../audio.wav" }
error Error occurred { message: "error details" }

ttsAudioUrl in stream_end is the full CDN URL of the generated audio. It is undefined when TTS is disabled or fails (non-fatal โ€” the text response is still valid).

Example SSE Response:

data: {"type":"stream_start","data":{"threadId":"thread_abc","runId":"run_123"}}

data: {"type":"stream_chunk","data":{"content":"The"}}

data: {"type":"stream_chunk","data":{"content":" quadratic"}}

data: {"type":"stream_chunk","data":{"content":" formula"}}

data: {"type":"stream_end","data":{"content":"The quadratic formula is x = (-b ยฑ โˆš(bยฒ-4ac)) / 2a...","ttsAudioUrl":"https://cdn.example.com/tts/2026/05/19/run_abc123.wav"}}

JavaScript Implementation Example:

const sendMessage = async (conversationId, content, model) => {
  const deviceId = localStorage.getItem('deviceId');
  const { signature, timestamp } = generateSignature(API_KEY, PUBLIC_KEY);

  const response = await fetch(
    `/api/conversations/${conversationId}/messages`,
    {
      method: 'POST',
      headers: {
        'x-api-signature': signature, // โ† Required: API signature
        'x-api-timestamp': timestamp.toString(), // โ† Required: Timestamp
        'x-device-id': deviceId,
        'Content-Type': 'application/json',
        Accept: 'text/event-stream',
      },
      body: JSON.stringify({ content, model }),
    },
  );

  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let assistantMessage = '';
  let ttsAudioUrl = '';
  let threadId = '';
  let runId = '';

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    const chunk = decoder.decode(value, { stream: true });
    const lines = chunk.split('\n');

    for (const line of lines) {
      if (line.startsWith('data: ')) {
        try {
          const jsonStr = line.substring(6).trim();
          if (jsonStr === '[DONE]') continue;

          const eventData = JSON.parse(jsonStr);
          const type = eventData.type;
          const data = eventData.data;

          if (type === 'stream_start') {
            // Save these for stop functionality
            threadId = data.threadId;
            runId = data.runId;
          } else if (type === 'stream_chunk') {
            assistantMessage += data.content;
            // Update UI with partial message
          } else if (type === 'stream_end') {
            assistantMessage = data.content;
            ttsAudioUrl = data.ttsAudioUrl; // Full CDN URL, may be undefined
            // Final message received
          }
        } catch (e) {
          console.warn('Failed to parse SSE data', e);
        }
      }
    }
  }

  return { assistantMessage, ttsAudioUrl, threadId, runId };
};

Stop Message Generation

Stop the AI from generating more content mid-stream.

POST /api/conversations/{conversationId}/stop

Headers:

x-device-id: your-device-id
Content-Type: application/json

Request Body:

{
  "threadId": "thread_abc123",
  "runId": "run_xyz789"
}
Field Type Required Description
threadId string โœ… Thread ID from stream_start event
runId string โœ… Run ID from stream_start event

Response: 200 OK

๐Ÿ’ก Important: The threadId and runId are provided in the stream_start SSE event when you send a message. You must save these values to enable the stop functionality.


Get Message History

Retrieve previous messages in a conversation.

GET /api/conversations/{conversationId}/messages?page=1&limit=20

Headers:

x-device-id: your-device-id
Parameter Type Default Description
page number 1 Page number
limit number 20 Items per page (max: 100)

Response: 200 OK

{
  "data": {
    "items": [
      {
        "id": "msg_001",
        "role": "user",
        "content": "What is the quadratic formula?",
        "createdAt": "2026-05-19T10:31:00.000Z"
      },
      {
        "id": "msg_002",
        "role": "assistant",
        "content": "The quadratic formula is x = (-b ยฑ โˆš(bยฒ-4ac)) / 2a...",
        "ttsAudioUrl": "https://cdn.example.com/tts/2026/05/19/run_abc123.wav",
        "createdAt": "2026-05-19T10:31:05.000Z"
      }
    ],
    "total": 2
  }
}

ttsAudioUrl is only present on assistant messages. It is the full CDN URL of the synthesized audio. Omitted when TTS was not generated.


Delete Message

DELETE /api/conversations/{conversationId}/messages/{messageId}

Headers:

x-device-id: your-device-id

Response: 204 No Content


5.5 Upload

Upload files to use in your conversations.


Generate Upload URL

Get a presigned URL to upload a file directly to cloud storage.

POST /api/upload/generate-url

Headers:

x-device-id: your-device-id
Content-Type: application/json

Request Body:

{
  "filename": "document.pdf",
  "contentType": "application/pdf",
  "fileSize": 1024000,
  "folder": "documents"
}
Field Type Required Description
filename string โœ… Original filename (max: 255 chars)
contentType enum โœ… MIME type (see allowed types)
fileSize number โŒ File size in bytes (max: 100MB)
folder string โŒ Subfolder path (max: 200 chars)

Response: 200 OK

{
  "data": {
    "uploadUrl": "https://bucket.s3.amazonaws.com/path?signature...",
    "fileKey": "app-name/uploads/2026/01/20/abc-123/document.pdf",
    "fileUrl": "https://cdn.example.com/uploads/.../document.pdf",
    "expiresIn": 3600,
    "contentType": "application/pdf"
  }
}

How to Upload:

// Step 1: Get the presigned URL
const { signature, timestamp } = generateSignature(API_KEY, PUBLIC_KEY);

const response = await fetch('/api/upload/generate-url', {
  method: 'POST',
  headers: {
    'x-api-signature': signature, // โ† Required: API signature
    'x-api-timestamp': timestamp.toString(), // โ† Required: Timestamp
    'x-device-id': deviceId,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    filename: file.name,
    contentType: file.type,
    fileSize: file.size,
  }),
});
const { data } = await response.json();

// Step 2: Upload the file directly to S3 using PUT
// Note: S3 upload does NOT need signature - it uses presigned URL
await fetch(data.uploadUrl, {
  method: 'PUT',
  headers: {
    'Content-Type': data.contentType,
  },
  body: file, // The actual file binary
});

// Step 3: Use fileUrl in your messages
console.log('File available at:', data.fileUrl);

5.6 CMS

The CMS module exposes read-only content managed in Strapi โ€” countries, AI personas, milestones, topics, lessons, and screen layouts. All endpoints require the x-device-id header.

Localization

Pass the x-country-code header to receive localized text fields (titles, descriptions, etc.). Omit it to receive global content with all localized fields as undefined.

Header Value Required Description
x-device-id any device ID Yes Required on all endpoints
x-country-code EN, VI, โ€ฆ No Country/language code for localized content

Step 1 โ€” Load home screen layout
GET /api/cms/screens/{key}
  โ†’ topicListSection.topics: topic cards with documentId, slug, type, difficulty, cardPreview
  โ†’ bannerSection.lessonDocumentId: featured lesson documentId to deep-link into

Step 2 โ€” User taps a topic โ†’ get lessons for that topic
GET /api/cms/lessons?topicDocumentId={topic.documentId}
  โš ๏ธ  Use topic.documentId as the query param โ€” NOT as the path segment
  โ†’ Returns paginated lessons belonging to that topic (without content body)

Step 3 โ€” User taps a lesson โ†’ get full lesson detail
GET /api/cms/lessons/{lesson.documentId}
  โš ๏ธ  Use lesson.documentId from the lessons list (Step 2) or bannerSection.lessonDocumentId
  โ†’ Full detail: content (roleplay-config or sentence-list), persona, goal, scenario, tasks

Supporting endpoints (load once at app start):
GET /api/cms/countries     โ†’ Language options
GET /api/cms/personas      โ†’ AI character roster
GET /api/cms/milestones    โ†’ Achievement definitions

Common mistake: Do NOT call GET /api/cms/lessons/{topic.documentId} โ€” that endpoint requires a lesson documentId. To get lessons for a topic, use GET /api/cms/lessons?topicDocumentId={topic.documentId}.


List Countries

Returns all active countries (used as language options for the app).

GET /api/cms/countries

Response: 200 OK

{
  "data": [
    {
      "id": 2,
      "documentId": "j4mrfkhde73ra3umedhjbnwh",
      "code": "VI",
      "name": "Vietnamese",
      "isActive": true
    },
    {
      "id": 1,
      "documentId": "z5q41lv7eatn80dt0bax212n",
      "code": "EN",
      "name": "English",
      "isActive": true,
      "cardPreview": {
        "mediaUrl": "https://dev-static.apero.vn/content/uploads/mo_rong_anh_202604011831_3923a402b1.jpeg",
        "role": "DEFAULT"
      }
    }
  ],
  "timestamp": "19/05/2026 17:15:27",
  "path": "/api/cms/countries"
}

List Personas

Returns published AI personas. Localized fields (role, description, personality, systemPromptTemplate) are resolved from the x-country-code header.

GET /api/cms/personas?page=1&pageSize=25

Headers: x-country-code: EN (optional)

Query Parameters:

Parameter Type Default Description
page number 1 Page number
pageSize number 25 Items per page

Response: 200 OK

{
  "data": {
    "items": [
      {
        "id": 5,
        "documentId": "gmv3l8khsr5cni2n8epagwph",
        "name": "Alex",
        "slug": null,
        "cardPreview": {
          "mediaUrl": "https://dev-static.apero.vn/content/uploads/mo_rong_anh_202604011831_3923a402b1.jpeg",
          "role": "DEFAULT"
        },
        "voice": "en-US-GuyNeural",
        "role": "Pronunciation Coach",
        "description": "A professional pronunciation coach focused on clear, natural English speech.",
        "personality": "Precise, supportive, and detail-oriented.",
        "systemPromptTemplate": "You are Alex, a pronunciation coach. Help the user improve their English pronunciation."
      },
      {
        "id": 6,
        "documentId": "xxfr6ej3xz5rqawd2bl4o5no",
        "name": "Emma",
        "slug": null,
        "cardPreview": {
          "mediaUrl": "https://dev-static.apero.vn/content/uploads/mo_rong_anh_202604011831_3923a402b1.jpeg",
          "role": "DEFAULT"
        },
        "voice": "en-US-JennyNeural",
        "role": "English Tutor",
        "description": "A friendly and patient English tutor who helps learners build confidence.",
        "personality": "Warm, encouraging, and clear in explanations.",
        "systemPromptTemplate": "You are Emma, a friendly English tutor. Help the user practice English conversation."
      }
    ],
    "pagination": {
      "page": 1,
      "pageSize": 2,
      "pageCount": 1,
      "total": 2
    }
  },
  "timestamp": "19/05/2026 18:00:45",
  "path": "/api/cms/personas?page=1&pageSize=2"
}

Without x-country-code, all localized text fields (role, description, personality, systemPromptTemplate) will be undefined.


List Milestones

Returns active milestones with reward configuration.

GET /api/cms/milestones?page=1&pageSize=25

Headers: x-country-code: EN (optional)

Query Parameters:

Parameter Type Default Description
page number 1 Page number
pageSize number 25 Items per page

Response: 200 OK

{
  "data": {
    "items": [
      {
        "id": 5,
        "documentId": "bc4phn6qy5qdaz8i6v0yx95r",
        "type": "lessons_completed",
        "triggerValue": 10,
        "isActive": true,
        "cardPreview": {
          "mediaUrl": "https://dev-static.apero.vn/content/uploads/mo_rong_anh_202604011831_3923a402b1.jpeg",
          "role": "DEFAULT"
        },
        "title": "10 Lessons Completed!",
        "description": "Complete your first 10 lessons.",
        "reward": {
          "type": "badge",
          "amount": 1,
          "label": "First Steps Badge",
          "cardPreview": {
            "mediaUrl": "https://dev-static.apero.vn/content/uploads/mo_rong_anh_202604011831_3923a402b1.jpeg",
            "role": "DEFAULT"
          }
        }
      },
      {
        "id": 6,
        "documentId": "vygcrls60bin39w1vf4nttig",
        "type": "streak",
        "triggerValue": 7,
        "isActive": true,
        "cardPreview": {
          "mediaUrl": "https://dev-static.apero.vn/content/uploads/mo_rong_anh_202604011831_3923a402b1.jpeg",
          "role": "DEFAULT"
        },
        "title": "7-Day Streak!",
        "description": "Practice every day for 7 consecutive days.",
        "reward": {
          "type": "energy",
          "amount": 50,
          "label": "50 Energy Points",
          "cardPreview": {
            "mediaUrl": "https://dev-static.apero.vn/content/uploads/mo_rong_anh_202604011831_3923a402b1.jpeg",
            "role": "DEFAULT"
          }
        }
      }
    ],
    "pagination": {
      "page": 1,
      "pageSize": 2,
      "pageCount": 1,
      "total": 2
    }
  },
  "timestamp": "19/05/2026 18:01:42",
  "path": "/api/cms/milestones?page=1&pageSize=2"
}

List Lessons

Returns a paginated list of active lessons. Use topicDocumentId to filter lessons belonging to a specific topic. Content body is not included โ€” call GET /api/cms/lessons/:documentId to get full lesson detail.

GET /api/cms/lessons?page=1&pageSize=25&topicDocumentId=dv1kyej7onlk06zhc54ohi4i

Headers: x-country-code: EN (optional)

Query Parameters:

Parameter Type Default Description
page number 1 Page number
pageSize number 25 Items per page
topicDocumentId string โ€” Filter lessons belonging to a topic (use documentId from topic or screen)

Response: 200 OK

{
  "data": {
    "items": [
      {
        "id": 5,
        "documentId": "pd5dkfzjljijxu3kkn39f3hf",
        "slug": "ordering-coffee",
        "difficulty": "basic",
        "estimatedSeconds": 240,
        "energyCost": 5,
        "isActive": true,
        "cardPreview": {
          "mediaUrl": "https://dev-static.apero.vn/content/uploads/mo_rong_anh_202604011831_3923a402b1.jpeg",
          "role": "DEFAULT"
        },
        "topics": [
          {
            "id": 5,
            "documentId": "dv1kyej7onlk06zhc54ohi4i",
            "slug": "cafe-ordering"
          }
        ],
        "content": null
      }
    ],
    "pagination": {
      "page": 1,
      "pageSize": 25,
      "pageCount": 1,
      "total": 1
    }
  },
  "timestamp": "19/05/2026 18:12:09",
  "path": "/api/cms/lessons?topicDocumentId=dv1kyej7onlk06zhc54ohi4i"
}

content is always null in list responses. Call GET /api/cms/lessons/:documentId for the full lesson with content.


Get Lesson

Returns a single lesson by documentId, including its content (dynamic zone: roleplay-config or sentence-list) with all nested data populated.

GET /api/cms/lessons/:documentId

Headers: x-country-code: EN (optional)

Path Parameters:

Parameter Type Description
documentId string Strapi document ID of the lesson

Response: 200 OK

{
  "data": {
    "id": 6,
    "documentId": "v398c9npek6tuu3inf932apr",
    "slug": "introducing-yourself",
    "difficulty": "beginner",
    "estimatedSeconds": 300,
    "energyCost": 5,
    "isActive": true,
    "cardPreview": {
      "mediaUrl": "https://dev-static.apero.vn/content/uploads/mo_rong_anh_202604011831_3923a402b1.jpeg",
      "role": "DEFAULT"
    },
    "topics": [
      {
        "id": 6,
        "documentId": "j2vjzslcwoewzwt3em2agpfd",
        "slug": "daily-conversation"
      }
    ],
    "content": {
      "__component": "aip568-speaking.roleplay-config",
      "id": 6,
      "systemPromptOverride": null,
      "persona": {
        "id": 6,
        "documentId": "xxfr6ej3xz5rqawd2bl4o5no",
        "name": "Emma",
        "slug": null,
        "voice": "en-US-JennyNeural",
        "cardPreview": {
          "mediaUrl": "https://dev-static.apero.vn/content/uploads/mo_rong_anh_202604011831_3923a402b1.jpeg",
          "role": "DEFAULT"
        }
      },
      "goal": [
        {
          "id": 160,
          "value": "Practice introducing yourself naturally in English.",
          "language": { "code": "EN", "name": "English" }
        },
        {
          "id": 161,
          "value": "Luyแป‡n tแบญp tแปฑ giแป›i thiแป‡u bแบฃn thรขn mแป™t cรกch tแปฑ nhiรชn bแบฑng tiแบฟng Anh.",
          "language": { "code": "VI", "name": "Vietnamese" }
        }
      ],
      "scenario": [
        {
          "id": 162,
          "value": "You just joined a new company and are meeting your team for the first time.",
          "language": { "code": "EN", "name": "English" }
        },
        {
          "id": 163,
          "value": "Bแบกn vแปซa gia nhแบญp mแป™t cรดng ty mแป›i vร  ฤ‘ang gแบทp gแปก nhรณm lแบงn ฤ‘แบงu.",
          "language": { "code": "VI", "name": "Vietnamese" }
        }
      ],
      "inspireExamples": [
        {
          "id": 170,
          "value": "Hi, I'm Sarah. I'm the new marketing manager. I'm originally from New York but I've been living in Hanoi for 3 years.",
          "language": { "code": "EN", "name": "English" }
        }
      ],
      "tasks": [
        {
          "id": 11,
          "requiredPhrases": ["my name is", "i'm from"],
          "description": [
            {
              "id": 164,
              "value": "Tell Emma your name and where you are from.",
              "language": { "code": "EN", "name": "English" }
            },
            {
              "id": 165,
              "value": "Nรณi vแป›i Emma tรชn vร  quรช quรกn cแปงa bแบกn.",
              "language": { "code": "VI", "name": "Vietnamese" }
            }
          ],
          "hint": [
            {
              "id": 168,
              "value": "Try: My name is [name] and I'm from [place].",
              "language": { "code": "EN", "name": "English" }
            }
          ]
        },
        {
          "id": 12,
          "requiredPhrases": ["working as", "my role"],
          "description": [
            {
              "id": 166,
              "value": "Share what your role is in the new company.",
              "language": { "code": "EN", "name": "English" }
            },
            {
              "id": 167,
              "value": "Chia sแบป vai trรฒ cแปงa bแบกn trong cรดng ty mแป›i.",
              "language": { "code": "VI", "name": "Vietnamese" }
            }
          ],
          "hint": [
            {
              "id": 169,
              "value": "Try: I'll be working as a [job title].",
              "language": { "code": "EN", "name": "English" }
            }
          ]
        }
      ]
    }
  },
  "timestamp": "19/05/2026 18:01:53",
  "path": "/api/cms/lessons/v398c9npek6tuu3inf932apr"
}

content is the first item of the dynamic zone. The shape depends on the component type:

  • aip568-speaking.roleplay-config โ€” roleplay conversation with persona, goal, scenario, tasks
  • aip568-pronunciation.sentence-list โ€” list of sentences with audio, phonetic, translations

Note: Localized text fields inside content (e.g. goal, scenario, tasks[].description) are returned as raw arrays of { id, value, language: { code, name } } objects โ€” one entry per language. Clients must filter by language.code to get the entry matching their locale.

Note: Top-level lesson fields title (and topic title in topics[]) are localized and only returned when x-country-code header is provided.

Error Responses:

Status Description
404 Not Found Lesson with given documentId does not exist

Get Screen

Returns the layout configuration for a named screen. Used to drive dynamic home/lobby screen layouts.

GET /api/cms/screens/:key

Headers: x-country-code: EN (optional)

Path Parameters:

Parameter Type Description
key string Screen key: home, roleplay, pronunciation, discover, profile, custom

Response: 200 OK

{
  "data": {
    "id": 3,
    "documentId": "yp6zx56344z4j6cw6s3p06tv",
    "key": "home",
    "isActive": true,
    "topicListSection": {
      "topicType": "speaking",
      "displayMode": "grid",
      "topics": [
        {
          "documentId": "j2vjzslcwoewzwt3em2agpfd",
          "slug": "daily-conversation",
          "type": "speaking",
          "difficulty": "beginner",
          "isActive": true,
          "cardPreview": {
            "mediaUrl": "https://dev-static.apero.vn/content/uploads/mo_rong_anh_202604011831_3923a402b1.jpeg",
            "role": "DEFAULT"
          }
        },
        {
          "documentId": "dv1kyej7onlk06zhc54ohi4i",
          "slug": "cafe-ordering",
          "type": "speaking",
          "difficulty": "basic",
          "isActive": true,
          "cardPreview": {
            "mediaUrl": "https://dev-static.apero.vn/content/uploads/mo_rong_anh_202604011831_3923a402b1.jpeg",
            "role": "DEFAULT"
          }
        }
      ]
    },
    "bannerSection": {
      "cardPreview": {
        "mediaUrl": "https://dev-static.apero.vn/content/uploads/mo_rong_anh_202604011831_3923a402b1.jpeg",
        "role": "DEFAULT"
      },
      "lessonDocumentId": "v398c9npek6tuu3inf932apr"
    }
  },
  "timestamp": "19/05/2026 18:01:48",
  "path": "/api/cms/screens/home"
}

Error Responses:

Status Description
404 Not Found No active screen found for the given key

6. Data Models

Assistant

interface Assistant {
  id: string; // UUID - Unique identifier
  providerAssistantId: string; // Provider's assistant ID
  name: string; // Display name
  description?: string; // Optional description
  instructions: string; // System prompt
  temperature?: number; // Creativity (0-2), default: 0.7
  topP?: number; // Nucleus sampling (0-1), default: 1.0
  maxTokens?: number; // Max response tokens, default: 2048
  createdAt: Date; // Creation timestamp
  updatedAt: Date; // Last update timestamp
}

Conversation

interface Conversation {
  id: string; // UUID - Unique identifier
  title: string; // Conversation title
  assistantId: string; // Linked assistant ID
  providerThreadId?: string; // Provider's thread ID
  model?: string; // AI model being used
  instructions?: string; // Instructions override
  temperature?: number; // Temperature override
  topP?: number; // TopP override
  maxTokens?: number; // MaxTokens override
  userId?: string; // Owner user ID (from device)
  createdAt: Date; // Creation timestamp
  updatedAt: Date; // Last update timestamp
  messages?: Message[]; // Messages in conversation
}

Message

interface Message {
  id: string; // UUID - Unique identifier
  role: 'user' | 'assistant'; // Who sent the message
  content: string; // Message text
  ttsAudioUrl?: string; // Full CDN URL of the TTS audio (assistant messages only)
  providerMessageId?: string; // Provider's message ID
  createdAt: Date; // Timestamp
}

Upload Response

interface UploadResponse {
  uploadUrl: string; // Presigned PUT URL (expires)
  fileKey: string; // S3 object key/path
  fileUrl: string; // Public access URL
  expiresIn: number; // URL expiration in seconds
  contentType: string; // File MIME type
}

CMS โ€” Country

interface CardPreviewDto {
  mediaUrl?: string; // Strapi media URL (absent when no media attached)
  role?: string; // DEFAULT | FEATURED | COMPACT | AVATAR | BANNER
}

interface CmsCountryDto {
  id: number;
  documentId: string;
  code: string; // e.g. "EN", "VI"
  name: string;
  isActive: boolean;
  cardPreview?: CardPreviewDto;
}

CMS โ€” Persona

interface CmsPersonaDto {
  id: number;
  documentId: string;
  name: string;
  slug: string;
  voice?: string; // TTS voice ID (e.g. "en-US-JennyNeural")
  cardPreview?: CardPreviewDto;
  role?: string; // Localized
  description?: string; // Localized
  personality?: string; // Localized
  systemPromptTemplate?: string; // Localized โ€” base system prompt for AI conversations
}

CMS โ€” Milestone

interface CmsRewardDto {
  type: string; // energy | badge | premium_day | custom
  amount?: number;
  label?: string; // Localized
  cardPreview?: CardPreviewDto;
}

interface CmsMilestoneDto {
  id: number;
  documentId: string;
  type: string; // streak | lessons_completed | score_achieved | custom
  triggerValue: number; // Threshold to unlock (e.g. 7 for 7-day streak)
  isActive: boolean;
  cardPreview?: CardPreviewDto;
  title?: string; // Localized
  description?: string; // Localized
  reward?: CmsRewardDto;
}

CMS โ€” Topic

interface CmsTopicDto {
  id: number;
  documentId: string;
  slug: string;
  type: string; // speaking | pronunciation | reading | writing | grammar | custom
  difficulty: string; // beginner | basic | intermediate | advanced
  isActive: boolean;
  cardPreview?: CardPreviewDto;
  title?: string; // Localized
  description?: string; // Localized
  shortDescription?: string; // Localized
}

CMS โ€” Lesson

interface CmsTopicRefDto {
  id: number;
  documentId: string;
  slug: string;
  title?: string; // Localized โ€” only resolved when x-country-code is set
}

interface CmsLessonDto {
  id: number;
  documentId: string;
  slug: string;
  difficulty: string; // beginner | basic | intermediate | advanced
  estimatedSeconds: number;
  energyCost: number;
  isActive: boolean;
  cardPreview?: CardPreviewDto;
  title?: string; // Localized
  topics?: CmsTopicRefDto[];
  content?: RoleplayConfig | SentenceList | null; // Dynamic zone โ€” first item only
}

// Content: Speaking lesson
interface RoleplayConfig {
  __component: 'aip568-speaking.roleplay-config';
  persona?: PersonaRef;
  goal?: string; // Localized
  scenario?: string; // Localized
  inspireExamples?: string[]; // Localized examples
  systemPromptOverride?: string;
  tasks?: RoleplayTask[];
}

interface RoleplayTask {
  description?: string; // Localized
  hint?: string; // Localized
  requiredPhrases?: string[];
}

// Content: Pronunciation lesson
interface SentenceList {
  __component: 'aip568-pronunciation.sentence-list';
  sentences: Sentence[];
}

interface Sentence {
  text: string;
  phonetic?: string;
  difficulty?: string; // easy | medium | hard
  cardPreview?: CardPreviewDto; // Audio file via cardPreview.mediaUrl
  translations?: string[]; // Localized
  hints?: string[]; // Localized
}

CMS โ€” Screen

interface CmsTopicSummaryDto {
  documentId: string;
  slug: string;
  type: string;
  difficulty: string;
  isActive: boolean;
  cardPreview?: CardPreviewDto;
  title?: string; // Localized
}

interface CmsTopicListSectionDto {
  title?: string; // Localized section heading
  topicType?: string; // Pre-filter hint for the list
  displayMode?: string; // grid | list | carousel
  topics?: CmsTopicSummaryDto[];
}

interface CmsBannerSectionDto {
  title?: string; // Localized
  cardPreview?: CardPreviewDto;
  topicDocumentId?: string; // Deep-link to a topic
  lessonDocumentId?: string; // Deep-link to a lesson
}

interface CmsScreenDto {
  id: number;
  documentId: string;
  key: string; // home | roleplay | pronunciation | discover | profile | custom
  isActive: boolean;
  title?: string; // Localized
  topicListSection?: CmsTopicListSectionDto;
  bannerSection?: CmsBannerSectionDto;
}

SSE Event

interface SSEEvent {
  type: 'stream_start' | 'stream_chunk' | 'stream_end' | 'error';
  data: StreamStartData | StreamChunkData | StreamEndData | ErrorData;
}

interface StreamStartData {
  threadId: string; // Thread ID for stop functionality
  runId: string; // Run ID for stop functionality
}

interface StreamChunkData {
  content: string; // Partial message token
}

interface StreamEndData {
  content: string; // Full complete message
  ttsAudioUrl?: string; // Full CDN URL of the TTS audio (undefined if TTS failed/disabled)
}

interface ErrorData {
  message: string; // Error description
}

7. Enums & Definitions

Supported Models

Value Description
gpt-4o-mini OpenAI GPT-4o Mini model
inhouse Internal/custom AI model

Message Roles

Value Description
user Message sent by the user
assistant Message generated by the AI

Allowed File Types

MIME Type Extension Description
image/jpeg .jpg, .jpeg JPEG image
image/png .png PNG image
image/gif .gif GIF image
image/webp .webp WebP image
application/pdf .pdf PDF document
application/msword .doc Word document (legacy)
application/vnd.openxmlformats-officedocument.wordprocessingml.document .docx Word document
application/json .json JSON file
text/plain .txt Plain text
text/csv .csv CSV file

8. Error Handling

Error Response Format

All errors follow this structure:

{
  "statusCode": 400,
  "message": "Validation failed",
  "error": "Bad Request"
}

Common HTTP Status Codes

Code Meaning When It Happens
200 OK Request successful
201 Created Resource created
204 No Content Deleted successfully
400 Bad Request Invalid input data
401 Unauthorized Missing or invalid x-device-id header
403 Forbidden No permission to access resource
404 Not Found Resource doesn't exist
422 Unprocessable Entity Validation error
429 Too Many Requests Rate limit exceeded
500 Internal Server Error Server issue

Validation Errors

{
  "statusCode": 400,
  "message": ["name must be a string", "instructions should not be empty"],
  "error": "Bad Request"
}

Handling 401 Unauthorized

When you receive a 401 error, it usually means:

  • The x-device-id header is missing
  • The Device ID is invalid or expired

Recommended action: Clear local storage and redirect user to login.

if (response.status === 401) {
  localStorage.removeItem('deviceId');
  window.location.href = '/login';
}

9. Best Practices

โœ… Do's

  1. Always include required headers

    const { signature, timestamp } = generateSignature(API_KEY, PUBLIC_KEY);
    
    headers: {
      'x-api-signature': signature,           // โ† Required
      'x-api-timestamp': timestamp.toString(), // โ† Required
      'x-device-id': localStorage.getItem('deviceId') // โ† Required
    }
  2. Save threadId and runId from stream_start event

    if (eventData.type === 'stream_start') {
      this.threadId = eventData.data.threadId;
      this.runId = eventData.data.runId;
    }
  3. Handle SSE connection drops gracefully

    response.body.on('error', (err) => {
      console.error('Stream error:', err);
      // Show error message to user
      // Optionally retry the request
    });
  4. Use pagination for large lists

    GET /api/conversations?skip=20&take=10
  5. Validate file types before upload

    const allowedTypes = [
      'image/jpeg',
      'image/png',
      'application/pdf',
      'text/plain',
    ];
    if (!allowedTypes.includes(file.type)) {
      throw new Error('Invalid file type');
    }
  6. Store Device ID persistently

    // On login/first use
    localStorage.setItem('deviceId', generatedDeviceId);

โŒ Don'ts

  1. Don't forget the x-device-id header โ€” All requests (except public endpoints) require it.

  2. Don't hardcode IDs โ€” Always use IDs returned from the API.

  3. Don't ignore the stream_start event โ€” You need threadId and runId to stop generation.

  4. Don't upload files larger than 100MB โ€” The API will reject them.

  5. Don't parse SSE responses as regular JSON โ€” They have a special format (data: {...}\n\n).

Complete API Wrapper Implementation

class AIClient {
  constructor(baseUrl, apiKey, publicKey) {
    this.baseUrl = baseUrl;
    this.apiKey = apiKey;
    this.publicKey = publicKey;
    this.deviceId = localStorage.getItem('deviceId');
  }

  async request(path, options = {}) {
    if (!this.deviceId) {
      throw new Error('Not authenticated');
    }

    // Generate fresh signature for each request
    const { signature, timestamp } = generateSignature(
      this.apiKey,
      this.publicKey,
    );

    const response = await fetch(`${this.baseUrl}${path}`, {
      ...options,
      headers: {
        ...options.headers,
        'x-api-signature': signature,
        'x-api-timestamp': timestamp.toString(),
        'x-device-id': this.deviceId,
      },
    });

    if (response.status === 401) {
      localStorage.removeItem('deviceId');
      this.deviceId = null;
      throw new Error('Session expired');
    }

    return response;
  }

  // Assistants
  async getAssistants(params = {}) {
    const query = new URLSearchParams(params).toString();
    const res = await this.request(`/api/assistants?${query}`);
    return res.json();
  }

  async createAssistant(data) {
    const res = await this.request('/api/assistants', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data),
    });
    return res.json();
  }

  // Conversations
  async createConversation(assistantId, title, model) {
    const res = await this.request('/api/conversations', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ assistantId, title, model }),
    });
    return res.json();
  }

  // Messages (Streaming)
  async sendMessage(conversationId, content, model, onChunk, onComplete) {
    const res = await this.request(
      `/api/conversations/${conversationId}/messages`,
      {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Accept: 'text/event-stream',
        },
        body: JSON.stringify({ content, model }),
      },
    );

    const reader = res.body.getReader();
    const decoder = new TextDecoder();
    let threadId, runId;

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;

      const chunk = decoder.decode(value, { stream: true });
      for (const line of chunk.split('\n')) {
        if (line.startsWith('data: ')) {
          try {
            const event = JSON.parse(line.substring(6));
            if (event.type === 'stream_start') {
              threadId = event.data.threadId;
              runId = event.data.runId;
            } else if (event.type === 'stream_chunk') {
              onChunk?.(event.data.content);
            } else if (event.type === 'stream_end') {
              onComplete?.(event.data.content, event.data.ttsAudioUrl, {
                threadId,
                runId,
              });
            }
          } catch (e) {}
        }
      }
    }
  }

  async stopGeneration(conversationId, threadId, runId) {
    return this.request(`/api/conversations/${conversationId}/stop`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ threadId, runId }),
    });
  }
}

// Usage
const client = new AIClient(
  '{{BASE_URL}}',
  process.env.API_KEY,
  process.env.PUBLIC_KEY,
);

10. API Signature Authentication

What is API Signature?

Think of API signature like a secret handshake between your app and our server. Every time you make a request, you need to prove it's really you by sending a special encrypted code.

[!IMPORTANT] All API requests MUST include a signature in the x-api-signature header. Without it, the server will reject your request.


What You Need Before Starting

Ask your backend team or administrator for these 2 things:

  1. API Key โ€” A unique text string like "abc-123-xyz-789"
  2. Public Key โ€” A long text that starts with -----BEGIN PUBLIC KEY-----

Save these somewhere safe (like environment variables). Never put them directly in your code!


How It Works (Simple Explanation)

sequenceDiagram
    participant You as Your App
    participant Server as API Server

    You->>You: 1. Get current time
    You->>You: 2. Combine: time + API key
    You->>You: 3. Encrypt with public key
    You->>You: 4. Convert to base64 text

    You->>Server: 5. Send request with signature

    alt โœ… Signature is valid
        Server-->>You: 200 OK - Here's your data
    else โŒ Signature is wrong
        Server-->>You: 400 Error - Invalid signature
    end

In simple words:

  1. You create a special code using your API key and current time
  2. You encrypt it so nobody can read it
  3. You send it with your request
  4. Server checks if it's correct
  5. If correct โ†’ you get data. If wrong โ†’ you get error.

Step-by-Step: How to Add Signature to Your App

Step 1: Copy This Function

Copy this function into your project. This creates the signature for you:

const crypto = require('crypto');

// --- Helper: Generate Signature ---
function generateSignature(apiKey, publicKeyPem) {
  const timestamp = Date.now();
  const payload = timestamp + '@@@' + apiKey;

  const buffer = Buffer.from(payload, 'utf8');
  const encrypted = crypto.publicEncrypt(
    {
      key: publicKeyPem,
      padding: crypto.constants.RSA_PKCS1_PADDING,
    },
    buffer,
  );

  const signature = encrypted.toString('base64');
  return { signature, timestamp };
}

What this does:

  • Gets current time in milliseconds
  • Combines it with your API key using @@@ as separator
  • Encrypts it using the public key
  • Returns the signature and timestamp

Step 2: Use It in Your Requests

Here's how to make a request with signature:

// Your credentials (get these from your backend team)
const API_KEY = 'your-api-key-here';
const PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
-----END PUBLIC KEY-----`;

// Generate signature
const { signature, timestamp } = generateSignature(API_KEY, PUBLIC_KEY);

// Make request
const response = await fetch('https://api.example.com/api/assistants', {
  method: 'GET',
  headers: {
    'x-api-signature': signature, // โ† Add this
    'x-api-timestamp': timestamp.toString(), // โ† Add this
    'x-device-id': 'your-device-id',
    'Content-Type': 'application/json',
  },
});

const data = await response.json();
console.log(data);

Complete Example: API Client Class

Here's a ready-to-use class that handles everything for you:

const crypto = require('crypto');
const fetch = require('node-fetch');

class APIClient {
  constructor(apiKey, publicKey, baseUrl) {
    this.apiKey = apiKey;
    this.publicKey = publicKey;
    this.baseUrl = baseUrl;
  }

  // Generate signature automatically
  generateSignature() {
    const timestamp = Date.now();
    const payload = `${timestamp}@@@${this.apiKey}`;
    const buffer = Buffer.from(payload, 'utf8');

    const encrypted = crypto.publicEncrypt(
      {
        key: this.publicKey,
        padding: crypto.constants.RSA_PKCS1_PADDING,
      },
      buffer,
    );

    return {
      signature: encrypted.toString('base64'),
      timestamp,
    };
  }

  // Make any API request
  async request(endpoint, options = {}) {
    // Generate fresh signature for this request
    const { signature, timestamp } = this.generateSignature();

    const response = await fetch(`${this.baseUrl}${endpoint}`, {
      ...options,
      headers: {
        ...options.headers,
        'x-api-signature': signature,
        'x-api-timestamp': timestamp.toString(),
        'x-device-id': options.deviceId || 'default-device',
        'Content-Type': 'application/json',
      },
    });

    if (!response.ok) {
      const error = await response.json();
      throw new Error(`API Error: ${error.message}`);
    }

    return response.json();
  }

  // Example: Get assistants
  async getAssistants(deviceId) {
    return this.request('/api/assistants', { deviceId });
  }

  // Example: Create conversation
  async createConversation(deviceId, data) {
    return this.request('/api/conversations', {
      method: 'POST',
      deviceId,
      body: JSON.stringify(data),
    });
  }
}

// --- How to use it ---
const client = new APIClient(
  process.env.API_KEY, // Your API key
  process.env.PUBLIC_KEY, // Your public key
  'https://api.example.com', // API base URL
);

// Now just use it!
const assistants = await client.getAssistants('device-123');
const conversation = await client.createConversation('device-123', {
  assistantId: 'xxx',
  title: 'My Chat',
});

Common Errors and How to Fix Them

Error Message What It Means How to Fix
MISSING_REQUIRED_FIELDS You forgot to add signature headers Make sure you include both x-api-signature and x-api-timestamp
SIGNATURE_ERROR_DECRYPT Wrong public key or API key Double-check you're using the correct keys from your backend team
SIGNATURE_EXPIRED Request took too long (> 5 minutes) Generate a new signature right before each request
SIGNATURE_INVALID_TIMESTAMP Timestamp doesn't match Don't change the timestamp after creating the signature

Example Error Response

{
  "statusCode": 400,
  "message": "Signature expired",
  "error": "Bad Request"
}

What to do: Generate a fresh signature and try again.


Important Rules (Please Read!)

โœ… DO:

  1. Generate a NEW signature for EVERY request

    // Good โœ…
    const { signature, timestamp } = generateSignature(apiKey, publicKey);
    await fetch(url, { headers: { 'x-api-signature': signature, ... } });
  2. Store keys in environment variables

    // Good โœ…
    const API_KEY = process.env.API_KEY;
    const PUBLIC_KEY = process.env.PUBLIC_KEY;
  3. Generate signature RIGHT BEFORE making the request

    // Good โœ…
    const makeRequest = async () => {
      const { signature, timestamp } = generateSignature(...);
      return fetch(...); // Immediately use it
    };

โŒ DON'T:

  1. Don't reuse old signatures

    // Bad โŒ
    const signature = oldSignature; // Don't do this!
  2. Don't put keys directly in code

    // Bad โŒ
    const API_KEY = 'abc123'; // Don't hardcode!
  3. Don't modify timestamp after creating signature

    // Bad โŒ
    const { signature, timestamp } = generateSignature(...);
    timestamp = Date.now(); // Don't change it!

Quick Test

Want to test if your signature works? Run this:

const crypto = require('crypto');

// Put your real keys here
const TEST_API_KEY = 'your-api-key';
const TEST_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
... your public key ...
-----END PUBLIC KEY-----`;

function testSignature() {
  console.log('๐Ÿงช Testing signature generation...\n');

  const timestamp = Date.now();
  const payload = `${timestamp}@@@${TEST_API_KEY}`;
  console.log('๐Ÿ“ฆ Payload:', payload);

  const buffer = Buffer.from(payload, 'utf8');
  const encrypted = crypto.publicEncrypt(
    {
      key: TEST_PUBLIC_KEY,
      padding: crypto.constants.RSA_PKCS1_PADDING,
    },
    buffer,
  );

  const signature = encrypted.toString('base64');
  console.log('โœ… Signature created!');
  console.log('๐Ÿ“ First 50 chars:', signature.substring(0, 50) + '...');

  console.log('\n๐Ÿ“ค Headers to send:');
  console.log({
    'x-api-signature': signature,
    'x-api-timestamp': timestamp.toString(),
  });
}

testSignature();

If this runs without errors, your signature generation is working! ๐ŸŽ‰


Checklist Before Going Live

Before you deploy to production, make sure:

  • โœ… You have the API key from your backend team
  • โœ… You have the public key from your backend team
  • โœ… Keys are stored in environment variables (not in code)
  • โœ… You're generating a NEW signature for each request
  • โœ… You're including both x-api-signature and x-api-timestamp headers
  • โœ… You tested with the test script above
  • โœ… Error handling is in place for signature failures

Need Help?

Common questions:

Q: Where do I get the API key and public key?
A: Ask your backend team or system administrator. They will provide both.

Q: Can I reuse the same signature for multiple requests?
A: No! Generate a fresh signature for each request.

Q: My signature keeps expiring, why?
A: Signatures expire after 5 minutes. Generate it right before making the request.

Q: What's the @@@ in the payload?
A: It's just a separator between timestamp and API key. Don't change it.

Q: Do I need to understand RSA encryption?
A: No! Just copy the generateSignature function and use it. It handles everything.