REST API
Server-side endpoints for knowledge management, persona reads, and subscription limits. Authenticate with an API key and call these from your backend to integrate PersonAIzer into your own flows.
Base URL: https://core.personaizer.com
Authentication
All API requests require an API key. Create and manage keys in the dashboard at Settings → API Keys. The full key is shown only once at creation — store it securely on your server and never expose it in client-side code or version control. Keys can be individually disabled or deleted from the dashboard at any time.
Request header
X-Api-Key: pa_AbCdEfGhIjKlMnOpQrStUvExample request
curl https://core.personaizer.com/api/knowledge/docs \
-H "X-Api-Key: pa_AbCdEfGhIjKlMnOpQrStUv"Zero-downtime key rotation
Create a second key from the dashboard — Settings → API Keys → New key
Update your integration to send the new key
Disable the old key from the dashboard, verify no traffic, then delete it
Restricted endpoints: Account settings, billing, and API key management only accept a browser session — API keys receive 403 auth.forbidden on those routes by design. All knowledge, persona, and subscription endpoints accept API keys.
Identifiers — always use external_id
Every knowledge document has two identifiers: an internal server-generated UUID and a caller-supplied external_id. Always use external_id — it is stable across re-uploads, survives content updates, and is the only identifier exposed in the public API.
Recommended
# Your own stable slug — survives re-uploads, human-readable
PUT /api/knowledge/docs
{
"items": [
{ "external_id": "product-SKU-9821", "content": "..." },
{ "external_id": "article-how-to-reset", "content": "..." }
]
}Avoid
# ✗ Unstable — row 42 today could mean a different product tomorrow.
# Timestamp-based IDs create duplicates on every re-upload.
{ "external_id": "42", ... }
{ "external_id": "1747123456789", ... }Two upload patterns
The API offers two endpoints with different processing semantics. Choose based on volume and whether you want immediate availability or batch control.
Batch upsert
PUT /api/knowledge/docsAccepts up to 100 documents per call. Docs land as staged — saved but not queued for embedding. Use for bulk catalog syncs where you want to control when embedding runs.
- Full create-or-update (upsert) semantics keyed on
external_id - Docs stay staged until you trigger processing from the dashboard
- No-op items (unchanged content) return instantly at zero cost
# Upload a catalog — docs land as "staged", Tasks ignores them.
PUT /api/knowledge/docs
{
"items": [
{ "external_id": "sku-001", "content": "Blue running shoes, size 10, $89." },
{ "external_id": "sku-002", "content": "Red trail runners, size 9, $109." }
]
}
# Response — docs are staged, not yet embedded:
# { "created": 2, "updated": 0, "no_op": 0, "failed": 0,
# "results": [
# { "external_id": "sku-001", "status": "created" },
# { "external_id": "sku-002", "status": "created" }
# ] }Single upsert
PUT /api/knowledge/docs/by-external-id/{external_id}Upserts one document and immediately queues it for embedding. Use for real-time updates — a product price change, a new article — where you want the content available as quickly as possible.
- Returns
re_embedded: truewhen content changed - Returns
re_embedded: falsefor unchanged content (free) - HTTP 201 on first call, 200 on subsequent updates
# Single upsert — immediately queued for embedding.
PUT /api/knowledge/docs/by-external-id/sku-001
{
"content": "Blue running shoes, size 10, now $79."
}
# Response: HTTP 200 (existing) or 201 (new)
# { "external_id": "sku-001", "status": "updated", "re_embedded": true }Tip: For large initial catalog imports use the batch endpoint, then trigger processing from the dashboard once your data looks correct. For ongoing real-time sync (webhooks, event-driven updates) use the single endpoint.
Persona activation
A knowledge document is only surfaced in a persona's context if it is activated for that persona. Activation is the join between a document and a persona — a document can be active for many personas or none. Only embedded (fully processed) documents participate in retrieval; staging or activating a staged document is valid but it won't appear in conversations until embedding completes.
Replace the full activation list
# Replace the full activation list for a persona.
PUT /api/personas/{persona_id}/knowledge
{
"external_ids": ["sku-001", "sku-002", "article-how-to-reset"]
}Replaces the persona's entire activation list. Any document not in external_ids is deactivated.
Add documents without disrupting existing activations
# Add docs without touching the existing list (additive).
POST /api/personas/{persona_id}/knowledge
{
"external_ids": ["sku-003", "sku-004"]
}
# Response: { "added": 2, "skipped": 0 }Additive — existing activations are untouched. Already-active documents are counted as skipped, not duplicated.
Remove a single document
# Remove a single doc from a persona — idempotent.
DELETE /api/personas/{persona_id}/knowledge/sku-003Idempotent — returns 204 No Content whether or not the document was active.
/api/personasList all personas for your account.
/api/personas/{persona_id}Get a single persona by ID.
/api/personas/{persona_id}/knowledgeList external_ids currently activated for a persona.
Checking your limits
Before large uploads, check your remaining quota. Knowledge Units (Ku) measure embedded content volume; Storage Units (Su) measure raw file storage in MB.
curl https://core.personaizer.com/api/subscription/limits \
-H "X-Api-Key: pa_AbCdEfGhIjKlMnOpQrStUv"
# { "usage": { "knowledgeUnitsUsed": 12480, "knowledgeUnitsLimit": 100000,
# "storageUnitsUsed": 4.2, "storageUnitsLimit": 512 } }Uploading beyond your limit returns 402 knowledge.quota_exceeded. The batch endpoint checks quota upfront — if the batch would exceed your limit the entire request is rejected, nothing is written.
Error codes
Every 4xx/5xx response carries application/problem+json with a stable string code extension. Branch on the code — never on title or detail strings.
| Code | HTTP | Meaning | Remediation |
|---|---|---|---|
| auth.unauthorized | 401 | Unauthorized The request did not include valid credentials. | Send an `Authorization: Bearer <jwt>` header, or `Authorization: Bearer pa_live_…` / `X-Api-Key: pa_live_…` for an API key. |
| auth.forbidden | 403 | Forbidden The credentials are valid but lack permission for this operation. | Confirm the user owns the resource or has the required role. |
| auth.invalid_credentials | 401 | Invalid credentials Email/password combination did not match any account. | Verify the credentials. Repeated failures are throttled by the per-IP login limit. |
| auth.api_key_invalid | 401 | API key invalid The provided API key did not match a known prefix, or its hash did not validate. | Re-copy the key from the API keys page. If you regenerated the key, the old value is permanently invalid. |
| auth.api_key_disabled | 401 | API key disabled The API key slot has been disabled by the account owner. | Switch the application to the other key slot (Primary ↔ Secondary), or regenerate the disabled slot to re-enable it. |
| auth.not_owner | 403 | Not the resource owner The authenticated account is not the owner of the requested resource. | Log out and sign in with the account that owns this resource. |
| auth.origin_not_allowed | 403 | Origin not allowed The request originated from a domain that is not in the account's allowlist. | Add the calling domain to the account's allowed origins, or contact support. |
| auth.consent_required | 403 | Consent required The end user has not granted the consent required for this operation. | Prompt the end user to complete the consent flow before retrying. |
| auth.user_not_initialized | 403 | User not initialized The authenticated account is missing required setup data. | Complete account onboarding (profile, billing, etc.) and retry. |
| limits.rate_exceeded | 429 | Rate limit exceeded Too many requests. Check the `Retry-After` header for the recommended wait time. The `extensions.scope` field hints at the partition (`user`, `ip`, `session`, `api_key_slot`). | Honor `Retry-After`. If `scope` is `api_key_slot`, switching to the other key slot may unblock immediately. |
| limits.concurrent_session | 429 | Concurrent session limit The account already has the maximum number of active sessions. | Close another active session or upgrade the subscription tier. |
| limits.waiting_room_full | 503 | Waiting room full The waiting room queue is at capacity. | Back off and retry after a short delay. |
| limits.quota_exceeded | 402 | Quota exceeded The account has consumed its allotted quota (minutes, knowledge units, storage, etc.). | Upgrade the subscription tier or wait for the next billing cycle. |
| limits.subscription_required | 402 | Subscription required This operation requires an active paid subscription. | Purchase a subscription on the pricing page. |
| limits.session_time_limit | 409 | Session time-of-day limit Sessions cannot start at the current time per the persona's schedule. | Retry within the persona's configured availability window. |
| limits.session_interaction_limit | 429 | Session interaction limit (WebSocket close 4005) Returned only as WebSocket close 4005, never as HTTP. The live session reached the persona's per-session interaction cap (PublishSettings.SessionInteractionLimit, counted in billing units — Full mode = 2 per turn, Chat mode = 1). | End-user UI should show a session-ended message; starting a new session is allowed (subject to cross-session limits). |
| limits.session_duration_limit | 409 | Session duration limit (WebSocket close 4006) Returned only as WebSocket close 4006, never as HTTP. The live session exceeded the persona's per-session duration cap (PublishSettings.SessionDurationLimitSeconds, wall-clock — includes Standby/idle time). | End-user UI should show a session-ended message; starting a new session is allowed (subject to cross-session limits). |
| upload.file_too_large | 413 | File too large The uploaded file exceeds the per-endpoint size limit. | Compress the file or split it. See the endpoint reference for per-endpoint size caps. |
| upload.invalid_signature | 400 | Invalid file signature The file content does not match the declared content type (e.g. an `.exe` renamed to `.pdf`). | Verify the file is genuinely the type its extension claims. |
| upload.unsupported_type | 415 | Unsupported file type The file's content type is not accepted by this endpoint. | Convert the file to one of the supported types listed in the endpoint reference. |
| upload.page_limit_exceeded | 413 | Page limit exceeded The document exceeds the maximum page count for ingestion. | Split the document into smaller files. |
| resource.not_found | 404 | Resource not found No resource exists at the addressed URL, or it is not visible to the caller. | Verify the ID, then the caller's permissions. |
| resource.conflict | 409 | Resource conflict The operation conflicts with the resource's current state (duplicate, version skew, etc.). | Re-read the resource and retry with current state. |
| validation.failed | 400 | Validation failed One or more request fields failed validation. The `errors` extension contains per-field details. | Inspect `extensions.errors` and correct the highlighted fields. |
| server.error | 500 | Server error An unexpected server-side error occurred. | Retry with backoff. If the failure persists, contact support with the response's `traceId` extension if present. |
Rate limits
When a limit is exceeded the response is 429 Too Many Requests with a Retry-After header (seconds) and ProblemDetails body code = limits.rate_exceeded. The extensions.scope field tells you whether the partition was per-user, per-IP, or per-API-key-slot.
| Policy | Limit | Window | Partition | Purpose |
|---|---|---|---|---|
| AuthSensitive | 5 | 30 seconds | per IP scope: ip | Login, register, password reset, email confirmation. Strict per-IP limit so credential-stuffing campaigns from a single source get throttled fast. |
| AuthRefresh | 30 | 1 minute | per IP scope: ip | Refresh-token exchange. Looser than AuthSensitive but still per-IP to bound rotation abuse. |
| PersonaUploads | 20 | 1 minute | per user (per IP if anonymous) scope: user | Persona texture/asset uploads. Per-user when authenticated, per-IP otherwise. |
| KnowledgeUploadsRate internal services bypass | 60 | 1 minute | per user (per IP if anonymous) scope: user | Knowledge document uploads (PDF/DOCX/etc.). Limit is sourced from KnowledgeAcceptorSettings.RateLimit so the same knob controls accept-time check and throttle. |
| SubscriptionChange | 5 | 10 minutes | per user (per IP if anonymous) scope: user | Subscription tier changes and cancellations. Tight to prevent oscillation abuse. |
| MutationDefault internal services bypass | 300 | 1 minute | per user (per IP if anonymous) scope: user | Generic per-user safety net for state-changing endpoints (POST/PUT/PATCH/DELETE) without a stricter category-specific policy. Loose by design — never bites a real user, catches scripted abuse. |
| Global internal services bypass | 300 | 1 minute | per IP scope: ip | Per-IP safety net applied to every request. Loose by design — never bites a real user, catches scripted abuse on untagged endpoints. Stricter category-specific policies apply on top. |
| SessionSnapshot | 30 | 1 minute | per session scope: session | Snapshot uploads per active session (matches expected camera-capture cadence with headroom). Partitioned by sessionId. |
| SessionRead | 240 | 1 minute | per session scope: session | Per-session image / snapshot reads. Looser than upload — the same client may pull many cached assets. |
| SessionWebSocket | 30 | 1 minute | per IP scope: ip | WebSocket upgrade attempts per source IP. Bounds concurrent-connection abuse before a session is fully established. |
| WaitingRoomQueue | 30 | 1 minute | per user (per IP if anonymous) scope: user | Queue join / finalize / cancel actions. Per-user when authenticated, per-IP for anonymous guests so shared egress (corporate proxy, mobile CGN) doesn't share a bucket between legitimate users. |
| WaitingRoomQueueWs | 60 | 1 minute | per IP scope: ip | WebSocket upgrade attempts for queue position polling. Per-IP so one host can't open thousands of long-lived sockets to exhaust capacity. |