BestChar · SDK v2
API & widget developer guide
Connect character chat to your site with signed visitor identity and current-page context. These examples use SDK v2 and the current server API.
Install with one script
Use a widget key that allows anonymous chat to load saved colors, greetings and the portrait launcher. Saved edits apply when the widget reloads. Keys requiring signed users need the authenticated identity example below.
<script src="https://bestchar.com/sdk/v2/widget.js?key=YOUR_WIDGET_KEY&lang=ko" defer></script>Saved settings and precedence
Explicit init values override saved server settings, which override SDK defaults. Themes merge by field. Copy accepts a string or ko/ja/en map; missing translations use built-in text. Without explicit context, public title/description metadata and URL update automatically on SPA navigation. Set autoContext:false to disable collection. Calling setContext preserves the explicit context.
// Owner account API; never expose your account token in the embedded page.
// PUT /api/v1/characters/YOUR_CHARACTER_ID/widget-settings
{
"header_background_color": "#222222",
"header_text_color": "#FFFFFF",
"ai_message_bg_color": "#333333",
"ai_message_text_color": "#FFFFFF",
"launcher_style": "avatar",
"welcome_message": {"ko":"안녕하세요!", "ja":"こんにちは!", "en":"Hello!"},
"placeholder_text": {"ko":"메시지 입력", "ja":"メッセージを入力", "en":"Type a message"},
"proactive_message": {"ko":"도움이 필요하신가요?", "ja":"お手伝いしましょうか?", "en":"Need help?"}
}Choose the correct authentication
Account APIs use Authorization: Bearer <login token>. A public widget key (bc_) cannot replace a login token. Widget chat uses X-API-Key and X-External-User-ID, plus X-User-Hash when user verification is enabled. Catalog management additionally requires a server secret and character scope. Never put signing_secret or login tokens in public snippets.
# Account login: form-encoded username (email) and password.
curl --fail-with-body https://bestchar.com/api/v1/auth/signin \
--data-urlencode "username=$BESTCHAR_EMAIL" \
--data-urlencode "password=$BESTCHAR_PASSWORD"
# Store the returned access_token securely as BESTCHAR_LOGIN_TOKEN.
curl --fail-with-body https://bestchar.com/api/v1/characters/ \
-H "Authorization: Bearer $BESTCHAR_LOGIN_TOKEN"
# Widget chat uses X-API-Key + X-External-User-ID + X-User-Hash instead.
# Never use the public bc_ widget key as an account login token.Prepare a project and widget key
Create a project and assign a character in the dashboard, then issue a key as its owner. Replace the UUID and domain placeholders below. api_key and signing_secret are returned only at creation. Allowlist your host site and use HTTPS in production. Sending a signature alone does not enable verification: a BestChar operator must enable require_user_validation for that key with the script below.
curl --fail-with-body https://bestchar.com/api/v1/project-api-keys \
-H "Authorization: Bearer $BESTCHAR_LOGIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"project_id":"YOUR_PROJECT_UUID","key_name":"my-site","allowed_domains":["example.com"],"require_https":true}'
# The response includes signing_secret ONCE. Store it on your server only.
# Enable user verification yourself (console toggle, or this call).
# Wire userHash first: enabling it rejects every request without one.
curl --fail-with-body -X PATCH \
https://bestchar.com/api/v1/project-api-keys/YOUR_KEY_UUID/security \
-H "Authorization: Bearer $BESTCHAR_LOGIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"require_user_validation":true}'
# Lost the secret? Rotate it (the API key itself stays the same):
# POST /api/v1/project-api-keys/YOUR_KEY_UUID/rotate-secretConnect identity and memory
Generate a stable user ID and HMAC-SHA256 signature on your authenticated host server, for example promge:user:42. Never sign arbitrary client-supplied IDs. The Python helper is host-side example code; /api/bestchar/identity is an authenticated endpoint you implement, not a built-in BestChar endpoint. Keep the same ID within the same integration scope to preserve continuity. Different IDs across sites are not automatically linked. Call shutdown() on logout.
# Host server: call only with the authenticated session user.
import hashlib
import hmac
import os
def bestchar_identity(authenticated_user):
user_id = f"promge:user:{authenticated_user.id}"
signature = hmac.new(
os.environ["BESTCHAR_SIGNING_SECRET"].encode(),
user_id.encode(), hashlib.sha256,
).hexdigest()
return {"userId": user_id, "userHash": signature, "traits": {}}
# Return this from YOUR authenticated /api/bestchar/identity endpoint.
# Never accept an arbitrary user ID from the browser to sign.
# Response header: Cache-Control: private, no-storeEmbed on your website
Load the SDK, then initialize with identity returned by your server. This example requires a signed-in user. Replace YOUR_WIDGET_KEY and YOUR_CHARACTER_ID and implement the host identity endpoint first. The host domain must be allowlisted for the key.
<script src="https://bestchar.com/sdk/v2/widget.js"></script>
<script type="module">
const response = await fetch('/api/bestchar/identity', {
credentials: 'same-origin', cache: 'no-store'
});
if (!response.ok) throw new Error('Sign in to continue');
const identity = await response.json();
await window.BestChar.init({
host: 'https://bestchar.com',
apiKey: 'YOUR_WIDGET_KEY',
characterId: 'YOUR_CHARACTER_ID',
lang: 'ko',
user: identity,
context: {site: {
url: new URL('/ko/artworks/42', location.origin).href,
title: 'Watercolor city',
content: 'A public description of the current artwork.'
}}
});
</script>Page context and user statistics
Send only public page information in context.site. URLs must be HTTP(S), without credentials, up to 500 characters. Titles and content are truncated to 200 and 2,000 characters. traits accepts up to 30 scalar values: strings, finite numbers or booleans; keys are 1–64 characters and string values up to 200. The SDK bounds inputs; invalid direct API requests receive 422. setContext() replaces page hints; pass an empty object on unrelated routes. Updating only traits for the same user preserves the conversation.
// After init: replace context when the SPA route changes.
BestChar.setContext({site: {
url: new URL('/ko/artworks/43', location.origin).href,
title: 'Next artwork',
content: 'Only content the author made public.'
}});
// On routes without an artwork:
BestChar.setContext({});
// identity is the response from YOUR authenticated host server.
// Reuse its signed user ID/hash when refreshing statistics.
BestChar.boot({...identity, traits: {artworks_total: 13, favorite_platform: 'flux'}});
// On logout:
BestChar.shutdown();Page hints versus personal memory
Page context and traits are reference data for the current request, not proof of identity, authorization or payment. They are not directly saved as separate personal memories, but generated replies remain part of normal chat history and memory processing. Do not send secrets or private prompts. Making a page private does not automatically erase previously delivered conversations.
React, SPA and iframe
The React wrapper updates context and traits without recreating the widget. Pass signed identity through user. Here identity comes from your authenticated host server and artwork contains public current-page data. Use a package version containing these features, or build packages/widget from the repository. Script and iframe modes support the same context and cards.
// No package to install: the widget loads from BestChar and exposes window.BestChar.
import {useEffect} from 'react';
const SDK_URL = 'https://bestchar.com/sdk/v2/widget.js';
function loadSdk() {
if (window.BestChar) return Promise.resolve(window.BestChar);
return new Promise((resolve, reject) => {
const existing = document.querySelector(`script[src="${SDK_URL}"]`);
const script = existing ?? Object.assign(document.createElement('script'), {src: SDK_URL, async: true});
script.addEventListener('load', () => resolve(window.BestChar));
script.addEventListener('error', () => reject(new Error('BestChar SDK failed to load')));
if (!existing) document.head.appendChild(script);
});
}
export function SiteChat({identity, artwork, widgetKey, characterId}) {
// identity is the response from YOUR authenticated host server.
useEffect(() => {
if (!identity) return;
let cancelled = false;
loadSdk().then((BestChar) => {
if (cancelled) return;
BestChar.init({
apiKey: widgetKey, characterId,
host: 'https://bestchar.com', lang: 'ko', user: identity
});
});
// Unmount on logout: shutdown clears the widget and cancels in-flight chat.
return () => { cancelled = true; window.BestChar?.shutdown?.(); };
}, [identity?.userId, identity?.userHash, widgetKey, characterId]);
useEffect(() => {
if (!window.BestChar) return;
window.BestChar.setContext(artwork ? {site: {
url: artwork.page_url, title: artwork.title, content: artwork.public_description
}} : {});
}, [artwork]);
return null;
}REST request and SSE response
Widget chat uses POST /api/v1/widget/CHARACTER_ID/stream. Send the external user ID in headers, not the body. Include the host Origin in curl. The response is text/event-stream, not a single JSON object. Read reply, emotion and products from response events, then reuse conversation_id for the same character. Streams may include error events; done signals completion. Automatically retrying a partially received request may duplicate processing.
curl --no-buffer --fail-with-body \
"https://bestchar.com/api/v1/widget/$BESTCHAR_CHARACTER_ID/stream" \
-H "X-API-Key: $BESTCHAR_WIDGET_KEY" \
-H 'X-External-User-ID: promge:user:42' \
-H "X-User-Hash: $BESTCHAR_USER_HASH" \
-H 'Origin: https://example.com' \
-H 'Accept: text/event-stream' \
-H 'Content-Type: application/json' \
-d '{"message":"Tell me about this artwork","conversation_id":null,"lang":"en","context":{"site":{"url":"https://example.com/artworks/42","title":"Watercolor city","content":"Public artwork summary"},"traits":{"artworks_total":12}}}'
# Subsequent turns: replace null with the returned conversation_id.
# $BESTCHAR_USER_HASH must be the signature for EXACTLY promge:user:42.Recommendations, emotion and multi-character chat
When product search is enabled and finds matches, response.products contains results. The widget shows up to three cards with image, title, creator, price and link; no matches means an empty array. Empty prices are hidden, while 0KRW remains visible for free products. Only HTTP(S) links open in a new tab. emotion is a classification separate from reply text. Group mode sends sequential requests with a separate conversation_id per character. Paid-character buyer identity uses verified buyerToken/buyerTokens, not traits.
event: response
data: {"reply":"Here is a related artwork.","emotion":"happy","conversation_id":"11111111-1111-4111-8111-111111111111","products":[{"name":"Watercolor city","brand":"artist","price":"","image_url":"https://example.com/art.webp","product_url":"https://example.com/artworks/42"}]}
event: done
data: {"conversation_id":"11111111-1111-4111-8111-111111111111"}
// SDK event handler (not part of the SSE wire format):
BestChar.on('message', event => {
if (event.role === 'assistant') {
console.log(event.content, event.emotion, event.products);
}
});Manage products and artworks server-side
A public widget key alone cannot manage products. Send that key’s signing_secret as X-Widget-Management-Secret and an owned project character UUID as X-Character-ID. Call from your server, not the browser. Purchased characters are outside this management scope. The same scope applies to listing, search, stats and individual reads, updates and deletes. Bulk upsert uses POST /api/v1/external/products/bulk with up to 100 items.
curl --fail-with-body https://bestchar.com/api/v1/external/products/bulk \
-H "Authorization: Bearer $BESTCHAR_WIDGET_KEY" \
-H "X-Widget-Management-Secret: $BESTCHAR_SIGNING_SECRET" \
-H "X-Character-ID: $BESTCHAR_CHARACTER_ID" \
-H 'Content-Type: application/json' \
-d '{"update_existing":true,"products":[{"product_id":"artwork:42","name":"Watercolor city","description":"Public description","price":0,"currency":"","product_url":"https://example.com/artworks/42","image_url":"https://example.com/art.webp","extra_metadata":{"display_price":false}}]}'
# GET /api/v1/external/products
# POST /api/v1/external/products/search
# GET /api/v1/external/products/stats
# GET /api/v1/external/products/{product_db_uuid}
# PUT /api/v1/external/products/{product_db_uuid}
# DELETE /api/v1/external/products/{product_db_uuid}
# Individual routes use the returned DB UUID, not the external product_id.Promge catalog integration
The BestChar receiver alone is not a complete integration. Implement all three feeds below on Promge. Return only public, moderation-approved artworks and author-permitted prompt content. Preserve removal/unlisting tombstones after hard deletion. check fetches current public data before recommendation; missing items, errors and timeouts exclude those artworks. These are endpoints Promge must implement, not BestChar endpoints.
# Implement on the Promge server; all require X-Catalog-Token.
GET /api/public/catalog?since=<ISO8601>&cursor=<cursor>&limit=200
{"items":[...],"next_cursor":null}
GET /api/public/catalog/removed?since=<ISO8601>&cursor=<cursor>&limit=200
{"ids":[42,43],"next_cursor":null}
GET /api/public/catalog/check?ids=42&ids=43
{"items":[...]}
# One item (same visibility-filtered format for catalog and check):
{
"id":42, "title":"Watercolor city",
"description":"Public description", "prompt":"Public prompt only",
"negative_prompt":"", "platform":"flux", "model":"",
"style":"watercolor", "tags":["city"],
"image_url":"https://example.com/art.webp",
"thumb_url":"https://example.com/thumb.webp",
"page_url":"https://example.com/ko/artworks/42",
"creator":{"handle":"artist"},
"created_at":"2026-09-12T00:00:00Z",
"updated_at":"2026-09-12T00:00:00Z"
}
# Omit since on the initial import; use inclusive since and stable ID cursors.
# Maximum 200 items per list/removal page; maximum 10 items for check.
# Return current public data for check, not cached private prompts.
# Cache-Control: no-store. Synchronize feed/worker clocks (NTP).
# Keep tombstones; exclude tombstones for items that are public again.
# A missing checkpoint requires the full catalog plus all tombstones.Configure and run synchronization
Blank settings disable sync. Apply the same settings to BestChar backend, shopping worker and beat to run every 30 minutes. Checkpoints advance only after all writes succeed; failures retry. Artwork IDs can be integers or bounded strings and are stored with a promge: prefix. Unchanged content skips re-embedding. Removal updates both the vector DB and DB; failed vector deletion retains the row for retry. The first successful sync enables product search on the target character.
# BestChar server configuration; do not use VITE_ / NEXT_PUBLIC_ prefixes.
PROMGE_CATALOG_URL=https://promge.com/api/public/catalog
PROMGE_CATALOG_TOKEN=<shared-server-secret>
PROMGE_CHARACTER_ID=<owned-curator-character-UUID>
# After both deployments, feed implementation and configuration:
docker compose exec -T backend celery -A app.core.celery_app \
call app.tasks.promge_catalog_sync.syncTroubleshooting
401/403: check login-token versus widget-key usage, allowed domains, signatures, project membership and buyer access. 422: check UUIDs and context types/limits. 429: check request/concurrent-user limits and back off. For billing errors, inspect the SSE error and character entitlement/credits. SDK 404/500 or missing setContext indicates a deployment/cache issue. Missing cards: check product search, matches and catalog check responses. Exclude tokens and secrets from error logs.
Full API reference
Use the server-generated Swagger and OpenAPI for all character, project, knowledge and billing endpoints. Account permissions and endpoint-specific authentication still apply.