agenticerp_voice
SDK Flutter để gọi điện cho khách hàng qua tổng đài AgenticERP ngay trong ứng dụng của bạn: nhân viên bấm "Gọi" → khách thấy số hotline của doanh nghiệp → âm thanh đi ngay trong app → cuộc gọi được ghi âm và lưu vào lịch sử tổng đài như mọi cuộc gọi khác.
| Thành phần | Việc |
|---|---|
AerpClient |
Kết nối tổng đài bằng token: connect(token) · updateToken · sự kiện kết nối |
AerpCall |
Một cuộc gọi: makeCall(AerpCallParams) · hangup · mute · hold · getRecording |
AerpAudioManager |
Loa ngoài / chọn thiết bị âm thanh |
AerpResult |
Kết quả mọi thao tác: status, code, message |
English version below.
1. Thông tin bạn nhận được
Đầu mối triển khai AgenticERP gửi cho bạn qua kênh an toàn một API key của doanh nghiệp — dùng chung cho SDK, gọi qua deep link (mục 7), webhook và API đối tác:
API Key SID(dạngSK…)API Key Secret— chỉ lưu trong cấu hình máy chủ của bạn, không bao giờ đưa vào app- Địa chỉ máy chủ cho SDK (ví dụ
https://erp.example.com)
Mỗi nhân viên gọi qua SDK là một người dùng tổng đài có quyền gọi ra và ít nhất một đầu số gọi ra.
(Dành cho đầu mối triển khai: mỗi doanh nghiệp tạo một key ở Tổng đài AI → Cấu hình → Đầu số → tab SDK gọi
điện → Tạo API key; Secret chỉ hiện một lần. Nếu đối tác dùng cả
SDK lẫn deep link trên key này, trong Tích hợp ứng dụng đối tác bật "Cho phép Voice SDK kết nối bằng key
này" — mặc định tắt, khi đó key chỉ nhận deep link và connect trả sdk_key_invalid.)
2. Máy chủ của bạn ký token
Token là JWT HS256, ký bằng API Key Secret, do máy chủ của bạn phát cho app.
Không bao giờ đưa Secret vào app di động.
| Claim | Bắt buộc | Ý nghĩa |
|---|---|---|
iss |
✔ | API Key SID |
jti |
✔ | Mã duy nhất của token |
exp |
✔ | Thời điểm hết hạn (giây Unix). Không được dài hơn "Hạn token tối đa" của key (mặc định 24 giờ) |
userId |
✔ | Email đăng nhập AgenticERP của nhân viên gọi |
Node.js (jsonwebtoken)
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
function voiceToken(userEmail) {
return jwt.sign(
{ jti: crypto.randomUUID(), userId: userEmail },
process.env.API_KEY_SECRET,
{ algorithm: 'HS256', issuer: process.env.API_KEY_SID, expiresIn: '1h' },
);
}
PHP (firebase/php-jwt)
use Firebase\JWT\JWT;
$token = JWT::encode([
'iss' => getenv('API_KEY_SID'),
'jti' => bin2hex(random_bytes(16)),
'exp' => time() + 3600,
'userId' => $userEmail,
], getenv('API_KEY_SECRET'), 'HS256');
C# (System.IdentityModel.Tokens.Jwt)
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(apiKeySecret));
var token = new JwtSecurityToken(
issuer: apiKeySid,
claims: [new Claim("jti", Guid.NewGuid().ToString()), new Claim("userId", userEmail)],
expires: DateTime.UtcNow.AddHours(1),
signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256));
var jwt = new JwtSecurityTokenHandler().WriteToken(token);
Python (PyJWT)
import time, uuid, jwt
token = jwt.encode(
{"iss": API_KEY_SID, "jti": str(uuid.uuid4()), "exp": int(time.time()) + 3600, "userId": user_email},
API_KEY_SECRET, algorithm="HS256")
Gợi ý: cấp token hạn 1 giờ, chỉ cho nhân viên đã đăng nhập vào hệ thống của bạn.
Cuộc gọi được tính cho ai. userId là email đăng nhập AgenticERP của nhân viên — quy ước duy nhất,
không cần cấu hình gì thêm. Mỗi cuộc gọi qua SDK được ghi vào lịch sử tổng đài dưới tên nhân viên đó
(kèm máy lẻ của họ), nên lọc theo nhân viên, giám sát và thống kê đều tính như cuộc gọi bấm từ app AgenticERP.
Mã nhân viên riêng của hệ thống bạn, nếu cần lưu kèm cuộc gọi, đưa vào customData (ví dụ
{'employee_code': 'NV001'}) và đọc lại ở call.info.customData.
Sau connect, client.user cho biết tên và máy lẻ của nhân viên đã khớp — nên hiện lên app để nhân viên
thấy mình đang gọi bằng máy lẻ nào.
3. Cài đặt
flutter pub add agenticerp_voice
Yêu cầu: Flutter ≥ 3.27 (Dart ≥ 3.6). Android minSdk 21+, iOS 13+.
Android — android/app/src/main/AndroidManifest.xml:
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
iOS — ios/Runner/Info.plist:
<key>NSMicrophoneUsageDescription</key>
<string>Cần micro để nói chuyện với khách hàng qua tổng đài.</string>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
UIBackgroundModes/audio giữ cuộc gọi khi nhân viên chuyển sang app khác. Trong ios/Podfile đặt
platform :ios, '13.0'.
Quyền micro: SDK không tự hỏi quyền — app xin quyền trước khi gọi (ví dụ dùng permission_handler,
xem example/).
4. Sử dụng
Kết nối
import 'package:agenticerp_voice/agenticerp_voice.dart';
final client = AerpClient(baseUrl: 'https://erp.example.com');
client.events.listen((event) async {
switch (event) {
case AerpClientConnected(:final user):
print('Đã kết nối: ${user.displayName}, máy lẻ ${user.extension}');
case AerpTokenWillExpire(): // ~60 giây trước khi hết hạn
case AerpRequestAccessToken(): // phiên đã hết hạn / bị thu hồi
await client.updateToken(await myServer.fetchVoiceToken());
case AerpClientDisconnected(:final reason):
print('Mất kết nối: $reason');
case AerpClientError(:final code, :final message):
print('Lỗi $code: $message');
}
});
final result = await client.connect(await myServer.fetchVoiceToken());
if (!result.status) print('connect thất bại: ${result.code}');
// Các đầu số nhân viên được dùng để gọi ra (khách sẽ thấy số này):
for (final n in client.fromNumbers) print('${n.number} ${n.isDefault ? "(mặc định)" : ""}');
Phiên sống đúng bằng hạn token. Nếu phiên hết hạn giữa cuộc gọi, âm thanh vẫn tiếp tục; chỉ cần
updateToken(...) là SDK nối lại việc theo dõi cuộc gọi (và gửi lại lệnh gác máy nếu nhân viên đã gác
trong lúc chờ).
Gọi điện
final call = AerpCall(client);
call.events.listen((event) {
switch (event) {
case AerpSignalingStateChanged(:final state, :final reason):
// calling → ringing → answered → ended (hoặc busy)
print('Trạng thái: ${state.name} ${reason ?? ""}');
case AerpMediaStateChanged(:final state):
print('Âm thanh: ${state.name}');
case AerpCallInfoUpdated(:final info):
print('Thời lượng: ${call.talkDuration}');
case AerpCallError(:final code, :final message):
print('Lỗi cuộc gọi $code: $message');
}
});
final result = await call.makeCall(const AerpCallParams(
to: '0912345678', // số khách hàng
from: '02812345678', // tuỳ chọn — bỏ trống = đầu số mặc định của nhân viên
customData: {'booking_id': 'BK-1024'}, // mã của bạn, đọc lại ở call.info.customData
));
if (!result.status) print('Không gọi được: ${result.code} — ${result.message}');
await call.mute(true); // tắt tiếng
await call.setSpeakerphoneOn(true); // loa ngoài
await call.hold(true); // giữ máy (khách nghe nhạc chờ), chỉ khi đang nói chuyện
await call.hangup(); // gác máy
await call.destroy(); // giải phóng khi đóng màn hình
Mỗi AerpCall dùng cho một cuộc gọi; mỗi AerpClient có tối đa một cuộc gọi đang diễn ra.
Trong lúc đổ chuông SDK tự phát nhạc chờ (tắt bằng AerpCallOptions(playRingback: false)).
Ghi âm
final rec = await call.getRecording(); // sau khi cuộc gọi kết thúc
if (rec.available) openUrl(rec.playbackUrl!); // link ký sẵn, hiệu lực ~10 phút
Tệp ghi âm cần vài giây để tải lên sau khi gác máy; nếu available == false hãy thử lại sau.
5. Trạng thái và mã lỗi
Trạng thái cuộc gọi (AerpSignalingState): calling (đang quay số) → ringing (máy khách đổ
chuông) → answered (khách nghe máy) → ended / busy.
Lý do kết thúc (reason / call.info.endReason): local_hangup (nhân viên gác máy),
remote_hangup / normal (khách gác máy), no_answer, busy, unreachable (thuê bao không liên lạc
được — nhà mạng phát thông báo), cancelled, media_lost, hoặc mã lỗi của tổng đài.
Mã lỗi thường gặp (AerpResult.code):
| Mã | Ý nghĩa | Cách xử lý |
|---|---|---|
sdk_token_expired |
Token hết hạn | Lấy token mới, gọi updateToken |
sdk_token_signature_invalid |
Sai Secret | Kiểm tra API_KEY_SECRET trên máy chủ của bạn |
sdk_token_ttl_too_long |
Hạn token dài hơn mức cho phép của key | Giảm exp |
sdk_key_invalid / sdk_key_revoked |
SID sai hoặc key đã tắt | Liên hệ quản trị tổng đài |
sdk_user_not_found |
userId không phải email của nhân viên tổng đài nào |
Kiểm tra email đăng nhập AgenticERP của nhân viên |
forbidden |
Nhân viên chưa có quyền gọi ra | Quản trị cấp quyền Tổng đài |
invalid_phone |
Số điện thoại không hợp lệ | Kiểm tra số |
dnc_blocked |
Số nằm trong danh sách không gọi | — |
call_permission_denied |
Máy lẻ không được gọi loại số này (quốc tế, 1900…) | Quản trị nâng quyền gọi |
from_number_not_allowed |
Đầu số from không thuộc quyền của nhân viên |
Dùng số trong client.fromNumbers |
no_from_number |
Nhân viên chưa được cấp đầu số gọi ra | Quản trị cấp đầu số |
call_failed |
Tổng đài không đặt được cuộc gọi | Xem message, thử lại |
call_in_progress |
Đang có cuộc gọi khác | Gác cuộc gọi trước |
not_connected |
Chưa connect |
Gọi connect(token) |
network_error / timeout |
Mất mạng | Thử lại |
6. App ví dụ
example/ là app hoàn chỉnh: nhập địa chỉ tổng đài + token, chọn đầu số, gọi, tắt tiếng, loa ngoài,
giữ máy, gác máy. Example dùng gói agenticerp_voice trên pub.dev, nên có thể chép riêng thư mục này
làm khung cho app của bạn. Chạy: cd example && flutter pub get && flutter run.
7. Cách thứ hai: gọi qua deep link (mở app AgenticERP, không nhúng SDK)
Nếu ứng dụng của bạn không cần tự phát âm thanh cuộc gọi, có thể chuyển việc gọi sang app AgenticERP trên điện thoại của nhân viên: nhân viên bấm Gọi trong ứng dụng của bạn → app AgenticERP mở màn xác nhận → cuộc gọi đi ra bằng đầu số doanh nghiệp, có ghi âm và lịch sử → kết quả trả về hệ thống của bạn qua webhook hoặc API đối tác. Cách này dùng cùng API Key (SID / Secret) với SDK.
| SDK (mục 1–6) | Deep link (mục này) | |
|---|---|---|
| Âm thanh cuộc gọi | Trong app của bạn | Trong app AgenticERP |
| Nhân viên cần cài | Chỉ app của bạn | App của bạn và app AgenticERP (bản cập nhật mới nhất) |
| Kết quả cuộc gọi | AerpCall.events, call.info |
Webhook ký HMAC hoặc API đối tác |
7.1 Liên kết
com.agenticwork.agenticerp://voip/handover?v=1&ref=<mã tham chiếu>&user=<tên người dùng>&to=<số điện thoại>&ts=<giây unix>[&t=<token ký>][&from=<đầu số>][&label=<tên hiển thị>][&ret=<URL quay lại>]
| Tham số | Bắt buộc | Ý nghĩa |
|---|---|---|
v |
✔ | Luôn là 1 |
ref |
✔ | Mã tham chiếu của bạn (A-Z a-z 0-9 . _ : -, tối đa 128 ký tự) — mọi kết quả trả về theo mã này |
user |
✔ | Tên người dùng bên hệ thống của bạn (chỉ để theo dõi) |
to |
✔ | Số khách hàng, 8–15 chữ số, có thể có + ở đầu |
ts |
✔ | Thời điểm tạo liên kết (giây Unix). Liên kết không ký hết hạn sau 10 phút |
t |
Token ký (mục 7.3) — khuyến nghị | |
from |
Đầu số gọi ra; phải là đầu số nhân viên được dùng. Bỏ trống = đầu số mặc định | |
label |
Tên hiển thị trên màn xác nhận (tối đa 80 ký tự), không gửi lên máy chủ | |
ret |
URL quay lại ứng dụng của bạn sau cuộc gọi; tiền tố phải được đăng ký trước |
Mã hoá từng giá trị bằng Uri.encodeComponent (dấu cách thành %20, + thành %2B). Đừng dùng
Uri(queryParameters: …) của Dart cho liên kết này: nó đổi dấu cách thành +, và AgenticERP giữ + nguyên
văn nên Nguyễn Lan sẽ thành Nguyễn+Lan.
7.2 Mở app AgenticERP
Cùng một chuỗi liên kết dùng cho cả hai nền tảng: Android gửi liên kết bằng intent tường minh tới app AgenticERP, iOS mở thẳng URL (scheme theo bundle id).
import 'dart:io';
import 'package:android_intent_plus/android_intent.dart';
import 'package:url_launcher/url_launcher.dart';
String handoverQuery(Map<String, String> p) =>
p.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&');
Future<void> callViaAgenticErp({required String ref, required String user, required String to, String? token}) async {
final query = handoverQuery({
'v': '1',
'ref': ref,
'user': user,
'to': to,
'ts': '${DateTime.now().millisecondsSinceEpoch ~/ 1000}',
if (token != null) 't': token,
});
if (Platform.isAndroid) {
await AndroidIntent(
action: 'action_view',
data: 'com.agenticwork.agenticerp://voip/handover?$query',
package: 'com.agenticwork.agenticerp',
componentName: 'com.agenticwork.agenticerp.MainActivity',
).launch();
} else {
await launchUrl(Uri.parse('com.agenticwork.agenticerp://voip/handover?$query'),
mode: LaunchMode.externalApplication);
}
}
Muốn kiểm tra app AgenticERP đã cài chưa trước khi hiện nút Gọi: Android thêm
<queries><package android:name="com.agenticwork.agenticerp" /></queries> vào AndroidManifest.xml; iOS thêm
com.agenticwork.agenticerp vào LSApplicationQueriesSchemes trong Info.plist rồi dùng canLaunchUrl.
7.3 Token ký cho liên kết (khuyến nghị)
Máy chủ của bạn ký token cho từng lần bấm, đặt vào tham số t. Màn xác nhận khi đó hiện Đã xác thực, và
AgenticERP từ chối liên kết bị sửa ref, to hoặc user.
const token = jwt.sign(
{ jti: crypto.randomUUID(), ref, to, usr: user /* , ret */ },
process.env.API_KEY_SECRET,
{ algorithm: 'HS256', issuer: process.env.API_KEY_SID, audience: 'agentic-erp-handover', expiresIn: 600 },
);
Claim exp không được dài hơn hạn tối đa đã cấu hình cho tích hợp (mặc định 900 giây).
7.4 Nhận kết quả
- Webhook: AgenticERP gửi
POSTtới URL của bạn với các sự kiệnhandover.declined,call.started,call.ended,call.recording. HeaderX-AgenticERP-Signature: t=<giây unix>,v1=<hex>là HMAC-SHA256 của chuỗi<t>.<thân yêu cầu>bằng secret webhook (whsec_…);X-AgenticERP-Event-Iddùng để bỏ trùng. - API đối tác (máy chủ của bạn tự đọc):
GET /api/v1/sdk/partner/handovers?external_ref=<ref>,GET /api/v1/sdk/partner/handovers/{id},GET /api/v1/sdk/partner/handovers/{id}/recording,GET /api/v1/sdk/partner/events?cursor=…, vớiAuthorization: Bearer <JWT>ký bằng API Key Secret:iss= SID,aud=agentic-erp-partner, cójti, hạn tối đa 300 giây.
Hướng dẫn tích hợp đầy đủ (bảng trạng thái, mã lỗi, nội dung webhook, quay lại ứng dụng của bạn, danh sách kiểm thử) có trong tài liệu "Gọi điện từ ứng dụng đối tác qua AgenticERP" (PDF) do đầu mối triển khai AgenticERP gửi kèm API Key.
English
agenticerp_voice lets your Flutter app place phone calls to customers through the AgenticERP
call centre: the customer sees the company hotline, the call audio runs inside your app, and the
call is recorded and logged in the call centre like any other call. Main types: AerpClient
(connection + token), AerpCall (one call), AerpAudioManager (loudspeaker / devices) and
AerpResult (result of every operation).
1. What you receive. Your AgenticERP deployment contact sends you, over a secure channel, a
single API key for the business — shared by the SDK, deep links (section 7), webhooks and the partner API:
API Key SID, API Key Secret (keep it on your server only) and the server address — and tells you
that the token's userId is the staff member's AgenticERP login email. Each staff member
calling through the SDK is a call-centre user with call-out permission and at least one outbound hotline.
(For the deployment contact: create one key per business in Call Center → Configuration → Hotlines →
Calling SDK → Create API key; the Secret is shown once. If the partner
uses both the SDK and deep links on that key, turn on "Allow the Voice SDK to connect with this key" in the
partner-app integration settings — it is off by default, and then connect returns sdk_key_invalid.)
2. Your server signs tokens. HS256 JWT signed with the Secret, claims iss (SID), jti, exp
(not longer than the key's maximum lifetime, 24 h by default) and userId. userId decides which staff member
the call belongs to: every SDK call is logged under that AgenticERP user (with their extension), so per-agent
history, monitoring and statistics count it like a call made from the AgenticERP app. userId is always the
staff member's AgenticERP login email; keep your own employee code in the call's customData if you need it. client.user shows the matched name and extension. Samples for Node.js, PHP,
C# and Python are in section 2 above. Never ship the Secret in the app.
3. Install. flutter pub add agenticerp_voice, then add the Android permissions and the iOS
Info.plist keys from section 3. Flutter ≥ 3.27, Android minSdk 21, iOS 13. Ask for the microphone
permission in your app before calling.
4. Use. AerpClient(baseUrl:) → connect(token) → listen to events and call
updateToken(newToken) on AerpTokenWillExpire / AerpRequestAccessToken. Then
AerpCall(client).makeCall(AerpCallParams(to: ..., from: ..., customData: ...)), follow
AerpSignalingStateChanged (calling → ringing → answered → ended/busy), and use mute,
setSpeakerphoneOn, hold, hangup, getRecording, destroy. One call per AerpCall, one active
call per client. A local ringback tone plays while ringing (disable with
AerpCallOptions(playRingback: false)).
5. Errors. Every operation returns AerpResult(status, code, message); the codes are listed in
the table in section 5 and in AerpErrorCode.
6. Example. example/ is a complete demo app that depends on the pub.dev release, so you can
copy the folder as a starting point (cd example && flutter pub get && flutter run).
7. Second option: call via deep link (no SDK). If your app does not need to carry the call audio itself,
hand the call to the AgenticERP app on the staff member's phone with one link,
com.agenticwork.agenticerp://voip/handover?v=1&ref=…&user=…&to=…&ts=…[&t=…][&from=…][&label=…][&ret=…]: send it as
an explicit intent on Android (package com.agenticwork.agenticerp, activity
com.agenticwork.agenticerp.MainActivity) and open it directly on iOS. Encode each value with Uri.encodeComponent (not
Uri(queryParameters:), which turns spaces into +). Optionally sign each link (t): HS256 with the API Key
Secret, iss = SID, aud = agentic-erp-handover, claims jti, ref, to, usr. Results reach your
server by signed webhook (X-AgenticERP-Signature: t=…,v1=HMAC-SHA256("<t>.<body>")) or the partner API
(/api/v1/sdk/partner/handovers…, /events, bearer JWT with aud = agentic-erp-partner, max 300 s). The same
API key serves both options. The full integration guide ("Call handover from a partner app", PDF) comes with your key.
Libraries
- agenticerp_voice
- AgenticERP Voice SDK — place phone calls through the AgenticERP call centre (tổng đài) from a Flutter app, with the call audio running inside the app.