๐ AI Chat Backend API Documentation
Version: 0.0.1 Last Updated: May 2026
Table of Contents
- Overview
- Getting Started
- Authentication
- API Flow
- API Endpoints
- Data Models
- Enums & Definitions
- Error Handling
- 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" } |
ttsAudioUrlinstream_endis the full CDN URL of the generated audio. It isundefinedwhen 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
threadIdandrunIdare provided in thestream_startSSE 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
}
}
ttsAudioUrlis only present onassistantmessages. 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 |
Recommended Client Flow
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, useGET /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 beundefined.
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"
}
contentis alwaysnullin list responses. CallGET /api/cms/lessons/:documentIdfor 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"
}
contentis the first item of the dynamic zone. The shape depends on the component type:
aip568-speaking.roleplay-configโ roleplay conversation with persona, goal, scenario, tasksaip568-pronunciation.sentence-listโ list of sentences with audio, phonetic, translationsNote: 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 bylanguage.codeto get the entry matching their locale.Note: Top-level lesson fields
title(and topictitleintopics[]) are localized and only returned whenx-country-codeheader 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 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-idheader 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
-
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 } -
Save threadId and runId from stream_start event
if (eventData.type === 'stream_start') { this.threadId = eventData.data.threadId; this.runId = eventData.data.runId; } -
Handle SSE connection drops gracefully
response.body.on('error', (err) => { console.error('Stream error:', err); // Show error message to user // Optionally retry the request }); -
Use pagination for large lists
GET /api/conversations?skip=20&take=10 -
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'); } -
Store Device ID persistently
// On login/first use localStorage.setItem('deviceId', generatedDeviceId);
โ Don'ts
-
Don't forget the
x-device-idheader โ All requests (except public endpoints) require it. -
Don't hardcode IDs โ Always use IDs returned from the API.
-
Don't ignore the
stream_startevent โ You needthreadIdandrunIdto stop generation. -
Don't upload files larger than 100MB โ The API will reject them.
-
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-signatureheader. Without it, the server will reject your request.
What You Need Before Starting
Ask your backend team or administrator for these 2 things:
- API Key โ A unique text string like
"abc-123-xyz-789" - 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:
- You create a special code using your API key and current time
- You encrypt it so nobody can read it
- You send it with your request
- Server checks if it's correct
- 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:
-
Generate a NEW signature for EVERY request
// Good โ const { signature, timestamp } = generateSignature(apiKey, publicKey); await fetch(url, { headers: { 'x-api-signature': signature, ... } }); -
Store keys in environment variables
// Good โ const API_KEY = process.env.API_KEY; const PUBLIC_KEY = process.env.PUBLIC_KEY; -
Generate signature RIGHT BEFORE making the request
// Good โ const makeRequest = async () => { const { signature, timestamp } = generateSignature(...); return fetch(...); // Immediately use it };
โ DON'T:
-
Don't reuse old signatures
// Bad โ const signature = oldSignature; // Don't do this! -
Don't put keys directly in code
// Bad โ const API_KEY = 'abc123'; // Don't hardcode! -
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-signatureandx-api-timestampheaders - โ 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.