Security & Data Residency
Sourced from PROJECT-DOCS/DEVELOPER_PLATFORM.md (measured against production, 2026-08-05) and cross-checked against pkg/crypto/aesgcm.go, pkg/database/customer_memory_crypto.go, and database.Scoped(). Where a figure is a point-in-time production measurement rather than a structural fact, it's marked as such — it will drift.
Whose database is it
Ours — there is no bring-your-own-database option, and no code path that would allow one. A grep across the backend and migrations for any external-database concept (external_db, customer_db, a stored customer connection string) found nothing. Every customer's data lives in one shared Postgres 16 + pgvector instance, isolated by organization_id on every query.
Production, as measured 2026-08-05: 130 MB · 66 organizations · 124 live agents · 1,821 calls · 245 memory rows. (Point-in-time — will be stale by the time you read this; don't cite the exact counts, cite the architecture.)
Tenant isolation
Every tenant-scoped query goes through a helper (database.Scoped(c, db)) that panics in dev and fails closed in production if the organization filter is missing from the request context — a missing filter is treated as a cross-tenant leak waiting to happen, not a bug to fix at leisure. That design paid off directly: a 2026-08-04 sweep using this same reasoning found and fixed six real cross-tenant issues on the write side, including a body-supplied agent_id that could be written into another organization's row, a reseller code path that degenerated to WHERE id = ? with no org filter, and a call-completion webhook scoped so narrowly it had reached nobody for three months.
Encryption, layer by layer
Stated plainly because "we encrypt customer data" is true for some of it and not yet true for all of it, and the honest breakdown matters more than the summary claim:
| Layer | State |
|---|---|
| Disk at rest | Azure SSE, platform-managed key |
| OAuth tokens (HubSpot / Google / Salesforce), SSO, LTI | AES-256-GCM, app-layer, our own key |
| Customer memory (summary, last-interaction, follow-up reason, key facts) | AES-256-GCM, app-layer |
| API keys | SHA-256 hash — not encrypted, unrecoverable by design |
| Call recordings | Azure Blob, SSE + short-lived SAS URLs |
| Call transcripts | Disk-layer encryption only — not application-layer |
Call transcripts are the largest single body of Amendment 13 (Israeli privacy law) personal data this platform holds, and they are the one category not yet encrypted at the application layer. That's a real, current gap, not a historical one that's since been closed.
Separately: a backfill of the customer-memory encryption left some pre-existing rows in plaintext (all last written before 2026-06-28 as of the 2026-08-05 measurement) — the write path is correct and has been for a while, it's specifically old rows from before the encryption was added that are still catching up. A backfill job exists and is idempotent; running it against production is a deliberate, manually-triggered operation, not an automatic migration.
What this means if you're integrating
- Don't put anything in a webhook
payload_template,custom_headers, or agentmetadatafield that you wouldn't want stored at the same protection level as the category it belongs to above. - If your integration touches call transcripts via the API (
GET /calls/:idincludes transcript), treat that data with the same care you'd apply to any unencrypted-at-rest PII on your own side — VoiceMatrix's disk-level encryption protects against physical media loss, not against a database compromise. - API keys cannot be recovered if lost — only replaced. Build your integration assuming key rotation is a "generate new, update config, revoke old" flow, not a "retrieve the old one" flow.
Next
- Authentication — how keys themselves are protected
- Errors