BazaarLink Documentation
BazaarLink is a unified AI API gateway for Taiwan — providing access to hundreds of models from OpenAI, Anthropic, Google, Meta, and more through a single, OpenAI-compatible API endpoint.
Pricing
BazaarLink charges zero markup on model usage (same as upstream provider list price), plus a 10% transaction fee and 5% business tax. Accounted in USD with TWD quotes and Taiwan e-invoices. Pay-as-you-go credits for self-serve users; enterprises can arrange monthly billing (Net-30, negotiable).
How it works
- Top-up: New Taiwan Dollars are converted to USD at the Bank of Taiwan's live TWD→USD selling rate at the moment of the transaction, and credited to your BazaarLink balance in USD.
- Usage: Every API call is billed by actual token usage; the per-token price matches the upstream provider's official USD pricing (zero markup), plus a 10% transaction fee.
- Invoice: Taiwan e-invoices are supported for local accounting workflows. Companies that need procurement or monthly billing can arrange enterprise billing terms.
About the exchange rate
Foreign-exchange conversion uses the Bank of Taiwan published rate; monthly billing uses the rate at billing (statement) time, while prepaid top-ups convert at the top-up-time rate. The rate and timestamp are retained with billing records.
Quick Start
Get started in under 5 minutes. BazaarLink is fully compatible with the OpenAI SDK — just change the
Base URL
https://bazaarlink.ai/api/v1
Using the OpenAI SDK
BazaarLink is fully compatible with the OpenAI SDK. Just change the base URL and API key — all other code stays the same.
from openai import OpenAI
client = OpenAI(
base_url="https://bazaarlink.ai/api/v1",
api_key="sk-bl-YOUR_API_KEY",
)
completion = client.chat.completions.create(
model="openai/gpt-4.1",
messages=[
{"role": "user", "content": "What is the meaning of life?"}
],
)
print(completion.choices[0].message.content)sk-bl-.✗ gpt-4.1 claude-sonnet-4-6 gemini-2.5-flash
Authentication
All API requests require an Authorization header with your API key.
Authorization: Bearer sk-bl-YOUR_API_KEY
Get your API key from the dashboard. Keep your key secure — do not expose it in client-side code.
Optional Headers
Principles
BazaarLink is designed around three core principles:
1. Unified Interface
One API, one SDK, hundreds of models. Switch between OpenAI, Anthropic, Google Gemini, Meta Llama, and others without changing your code — just change the model ID.
2. Price Optimization
BazaarLink automatically routes to the most cost-effective provider for your chosen model. You pay only for what you use, billed in USD with full invoicing support.
3. High Availability
Automatic failover means if a provider goes down, your requests are seamlessly rerouted. No code changes, no downtime.
Multimodal
BazaarLink supports multimodal inputs — send images, audio, and files alongside text to models that support them. Content is passed through to the upstream provider.
Supported Modalities
Sending Images
Use the content array format with image_url parts. Supported formats: PNG, JPEG, WebP, and GIF (including animated). You can include multiple images in a single message — each as a separate image_url part:
response = client.chat.completions.create(
model="openai/gpt-4.1",
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": "What's in this image?"},
{
"type": "image_url",
"image_url": {
"url": "https://example.com/photo.jpg",
"detail": "auto" # "low", "high", or "auto"
}
}
]
}
],
)
print(response.choices[0].message.content)Image Generation
BazaarLink offers two image-generation paths: (A) /v1/chat/completions with modalities: ["image"] — the native path, supports SSE streaming and mixed text+image output; recommended for new integrations. (B) /v1/images/generations — OpenAI DALL·E-compatible request shape, but responses are SSE event streams (required for slow models to dodge the 100 s upstream timeout). Both paths emit the same SSE event protocol — endpoint choice is purely a request-shape preference.
Supported Models — /images/generations
Use the `/images/generations` endpoint (OpenAI-compatible format). Returns image URLs in `data[].url`.
| Model | Modality |
|---|---|
| bytedance-seed/seedream-4.5 | image+text->image |
| black-forest-labs/flux.2-max | text+image->image |
Supported Models — /chat/completions + modalities
Use the `/chat/completions` endpoint with `modalities: ["image"]` or `["image", "text"]`. Generated images are returned in `choices[0].message.images[]`, not in `content`.
| Model | Modality |
|---|---|
| google/gemini-2.5-flash-image | text+image->text+image |
| google/gemini-3.1-flash-image-preview | text+image->text+image |
| bytedance-seed/seedream-4.5 | image+text->image |
| openai/gpt-5.4-image-2 | text+image+file->text+image |
| google/gemini-3-pro-image-preview | text+image->text+image |
| black-forest-labs/flux.2-max | text+image->image |
Using the modalities Parameter
To request image output, pass `modalities: ["image"]` or `["image", "text"]`. Generated images are returned in `choices[0].message.images[]` as base64-encoded data URIs.
response = client.chat.completions.create(
model="google/gemini-2.5-flash-image",
messages=[{"role": "user", "content": "A serene mountain lake at sunrise"}],
modalities=["image", "text"], # ["image"] only for Flux / DALL-E
image_config={
"aspect_ratio": "16:9",
"image_size": "2K",
},
)
# Generated images are in message.images[], NOT in message.content
for img in response.choices[0].message.images:
data_uri = img["image_url"]["url"] # "data:image/png;base64,..."
# Text caption (when modalities includes "text")
print(response.choices[0].message.content)image_config Options
Fine-tune generation with the optional image_config parameter:
Video Generation
Asynchronous three-step flow (submit → poll → content). Video generation takes 30 s–5 min, which doesn't fit the synchronous request/response shape of chat-completions — so BazaarLink exposes it as a dedicated /api/v1/videos endpoint using a job-id pattern: submit returns a vjob_* ID → poll status → fetch bytes on completion. Calling a video model via /chat/completions or /images/generations returns 400 (code: wrong_endpoint_for_video). Billing settles against real usage.cost when the job reaches completed.
1. Submit job (returns vjob_xxx immediately)
/api/v1/videoscurl https://bazaarlink.ai/api/v1/videos \
-H "Authorization: Bearer $BL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "alibaba/wan-2.7",
"prompt": "a bird flying over mountains",
"duration": 3,
"resolution": "720p",
"generate_audio": false
}'
# → 202 { "id": "vjob_xxx", "status": "pending" }2. Poll status
/api/v1/videos/{id}curl -H "Authorization: Bearer $BL_API_KEY" \ https://bazaarlink.ai/api/v1/videos/vjob_xxx
3. Fetch video content (MP4)
/api/v1/videos/{id}/contentcurl -H "Authorization: Bearer $BL_API_KEY" \ -o output.mp4 \ https://bazaarlink.ai/api/v1/videos/vjob_xxx/content
Supported video models
| Model ID | Modality |
|---|---|
| bytedance/seedance-2.0 | text+image->video |
| bytedance/seedance-2.0-fast | text+image->video |
PDF Inputs
Send PDF documents directly in messages for models to analyze, summarize, or answer questions about. BazaarLink passes file content through to upstream providers that support it.
Supported Formats
- PDF documents (text, images, tables, scanned)
- Base64-encoded data URL (`data:application/pdf;base64,...`)
- Multi-page documents
- Password-free PDFs only
import base64
with open("document.pdf", "rb") as f:
pdf_data = base64.b64encode(f.read()).decode()
response = client.chat.completions.create(
model="anthropic/claude-sonnet-4.6",
messages=[{
"role": "user",
"content": [
{
"type": "file",
"file": {
"filename": "document.pdf",
"file_data": f"data:application/pdf;base64,{pdf_data}",
},
},
{"type": "text", "text": "Summarize this document."},
],
}],
)Processing Engines
Selecting a Processing Engine
Pass a `plugins` array to select the PDF parsing engine. The parsed content is automatically injected into the model context — works with any model, not just PDF-native ones.
import base64
with open("document.pdf", "rb") as f:
pdf_data = base64.b64encode(f.read()).decode()
response = client.chat.completions.create(
model="openai/gpt-4o", # any model — parser injects text into context
messages=[{
"role": "user",
"content": [
{
"type": "file",
"file": {
"filename": "report.pdf",
"file_data": f"data:application/pdf;base64,{pdf_data}",
},
},
{"type": "text", "text": "Summarize this document."},
],
}],
plugins=[{
"id": "file-parser",
"pdf": {"engine": "mistral-ocr"}, # or "pdf-text" (free), "native"
}],
)Audio Inputs
BazaarLink supports two audio paths: inline audio in chat messages for multimodal models, and a dedicated transcription endpoint for speech-to-text.
Path 1 — Chat completions (multimodal input)
Supported Formats
WAVMP3AIFFAACOGG (Opus / Vorbis)FLACM4APCM16 (raw)PCM24 (raw)import base64
with open("audio.wav", "rb") as f:
audio_b64 = base64.b64encode(f.read()).decode()
# audio_b64 is a raw base64 string — do NOT add data: prefix
response = client.chat.completions.create(
model="openai/gpt-4o-audio-preview",
messages=[{
"role": "user",
"content": [
{"type": "text", "text": "Transcribe this audio:"},
{
"type": "input_audio",
"input_audio": {
"data": audio_b64, # raw base64, no "data:audio/...;base64," prefix
"format": "wav", # wav, mp3, flac, ogg, m4a, aac
},
},
],
}],
)
print(response.choices[0].message.content)Path 2 — Transcription endpoint
POST /v1/audio/transcriptions is a dedicated speech-to-text endpoint compatible with the OpenAI Whisper API. Drop-in replacement: point your existing OpenAI SDK client at BazaarLink and it just works.
from openai import OpenAI
client = OpenAI(
base_url="https://bazaarlink.ai/v1",
api_key="YOUR_API_KEY",
)
with open("audio.wav", "rb") as f:
transcript = client.audio.transcriptions.create(
model="openai/whisper-1", # or "openai/gpt-4o-transcribe"
file=f,
)
print(transcript.text)Text-to-Speech (TTS)
Synthesize natural speech from text via POST /v1/audio/speech. Returns binary audio (mp3/opus/aac/flac/wav/pcm depending on model and response_format).
TTS Models
| Model | Billing | Notes |
|---|---|---|
| openai/gpt-4o-mini-tts-2025-12-15 | $0.60 / 1M chars | Cost-efficient, 11 voices, accepts natural-language instructions for tone/pace/style. |
| openai/tts-1 | $15 / 1M chars | Original OpenAI TTS — faster, lower fidelity. |
| openai/tts-1-hd | $30 / 1M chars | Higher-fidelity OpenAI TTS. |
| mistralai/voxtral-mini-tts-2603 | $16 / 1M chars | Mistral's voice model — different voice character. |
Voices (gpt-4o-mini-tts)
alloyashballadcoralechofablenovaonyxsageshimmerverseVoice availability varies by model — check the model card. For natural Mandarin female voice, pick coral, nova, sage, or shimmer.
Response Formats
mp3 (default)opusaacflacwavpcmEach model accepts a subset of formats. mp3 and pcm are the safest defaults.
from openai import OpenAI
client = OpenAI(
base_url="https://bazaarlink.ai/api/v1",
api_key="sk-bl-YOUR_API_KEY",
)
with client.audio.speech.with_streaming_response.create(
model="openai/gpt-4o-mini-tts-2025-12-15",
voice="coral",
input="Hello from BazaarLink.",
response_format="mp3",
) as response:
response.stream_to_file("hello.mp3")Steering with instructions
openai/gpt-4o-mini-tts accepts an optional instructions string in natural language to direct voice tone, pace, and style.
Limits
Input text is limited to 4096 characters per request. For longer text, split into multiple calls.
Video Inputs
Send video content for models to analyze visuals, generate descriptions, or answer questions about scenes and events. Supported via Gemini models today.
Supported Formats
MP4 (H.264)MPEGMOVWebMProvider Variations
- Google AI (gemini-*): Supports YouTube URLs and Google Cloud Storage (gs://) URIs. File size limit: ~1 GB / up to 1 hour (Gemini 1.5 Pro); ~50 MB / 5 min (Gemini 1.5 Flash, 2.0 Flash).
- Vertex AI (gemini-* via vertex): Supports base64-encoded video data and GCS URIs. Suited for private or enterprise storage.
- Other providers: MP4, MPEG, MOV, WebM via URL or base64 (model-dependent).
# Video analysis via Gemini (Google AI — direct video URL)
response = client.chat.completions.create(
model="google/gemini-2.5-flash",
messages=[{
"role": "user",
"content": [
{
"type": "video_url",
"video_url": {"url": "https://example.com/video.mp4"},
},
{"type": "text", "text": "What is happening in this video?"},
],
}],
)
# Via base64 — local file upload
import base64
with open("video.mp4", "rb") as f:
vid_b64 = base64.b64encode(f.read()).decode()
response = client.chat.completions.create(
model="google/gemini-2.5-flash",
messages=[{
"role": "user",
"content": [
{
"type": "video_url",
"video_url": {"url": f"data:video/mp4;base64,{vid_b64}"},
},
{"type": "text", "text": "Describe the key scenes."},
],
}],
)Best Practices
- Trim to only the necessary segments — shorter videos reduce cost and latency.
- Use 720p resolution or lower — higher resolution rarely improves model understanding.
- Compress with H.264 codec for widest compatibility.
- For long videos, provide a text description of the relevant time range.
Management API Keys
Management keys are designed for programmatic key management. They can create, list, update, disable, and delete standard API keys — but cannot make AI model calls.
Creating a Management Key
On the API Keys page, create a key and select type "Management".
List Keys
GET https://bazaarlink.ai/api/v1/keys
Authorization: Bearer sk-bl-YOUR_MGMT_KEY
# Response
{
"keys": [
{
"id": "clxyz123...",
"name": "Production Key",
"keyType": "standard",
"keyPrefix": "sk-bl-abc1",
"keySuffix": "XyZ9",
"enabled": true,
"spendLimitUsd": 10.00,
"spendLimitPeriod": "month",
"expiresAt": null,
"createdAt": "2026-01-01T00:00:00.000Z",
"lastUsed": "2026-03-01T12:34:56.000Z",
"requestCount": 1234,
"totalTokens": 5678901
}
]
}Create Sub-Key
POST https://bazaarlink.ai/api/v1/keys
Authorization: Bearer sk-bl-YOUR_MGMT_KEY
Content-Type: application/json
{
"name": "Agent Key",
"limit": 10.00,
"limit_reset": "monthly",
"expires_at": "2026-12-31T23:59:59Z"
}
# limit_reset: daily | weekly | monthly
# expires_at: ISO 8601 datetime (optional)
# Response — save the key value, it won't be shown again
{
"id": "clxyz789...",
"name": "Agent Key",
"key": "sk-bl-xyz789abcdef...",
"keyType": "standard",
"spendLimitUsd": 10.00,
"spendLimitPeriod": "month",
"expiresAt": "2026-12-31T23:59:59.000Z",
"enabled": true,
"createdAt": "2026-03-01T00:00:00.000Z"
}Update Key
PATCH https://bazaarlink.ai/api/v1/keys/:id
Authorization: Bearer sk-bl-YOUR_MGMT_KEY
Content-Type: application/json
{"enabled": false} # disable key
{"spendLimitUsd": 5, "spendLimitPeriod": "week"} # set spend limit
{"spendLimitUsd": null} # remove spend limit
# Response: {"updated": true}Revoke Key
DELETE https://bazaarlink.ai/api/v1/keys/:id Authorization: Bearer sk-bl-YOUR_MGMT_KEY # Returns 204 No Content on success
Query Balance
GET https://bazaarlink.ai/api/v1/credits
Authorization: Bearer sk-bl-YOUR_MGMT_KEY
# Response
{
"data": {
"total_credits": 12.345,
"total_usage": 3.210
}
}Query Usage
GET https://bazaarlink.ai/api/v1/usage?period=month Authorization: Bearer sk-bl-YOUR_MGMT_KEY # period: day | week | month | year
App Attribution
Identify your application in request headers to enable usage tracking, dashboard visibility, and fine-grained analytics.
Available Headers
| Header | Description |
|---|---|
| HTTP-Referer | Your site URL, for rankings and analytics (optional) |
| X-Title | Your app name, shown in dashboards (optional) |
from openai import OpenAI
client = OpenAI(
base_url="https://bazaarlink.ai/api/v1",
api_key="sk-bl-YOUR_KEY",
default_headers={
"HTTP-Referer": "https://yourapp.com", # Optional: your site URL
"X-Title": "My Application", # Optional: your app name
},
)
response = client.chat.completions.create(
model="openai/gpt-4o",
messages=[{"role": "user", "content": "Hello!"}],
)Report Feedback
Help us improve BazaarLink by reporting issues, bugs, or suggestions. We actively monitor all feedback channels.
How to Report
What to Include
- Request ID (from the response id field)
- Model used and parameters sent
- Expected vs actual behavior
- Timestamps and frequency of issue
- Error messages or HTTP status codes
Error Codes
BazaarLink uses standard HTTP status codes. Error responses follow the OpenAI format:
{
"error": {
"message": "Invalid or disabled API key.",
"type": "invalid_request_error",
"code": 401
}
}Handling Errors
from openai import OpenAI, APIError, RateLimitError
client = OpenAI(
base_url="https://bazaarlink.ai/api/v1",
api_key="sk-bl-YOUR_API_KEY",
)
try:
response = client.chat.completions.create(
model="openai/gpt-4.1",
messages=[{"role": "user", "content": "Hello!"}],
)
except RateLimitError:
print("Rate limited — waiting before retry...")
except APIError as e:
print(f"API error {e.status_code}: {e.message}")Streaming Error Formats
Errors that occur before any tokens are streamed return a standard HTTP error response with a JSON body.
Errors that occur mid-stream are sent as SSE events with finish_reason: "error". Parse the error field in the delta.
// Error chunk sent mid-stream (finish_reason: "error")
type MidStreamError = {
choices: [
{
index: 0;
finish_reason: "error";
delta: { content: "" };
native_finish_reason: null;
error: {
code: number;
message: string;
metadata?: {
provider_name?: string;
raw?: unknown;
};
};
}
];
};Debugging
Use debug.echo_upstream_body: true to inspect the exact request body sent to the upstream provider. The transformed request is returned as the first SSE chunk. For development / debugging only — do not use in production.
// Request with debug enabled (streaming only)
{
"model": "openai/gpt-4.1",
"messages": [{ "role": "user", "content": "Hello" }],
"stream": true,
"debug": { "echo_upstream_body": true }
}Rate Limits
Rate limits are per user (not per key), measured in requests per minute (RPM). There is no daily cap. Tier is determined automatically by your account credit balance.
When a rate limit is exceeded you receive a 429 response with a Retry-After header. Implement exponential backoff when retrying requests.
Response Headers
Every successful response includes rate limit headers for client-side tracking:
X-RateLimit-Limit: 200 # Max requests per minute for your tier X-RateLimit-Remaining: 198 # Remaining requests in current window X-RateLimit-Reset: 1740000060 # Unix timestamp when the window resets X-Provider: anthropic # Which upstream provider served this request X-Request-Id: chatcmpl-abc123 # Unique request ID for debugging
FAQ
API Key Rotation
Regularly rotating API keys is a security best practice. BazaarLink supports zero-downtime key rotation — create a new key first, then migrate, then revoke the old one.
Rotation Steps
- Create a new API key
- Update your application or environment variables to use the new key
- Verify the new key is working correctly
- Disable or delete the old key
# Step 1: Create new key
POST https://bazaarlink.ai/api/v1/keys
Authorization: Bearer sk-bl-OLD_KEY
{"name": "Production v2"}
# → saves new key: sk-bl-NEW_KEY_VALUE
# Step 2: Update your application
# export BAZAARLINK_API_KEY=sk-bl-NEW_KEY_VALUE
# Step 3: Verify new key works
curl https://bazaarlink.ai/api/v1/models \
-H "Authorization: Bearer sk-bl-NEW_KEY_VALUE"
# Step 4: Revoke old key
DELETE https://bazaarlink.ai/api/v1/keys/:old_key_id
Authorization: Bearer sk-bl-NEW_KEY_VALUEActivity Export
Download your complete API usage history as CSV for financial audits, cost analysis, or compliance reporting.
CSV Export
Log in and go to the Logs page. Click the Export CSV button in the top-right corner to download your full history as a CSV file. No API call required.
CSV Columns
JSON Usage API
For programmatic access, query aggregated stats grouped by period, model, or key:
# Query usage data (grouped / aggregated)
GET https://bazaarlink.ai/api/v1/usage
Authorization: Bearer sk-bl-YOUR_KEY
# With period filtering (day | week | month | year)
GET https://bazaarlink.ai/api/v1/usage?period=month
# Response
{
"period": "month",
"since": "2025-01-01T00:00:00.000Z",
"credits": 10.5000,
"totals": {
"spend": 0.1812,
"requests": 309,
"tokens": 161200,
"promptTokens": 95000,
"completionTokens": 66200
},
"byModel": [{ "model": "openai/gpt-4.1", "spend": 0.0028, "tokens": 1200, "requests": 5 }],
"byKey": [{ "keyName": "My Agent", "spend": 0.0028, "tokens": 1200, "requests": 5 }],
"byApp": [{ "appName": "MyApp", "spend": 0.0015, "tokens": 600, "requests": 3 }],
"timeSeries": [{ "date": "2025-01-15", "model": "openai/gpt-4.1", "cost": 0.0012, "tokens": 500, "requests": 2 }]
}Usage Accounting
Query detailed usage statistics via API, including token consumption, cost analysis, and request history.
Response Field Reference
| Field | Type | Description |
|---|---|---|
| model | string | Model ID used (e.g., openai/gpt-4.1) |
| provider | string | Upstream provider name |
| prompt_tokens | number | Input tokens consumed |
| completion_tokens | number | Output tokens generated |
| total_tokens | number | Total tokens (prompt + completion) |
| reasoning_tokens | number | Reasoning tokens (for thinking models) |
| cached_tokens | number | Prompt tokens served from cache |
| cost | number | Total cost in NTD |
| duration_ms | number | End-to-end latency in milliseconds |
| throughput | number | Generation speed in tokens/sec |
| finish_reason | string | stop | length | content_filter | error |
| status | number | HTTP status code from upstream |
| app_name | string | null | Application name (X-Title header) |
| key_name | string | API key name used for the request |
import httpx
# Aggregated stats (Bearer token — period: day | week | month | year)
response = httpx.get(
"https://bazaarlink.ai/api/v1/usage",
headers={"Authorization": "Bearer sk-bl-YOUR_KEY"},
params={"period": "month"},
)
data = response.json()
totals = data["totals"]
print("This month: NT$%.4f (%d requests)" % (totals["spend"], totals["requests"]))
# Cost breakdown by model
for m in data["byModel"]:
print(" %s: NT$%.4f (%d reqs, %d tokens)" % (m["model"], m["spend"], m["requests"], m["tokens"]))Institution Plan
The Institution Plan lets any institution (school, enterprise, conference, government, etc.) issue short-lived session tokens to its members from a single org-level key. Members don't need to create a platform account. The org controls which members can request tokens by email domain (e.g. nthu.edu.tw); all usage is billed to the org's account. This page uses the education scenario as an example — the same mechanism works for any institution that needs short-term, multi-user temporary access.
Architecture overview
- Institution Key — Starts with sk-edu-. Created by an org_admin on the org keys page. Cannot be used directly as a Bearer token to call the API — direct calls return 403.
- Member Session Token — Starts with edu-sess-. Members obtain it after email verification. Default lifetime is 24 hours; revocable by an org admin.
- Allowed Domains — The org configures which email domains (exact match, no suffix bypass) may request a session.
- Usage attribution — All student requests are billed to the org account. Usage is viewable per session and per email in the org dashboard.
Step 1 — Request Institution Plan activation
Contact BazaarLink sales or support ([email protected] / [email protected]) and let us know your organization wants the Institution Plan enabled, with the list of allowed email domains (e.g. nthu.edu.tw). We will activate the feature for your organization:
{
"orgType": "education",
"eduConfig": {
"allowedDomains": ["nthu.edu.tw", "student.nthu.edu.tw"],
"sessionTtlSeconds": 86400,
"verificationTtlSeconds": 900,
"maxSessionsPerEmailPerKey": 5
}
}Step 2 — Org admin creates an Institution Key
On the org's API Keys page, choose "Education" as the key type when creating a new key (this is the internal codename for the institution key). The system generates an sk-edu-... key and shows it ONCE — save it and distribute it through your official channels to that org's members.
Step 3 — Member requests a verification code
Members can request a verification code in two ways: (a) visit /access and enter the institution key + their institutional email — the page calls the API for them; (b) call the API directly:
/api/edu/request-codecurl -X POST https://bazaarlink.ai/api/edu/request-code \
-H "Content-Type: application/json" \
-d '{
"key": "sk-edu-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
"email": "[email protected]"
}'
# 200 / 202 always returns {"ok":true,"sent":true} (key-enumeration defence)
# Sends a 6-digit verification code to the email; default 15-minute lifetimeStep 4 — Member submits the code to exchange for a session token
/api/edu/verifycurl -X POST https://bazaarlink.ai/api/edu/verify \
-H "Content-Type: application/json" \
-d '{
"key": "sk-edu-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
"email": "[email protected]",
"code": "646291"
}'
# Success → 200
{
"token": "edu-sess-827d11a1ec67d175cfd4f67f929261f4",
"expiresAt": "2026-05-04T11:16:00.163Z",
"organization": { "id": "...", "name": "NTHU AI Lab" }
}
# Wrong code → 400 {"error":"invalid"}
# 5 wrong attempts → 400 {"error":"too_many_attempts"} (code invalidated; re-request)Step 5 — Use the session token to call the API
Use the edu-sess-... token as a Bearer token against any chat / completions / embeddings endpoint:
curl -X POST https://bazaarlink.ai/api/v1/chat/completions \
-H "Authorization: Bearer edu-sess-827d11a1ec67d175cfd4f67f929261f4" \
-H "Content-Type: application/json" \
-d '{
"model": "anthropic/claude-haiku-4-5",
"messages": [{"role": "user", "content": "Hello"}]
}'403 — Education keys cannot be used directly. Visit /access to exchange for a session.This is an intentional reverse gate — it stops the institution from leaking long-lived keys to individual members.
Org dashboard — monitoring and revocation
Education-type orgs get an Education tab in the side nav, providing:
- Settings — Adjust allowed domains, TTL, max sessions per email per key, and per-session request / token / USD quotas.
- Sessions — List all active / expired / revoked sessions; filter by email; revoke individual sessions.
- Usage stats — Per-session call count, token consumption, and accumulated cost.
Security and limits
| Item | Default | Description |
|---|---|---|
| Session TTL | 24 hours | Session token lifetime; expired sessions require re-verification. |
| Verification code TTL | 15 minutes | Lifetime of the email verification code. |
| Verification code length | 6 digits | Stored as an HMAC-SHA256 hash in Redis, never in plaintext. |
| Guess limit | 5 attempts | Beyond this the code is invalidated immediately. |
| request-code cooldown | 60 seconds | Minimum interval between repeated requests for the same (key, email). |
| Per-IP rate limit | 10 / 15 min | Anti-spam. |
| Per-key rate limit | 100 / hour | Prevents bulk email blasts. |
| Max sessions per email | 5 | Configurable in eduConfig; prevents one inbox from hoarding tokens. |
| Revocation propagation | ≤ 60 seconds | L1/L2 cache TTL; after DB revocation it takes up to 60 seconds to propagate to all nodes. |
Billing and usage attribution
All requests made via session tokens are billed 100% to the organization that owns the institution key, in line with how upstream providers (OpenAI / Anthropic / etc.) bill (per-token). The org dashboard supports drill-down by session, by email, and by key.
Organization Management
BazaarLink organizations use a three-tier architecture: Organization → Team → Member. Credits are stored at the org level; each Team and member can have a monthly spend cap. API requests check member → team → org credits in sequence.
Create & Manage Organizations
- Go to Settings → Organizations → Create New Organization
- Create Teams in the org portal (optional: cost center code and monthly budget)
- Invite members by email, assign a role and Team
- Issue API keys for members — usage is automatically tagged to the correct Team / member
- View the Reports page for monthly spend broken down by Team, Model, or Member
Member Roles
Three-Tier Budget System
On every API request, three budget layers are checked in order. Exceeding any layer returns HTTP 429:
- Member monthly budget (OrgMember.monthlyBudget)
- Team monthly budget (Team.monthlyBudget)
- Org credits balance (Organization.credits)
Usage Reports
The Reports page in the org portal provides monthly spend analytics across four dimensions:
- Overview: total spend, margin rate, daily trend chart
- By Team: per-team spend, share %, model breakdown, budget utilization
- By Model: per-model spend, avg price ($/1M tokens)
- By Member: per-member spend — org_admin only
All views support CSV export with BOM prefix for direct Excel compatibility.
Management API (v1)
The /api/v1/orgs/ endpoints accept both Bearer management key (sk-bl-...) and session cookie, enabling server-to-server org management without a browser session.
Organizations
/api/v1/orgsList all organizations the caller belongs to, with role and joinedAt.
/api/v1/orgs/:orgIdGet org detail including team and member counts.
curl https://bazaarlink.ai/api/v1/orgs \ -H "Authorization: Bearer sk-bl-YOUR_MANAGEMENT_KEY"
Teams
/api/v1/orgs/:orgId/teamsList teams with member counts, ordered by name.
/api/v1/orgs/:orgId/teams/api/v1/orgs/:orgId/teams/:teamIdPartial update — include only the fields to change.
/api/v1/orgs/:orgId/teams/:teamId# Create a team
curl https://bazaarlink.ai/api/v1/orgs/{orgId}/teams \
-X POST \
-H "Authorization: Bearer sk-bl-YOUR_MANAGEMENT_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "Engineering", "costCenterCode": "ENG-001", "monthlyBudget": 500}'Members
/api/v1/orgs/:orgId/membersList all members with nested user (id/name/email) and team info.
/api/v1/orgs/:orgId/members404 if the email address has no BazaarLink account. 409 if already a member. Default role: member.
/api/v1/orgs/:orgId/members/:memberIdPartial update of role, teamId, or monthlyBudget.
/api/v1/orgs/:orgId/members/:memberIdReturns 400 if the target is the last org_admin.
# Add a member
curl https://bazaarlink.ai/api/v1/orgs/{orgId}/members \
-X POST \
-H "Authorization: Bearer sk-bl-YOUR_MANAGEMENT_KEY" \
-H "Content-Type: application/json" \
-d '{"email": "[email protected]", "role": "member", "monthlyBudget": 50}'
# Remove a member
curl https://bazaarlink.ai/api/v1/orgs/{orgId}/members/{memberId} \
-X DELETE \
-H "Authorization: Bearer sk-bl-YOUR_MANAGEMENT_KEY"Reports API
Query monthly spend data programmatically. Accessible to org_admin and billing_viewer. Accepts both web session and Bearer management key.
Query params: year (default current), month (default current, 1–12).
# Monthly overview via management key
curl "https://bazaarlink.ai/api/orgs/{orgId}/reports/overview?year=2026&month=3" \
-H "Authorization: Bearer sk-bl-YOUR_MANAGEMENT_KEY"
# By-team breakdown
curl "https://bazaarlink.ai/api/orgs/{orgId}/reports/by-team?month=3" \
-H "Authorization: Bearer sk-bl-YOUR_MANAGEMENT_KEY"
# Export CSV (downloads file)
curl "https://bazaarlink.ai/api/orgs/{orgId}/reports/export?month=3&view=by-team" \
-H "Authorization: Bearer sk-bl-YOUR_MANAGEMENT_KEY" \
-o report.csvTechnical architecture
RBAC nav visibility
ROLE_NAV_VISIBILITY (lib/auth/org-scope.ts) defines which navs each role sees in Org Portal. The backend enforces the same rules at the route layer via requireOrgRole() — UI hiding is only the first line of defense.
Spend Circuit Breaker
To stop a single prompt or batch loop from running away with cost, every chat completion request passes through the scoped circuit breaker (lib/scoped-circuit-breaker.ts).
- Dual-window rolling counter: 1-min + 1-hr sliding windows backed by Redis sorted sets
- Default thresholds: minute = $5, hourly = $20 (CB_DEFAULTS in lib/spend-circuit-breaker.ts)
- Settings resolution: member → team → org → default; each layer can override; 30s in-memory cache
- Trip behavior: HTTP 503 with Retry-After header; recovers automatically once the rolling window rolls over
- Cost path: every chat completion calls recordScopedUpstreamCost() in lib/usage-logger.ts
Allowed Models resolution chain
When a request hits /api/v1/chat/completions, the API key resolves to memberId → teamId → orgId, then Org.allowedModels is checked against the request's model id; mismatches return HTTP 403. Adding / removing models is instant — no redeploy needed.
Budget deduction architecture
- Org credits: PostgreSQL UPDATE ... RETURNING — atomic deduct prevents race conditions
- Team / Member layers: Redis INCRBY for real-time counting; reset by scheduled job at month start
- Check order: member.monthlyBudget → team.monthlyBudget → org.credits; any layer over budget returns HTTP 429
- User-facing balance is cached (5s TTL); the source of truth is DB / Redis
Error response reference
Allowed Models (Whitelist)
Restrict which models your organization, teams, or individual members can call. Useful for blocking expensive or unvetted models, enforcing model standards, or scoping a team to a single provider.
How it works
- Three independent layers — Organization, Team, Member — each holds its own list (String[] in the database).
- When all three layers are empty, every model is allowed (default behavior).
- When one or more layers are non-empty, the effective list is the intersection of the non-empty layers — a model must be allowed at every restricted layer to pass.
- Changes take effect within a few seconds (60s in-memory + 5min Redis cache; both are cleared on update).
Pattern format
- Exact match — e.g. openai/gpt-4o (this exact model only).
- Provider wildcard — e.g. openai/* (any model under the openai/ prefix).
- Lowercase only. Max 200 entries per list, 100 chars per entry.
Where to manage
Org Portal → Allowed Models. org_admin can edit org / team / member lists; team_admin can edit their own team and members within it.
Error response when blocked
Calls to a disallowed model return HTTP 403 with this body:
HTTP/1.1 403 Forbidden
Content-Type: application/json
{
"error": {
"message": "Model is not allowed for this account",
"code": "model_not_allowed"
}
}Management API
All endpoints accept Web Session or Bearer Management Key (sk-bl-...). PATCH replaces the entire list; pass [] to clear.
# Org-level list
GET /api/orgs/:orgId/allowed-models
PATCH /api/orgs/:orgId/allowed-models
# Team-level list
GET /api/orgs/:orgId/teams/:teamId/allowed-models
PATCH /api/orgs/:orgId/teams/:teamId/allowed-models
# Member-level list
GET /api/orgs/:orgId/members/:memberId/allowed-models
PATCH /api/orgs/:orgId/members/:memberId/allowed-models
# Example: restrict an org to OpenAI + a specific Anthropic model
curl -X PATCH https://bazaarlink.ai/api/orgs/$ORG_ID/allowed-models \
-H "Authorization: Bearer sk-bl-..." \
-H "Content-Type: application/json" \
-d '{"allowedModels": ["openai/*", "anthropic/claude-sonnet-4.6"]}'Circuit Breaker (Spend Kill Switch)
A dual-window rolling spend cap that blocks further requests when upstream cost spikes. Designed to contain runaway scripts, infinite loops, or stolen-key abuse before they cost real money.
How it works
- Two rolling windows are tracked in Redis per scope: 1-minute and 1-hour upstream cost (USD).
- If either window's spend reaches its threshold, all subsequent requests in that scope are rejected until the window rolls forward.
- Defaults: $5 / minute, $20 / hour, enabled by default.
- Counters live in Redis with TTL — recovery is automatic, no manual reset needed for org/team/member trips.
Scopes (member overrides team overrides org)
Each layer can set its own thresholds. Resolution order is member → team → org → platform default — the first non-null value wins per field (cbEnabled, cbMinuteUsd, cbHourlyUsd).
- Org level — applies to all keys under the organization. Set in Org Portal → Circuit Breaker.
- Team level — applies to all keys tagged to that team. Overrides org for those keys.
- Member level — applies only to keys tagged to that member. Overrides team and org.
Trip behavior
When tripped, requests fail fast (no upstream call is made). The response is HTTP 429 with this body:
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
{
"error": {
"message": "Spend circuit breaker tripped at member scope (minute window: $5.2341 ≥ $5.00). Try again later or contact your organization owner."
}
}Audit log
Every trip event and every configuration change is recorded:
- Trip events — actions org.cb.tripped / team.cb.tripped / org_member.cb.tripped. Deduplicated to one entry per scope+window per hour, so a sustained trip doesn't flood the log.
- Config changes — actions org.cb.update / team.cb.update / org_member.cb.update. Capture before/after values plus the actor.
Management API
Org admins can read and update settings via API. All endpoints accept Web Session or Bearer Management Key (sk-bl-...). Send any subset of fields in the PATCH body; null clears a field and falls back to the parent layer.
# Org-level config
GET /api/orgs/:orgId/circuit-breaker
PATCH /api/orgs/:orgId/circuit-breaker
# Team-level config
GET /api/orgs/:orgId/teams/:teamId/circuit-breaker
PATCH /api/orgs/:orgId/teams/:teamId/circuit-breaker
# Member-level config
GET /api/orgs/:orgId/members/:memberId/circuit-breaker
PATCH /api/orgs/:orgId/members/:memberId/circuit-breaker
# Example: tighten the org-level cap to $2/min, $10/hr
curl -X PATCH https://bazaarlink.ai/api/orgs/$ORG_ID/circuit-breaker \
-H "Authorization: Bearer sk-bl-..." \
-H "Content-Type: application/json" \
-d '{"cbMinuteUsd": 2, "cbHourlyUsd": 10, "cbEnabled": true}'
# GET response (org scope)
{
"settings": { "cbEnabled": true, "cbMinuteUsd": 2, "cbHourlyUsd": 10 },
"resolvedSettings": { "cbEnabled": true, "cbMinuteUsd": 2, "cbHourlyUsd": 10 },
"liveSpend": { "minuteSpend": 0.4123, "hourSpend": 3.8721 }
}