Skip to main content
VoicEra stores everything in FerretDB, which speaks the MongoDB wire protocol over PostgreSQL. This page lists every collection, the fields on its documents, the enumerations that constrain them, and the indexes created at startup. Collection names and indexes come from apps/api/app/database_init.py. Document field names come from apps/api/app/models/schemas.py and the services that write them. Campaign and queued-run documents come from apps/api/app/services/campaign/campaign_repository.py. initialize_database() runs on every API startup and is idempotent. It creates all eleven collections and their indexes if they are missing and leaves existing data alone. There is no migration system — a schema change is a code change plus whatever backfill you write yourself. There are eleven collections: Organizations, Users, Memberships, ProviderAuth, Agents, PhoneNumbers, KnowledgeDocuments, CallLogs, CallMetrics, Campaigns, and QueuedRuns.

Relationships

Every collection except Users is scoped by org_id. Users is global — one account can hold memberships in many organisations, and the JWT carries whichever one is currently active. See Multi-tenancy and roles.

Organizations

One document per organisation. Created by POST /api/v1/users/signup and by nothing else — there is no standalone create-organisation endpoint. Written by apps/api/app/services/org_service.py, which sets only the first four fields. concurrent_call_limit is read — not written — by get_org_concurrent_limit() in apps/api/app/services/campaign/campaign_repository.py: when present it is clamped to a minimum of 1, and when absent the organisation falls back to DEFAULT_ORG_CONCURRENCY_LIMIT (default 10). No endpoint writes concurrent_call_limit. The field is read by the campaign code but can only be set by editing the document directly in FerretDB.

Users

One document per person, global across organisations. Passwords are bcrypt hashes truncated to bcrypt’s 72-byte limit before hashing. The UserResponse schema returned by GET /api/v1/users/me adds org_id, role, organisation_name, and an organisations list of {org_id, name, role} — those are joined from Memberships and Organizations at read time, not stored on the user.

Memberships

The join between a user and an organisation, carrying the role. A user with three memberships has three documents. Signup writes the super_admin membership. POST /api/v1/members/invite writes member memberships; POST /api/v1/members/assign-admin promotes one to admin.

ProviderAuth

Encrypted credentials for one provider in one organisation. Documented in full at Provider credentials (ProviderAuth). GET /api/v1/auth/{provider} returns the decrypted auth as an object, masked for callers whose role is not admin or super_admin.
The auth blob is encrypted with PROVIDER_AUTH_ENCRYPTION_KEY. Rotating that key makes every stored credential undecryptable, and there is no re-encryption path — every organisation has to re-enter its provider credentials.

Agents

One document per agent. The behaviour and AI configuration live in a nested config blob. The config blob holds schema_version, prompts, behaviour, language, models, knowledge_base, and custom_variables. Every field of it — the full AgentBehaviour knob list, the STT, TTS, and LLM config shapes, and the knowledge-base attachment — is documented in Agent configuration. hangup_url is optional on the telephony attachment for agents provisioned before hangup URLs were always set.

PhoneNumbers

The organisation’s number inventory. A number belongs to one organisation globally and is optionally bound to one agent.
The phone_number_unique index is on phone_number alone, with no org_id component. Two organisations cannot hold the same number, and the second attach fails on a duplicate key rather than with a clear conflict message.

CallLogs

One document per call, inbound, outbound, or browser. duration is derived, not supplied. When a patch sets end_time_utc and does not carry a duration, apps/api/app/services/call_log_service.py computes it from start_time_utc and end_time_utc and clamps it to zero or above. A patch with no end_time_utc has any duration stripped out. CallLogResponse in apps/api/app/models/schemas.py declares recording_url and transcript_url twice each. Pydantic keeps the last declaration, so the behaviour is identical to declaring them once — but the duplication is real in the source and is not a documentation error.

CallMetrics

One document per call with pipeline performance data. Written once at call end by the runtime via PUT /api/v1/calls/{call_id}/metrics. Fetched separately through GET /api/v1/calls/{call_id}/metrics — not embedded on CallLogResponse.

Campaigns

One document per outbound campaign. Written by apps/api/app/services/campaign/campaign_repository.py. Defaults defined in apps/api/app/constants/campaign.py: max_concurrency, schedule_config, and circuit_breaker are not top-level fields. apps/api/app/routers/campaign.py nests all three inside orchestrator_metadata on both create and update, so orchestrator_metadata.max_concurrency is where the concurrency ceiling actually lives. Create and update both reject a max_concurrency above the organisation’s ceiling, which is Organizations.concurrent_call_limit when set and DEFAULT_ORG_CONCURRENCY_LIMIT otherwise.

QueuedRuns

The per-contact call queue for a campaign. One document per contact per retry attempt — a retry creates a new document rather than mutating the original, which is what makes the retry path idempotent. The orchestrator claims work with find_one_and_update, moving a document from queued to processing atomically, so two orchestrator instances cannot dial the same contact. campaign_call_dispatcher.py then sets processed or failed. The (campaign_id, source_uuid, retry_count) unique index is the idempotency guarantee: re-running the queue build for a campaign cannot create a second document for the same contact at the same attempt number.

KnowledgeDocuments

Metadata for an uploaded PDF. The vectors themselves live in per-organisation Chroma stores under CHROMA_BASE_DIR, not in FerretDB. Ingest runs as a FastAPI background task, so POST /api/v1/knowledge/upload returns processing immediately. See Knowledge base (RAG).

Enumerations

Every enumeration is a Literal in apps/api/app/models/schemas.py unless noted. Values are case-sensitive. Role is mirrored as constants in apps/api/app/database_init.py (ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_MEMBER, and the VALID_ROLES frozenset), which is what the routers compare against. TelephonyProvider is aliased to plain str, not a Literal. Valid values come from the telephony registry at runtime — call GET /api/v1/configuration/telephony to enumerate them. See Provider registry. The QueuedRuns.state values — queued, processing, processed, failed — are string literals in the campaign services, not a declared Literal type. failed appears in three different enumerations with three different meanings: a call that failed to connect (CallLogStatus), a call disposition (CallResponse), and a campaign that stopped abnormally (CampaignState). They are unrelated. Read the field name, not the value.

Indexes

Created by initialize_database() on every API startup. Failures on already-existing or duplicate indexes are swallowed and logged. Every organisation-scoped read is served by an org_id prefix, which is what keeps tenant isolation cheap rather than a full scan.