integrations — Phase 5

Schema locked 2026-06-11. 9 tables, 127 cols: connector (13), connector_credential (13), sync_run (18), sync_run_item (14), sync_error (14), provider_call (16), connector_webhook_event (12), webhook_delivery (16), field_mapping (11).

Integrations is the generic external-connector RUNTIME. One connector framework reused across all connector types — NOT per-integration tables. It executes API calls, runs sync jobs, processes inbound webhooks for its own connectors, delivers outbound webhooks, and logs/retries. All tables are tenant-scoped with RLS except connector_webhook_event (nullable tenant_id; written by webhook handler via service_role).

Design principles (document at every session touching this schema):

  • Config vs. runtime. admin.integration_config owns the config (enabled/disabled toggle, credentials_ref vault pointer, sync_schedule). admin.webhook_config owns outbound webhook endpoint definitions (URL, event subscriptions, secret_ref). Integrations owns the runtime: it reads Admin config at execution time and writes back last_sync_at / last_sync_status to admin.integration_config after each sync run. This is a controlled cross-schema write — the same pattern as Billing writing back paid_at / payment_status_ref to purchasing.vendor_invoice. Never duplicate config columns into the Integrations schema.
  • Reference-don't-copy. provider_call logs the mechanical API-call state: did the Resend/Twilio/FCM call succeed, get rate-limited, enter retry? It stores the provider_message_ref that Notifications stores on delivery_attempt. It does NOT store delivery outcome, engagement, recipient, or content — those belong to notifications.delivery_attempt (the authoritative business record). provider_call exists for connector-layer observability (rate-limit debugging, retry state, dead-letter), not as a delivery duplicate.
  • Owner-processes-own-webhooks. Integrations processes inbound webhooks for its own connectors (QuickBooks change notifications, Twilio inbound SMS, etc.) via connector_webhook_event. Payments owns Stripe's inbound webhooks (payments.stripe_event_log). There is no central webhook table spanning all modules. Rule: the module that owns the integration owns that integration's inbound webhooks.
  • Source type. A connector's source is 'api' (API-based sync — QuickBooks, Shopify), 'file' (deterministic known-format file import — Picas CSV, templated bulk load, via field_mapping + sync_run), or 'webhook' (event-driven — inbound webhook triggers processing). File source here means deterministic/known-format imports only. AI zero-mapping import (upload a random CSV → AI identifies structure → loads data) is NOT here — that belongs to the AI module.
  • Auth providers are not here. SSO/SAML/OIDC/social-login authentication providers are handled by Supabase Auth + identity.sso_provider config. Integrations connects to business systems (QuickBooks, Shopify, notification providers) — never to auth providers. Do not add auth-provider connectors to this schema.
  • Rate-limit state is not a table. Live rate-limit windows are ephemeral runtime state (Redis in-memory windows). provider_call logs individual calls for observability; the live sliding-window counter is never persisted to Postgres. This is a conscious non-table decision.

Cross-Phase FK seams:

Column References Status
*.tenant_id platform.tenant Locked — enforced
connector.integration_config_id admin.integration_config Locked — enforced (1:1 runtime anchor; reads config; writes back sync status)
connector_credential.connector_id, sync_run.connector_id, sync_run_item.connector_id, sync_error.connector_id, provider_call.connector_id, connector_webhook_event.connector_id, field_mapping.connector_id integrations.connector Intra-schema — enforced
sync_error.sync_run_id, sync_run_item.sync_run_id integrations.sync_run Intra-schema — enforced (nullable)
connector.last_sync_run_id integrations.sync_run Intra-schema — enforced (nullable; denormalized pointer)
webhook_delivery.webhook_config_id admin.webhook_config Locked — enforced (Integrations reads outbound endpoint def)
connector_webhook_event.tenant_id platform.tenant Nullable — resolved post-receipt; same pattern as payments.stripe_event_log
provider_call.provider_message_ref notifications.delivery_attempt.provider_message_ref Read seam — not an FK; Notifications stores this ref after IntegrationsService.send() returns it
connector_webhook_event.routed_to Downstream table (e.g. notifications.inbound_message for Twilio inbound SMS) Text seam — records where the event was routed; not an FK
sync_run_item.local_ref Any Vrida record (polymorphic — inventory.item_variant, crm.customer, purchasing.vendor_invoice, etc.) Polymorphic — NOT enforced FK; entity_type is the discriminator
sync_run.file_ref Files module / R2 key Text seam — Files module not built
Integrations WRITES BACK last_sync_at/last_sync_status to admin.integration_config admin schema Write seam — controlled cross-schema write via AdminService; not a FK

Deferred items:

  • v1.5 connectors: Shopify / WooCommerce / Wix (eCommerce sync), Sage / Xero / Sage Intacct (accounting), Mailchimp / SendGrid (marketing) — framework supports via connector_type; built later.
  • Workforce sync (Gusto / ADP / Deputy) — likely not built: HR is permanently out of scope for Vrida (cut 2026-06-10). These connectors lose their purpose without the HR module.
  • AI zero-mapping import pipeline (import_job / import_file / import_record) — relocated to AI module per PROJECT_DECISIONS "Integrations Module Scope (2026-06-11)". Not here.
  • Rate-limit window state — ephemeral runtime (Redis); not a table by design.
  • L3 (deferred): sync_run composite index ON (connector_id, started_at) — add when sync-run dashboard time-range queries are built.

integrations.connector — 13 cols

Runtime anchor — one row per active integration instance. The live operational counterpart to admin.integration_config (the config row). Tracks connection health, current status, and a denormalized pointer to the most recent sync run. connector_type is a free-text discriminator (e.g. 'quickbooks', 'resend', 'twilio') — the generic framework uses this to route connector-specific logic.

RLS: tenant-isolated on tenant_id.

Column Type Nullable Default Constraints / Notes
id UUID NOT NULL uuid_generate_v4() PK
tenant_id UUID NOT NULL FK → platform.tenant
created_at timestamptz NOT NULL now()
updated_at timestamptz NOT NULL now()
deleted_at timestamptz nullable Soft delete
integration_config_id UUID NOT NULL FK → admin.integration_config — the config row this runtime serves; 1:1
connector_type text NOT NULL Free-text discriminator (e.g. 'quickbooks', 'resend', 'twilio', 'fcm', 'picas_csv', 'shopify'). Generic — no enum; new connectors are a new value, not a schema change.
source_type text NOT NULL CHECK IN ('api', 'file', 'webhook') — 'api' = API-based sync; 'file' = deterministic known-format import; 'webhook' = event-driven
status text NOT NULL 'disconnected' CHECK IN ('connected','disconnected','error','connecting') — RUNTIME operational status, distinct from admin.integration_config.is_enabled (the config toggle)
last_handshake_at timestamptz nullable Last successful connection check / OAuth ping
last_sync_run_id UUID nullable FK → integrations.sync_run — most recent run; denormalized pointer for dashboard queries
health text nullable CHECK (health IS NULL OR health IN ('healthy','degraded','failing'))
error_message text nullable Current error detail when status = 'error'

Indexes:

  • PK on id
  • on (tenant_id)
  • on (connector_type)
  • on (status) WHERE status IN ('error','connecting')
  • UNIQUE on (tenant_id, integration_config_id) WHERE deleted_at IS NULL — one runtime connector per config row

integrations.connector_credential — 13 cols

OAuth runtime token state — rotates frequently (typically hourly). Distinct from admin.integration_config.credentials_ref, which is the static config pointer to the initial OAuth grant. This table holds the live access/refresh token vault references and their expiry state, enabling the connector to refresh tokens without touching Admin's config row.

All token refs are vault references — never raw tokens. The expires_at column drives the proactive refresh sweep.

RLS: tenant-isolated on tenant_id.

Column Type Nullable Default Constraints / Notes
id UUID NOT NULL uuid_generate_v4() PK
tenant_id UUID NOT NULL FK → platform.tenant
created_at timestamptz NOT NULL now()
updated_at timestamptz NOT NULL now()
deleted_at timestamptz nullable Soft delete
connector_id UUID NOT NULL FK → integrations.connector
credential_type text NOT NULL CHECK IN ('oauth2','api_key','basic','token')
access_token_ref text nullable Vault reference — the live access token; never stored raw
refresh_token_ref text nullable Vault reference — the refresh token; never stored raw
expires_at timestamptz nullable Access token expiry — the refresh sweep targets this column
scope text nullable OAuth granted scopes (e.g. 'com.intuit.quickbooks.accounting')
last_refreshed_at timestamptz nullable When the access token was last refreshed
status text NOT NULL 'active' CHECK IN ('active','expired','revoked','refresh_failed')

Indexes:

  • PK on id
  • on (tenant_id)
  • on (connector_id)
  • on (expires_at) WHERE status = 'active' — refresh sweep: find credentials expiring soon
  • UNIQUE on (connector_id) WHERE deleted_at IS NULL — one active credential set per connector

integrations.sync_run — 18 cols

One row per sync job execution. Mutable: status progresses from 'queued''running' → terminal state; record counts update as processing proceeds. The run_type discriminator covers the full range from scheduled batch syncs to file imports to webhook-triggered incremental syncs. Integrations writes last_sync_at / last_sync_status back to admin.integration_config when a run completes.

NOT append-onlystatus, records_succeeded, records_failed, completed_at, failure_reason all mutate as the run progresses.

RLS: tenant-isolated on tenant_id.

Column Type Nullable Default Constraints / Notes
id UUID NOT NULL uuid_generate_v4() PK
tenant_id UUID NOT NULL FK → platform.tenant
created_at timestamptz NOT NULL now()
updated_at timestamptz NOT NULL now()
deleted_at timestamptz nullable Soft delete
connector_id UUID NOT NULL FK → integrations.connector
run_type text NOT NULL CHECK IN ('scheduled','manual','initial_import','webhook_triggered','file_import')
direction text NOT NULL CHECK IN ('inbound','outbound','bidirectional')
status text NOT NULL 'queued' CHECK IN ('queued','running','completed','failed','partial','cancelled')
started_at timestamptz nullable When processing actually began
completed_at timestamptz nullable When the run reached a terminal status
records_total integer nullable Total records to process (NULL until known)
records_succeeded integer NOT NULL 0 Running count of successfully processed records
records_failed integer NOT NULL 0 Running count of failed records
file_ref text nullable For run_type = 'file_import' — the uploaded file's R2 key. Text seam — Files module not built.
trigger_ref text nullable What triggered the run (e.g. a connector_webhook_event.id for webhook-triggered runs)
summary text nullable Human-readable run summary (e.g. 'Synced 142 invoices, skipped 3')
failure_reason text nullable Top-level failure description for failed / partial runs

Table-level CHECKs:

CHECK (run_type != 'file_import' OR file_ref IS NOT NULL)

A file-import run must carry its file reference. (file_ref is the R2 key of the uploaded file; required for file-import runs, null for all others.)

Indexes:

  • PK on id
  • on (tenant_id)
  • on (connector_id)
  • on (status) WHERE status IN ('queued','running','failed')
  • on (started_at)
  • on (run_type)

integrations.sync_run_item — 14 cols

Per-record sync result — granular trail of what happened to each record in a sync_run. Provides the row-level audit needed to diagnose partial failures, replay failed items, and report on sync coverage. local_ref and external_record_id together form the cross-system identity map for each record.

RLS: tenant-isolated on tenant_id.

Column Type Nullable Default Constraints / Notes
id UUID NOT NULL uuid_generate_v4() PK
tenant_id UUID NOT NULL FK → platform.tenant
created_at timestamptz NOT NULL now()
updated_at timestamptz NOT NULL now()
deleted_at timestamptz nullable Soft delete
sync_run_id UUID NOT NULL FK → integrations.sync_run
connector_id UUID NOT NULL FK → integrations.connector — denormalized for direct connector-level queries without joining through sync_run
direction text NOT NULL CHECK IN ('inbound','outbound')
entity_type text NOT NULL What kind of record (e.g. 'invoice', 'customer', 'product', 'chart_of_accounts'). Connector-specific vocabulary.
local_ref UUID nullable The Vrida record's id (the mapped local entity). Polymorphic — NOT enforced FK; entity_type is the discriminator.
external_record_id text nullable The external system's record identifier (QuickBooks DocNumber, Shopify product id, etc.)
operation text NOT NULL CHECK IN ('create','update','delete','skip')
status text NOT NULL 'succeeded' CHECK IN ('succeeded','failed','skipped')
error_detail text nullable Error message for failed items

Indexes:

  • PK on id
  • on (tenant_id)
  • on (sync_run_id)
  • on (connector_id) — connector-level item queries without joining through sync_run
  • on (status) WHERE status = 'failed'
  • on (entity_type, local_ref) WHERE local_ref IS NOT NULL — "which sync runs touched this Vrida record"
  • on (external_record_id) WHERE external_record_id IS NOT NULL — "which Vrida record maps to this external id"

integrations.sync_error — 14 cols

Error and dead-letter log per connector. Collects errors that may span multiple sync runs (auth failures, persistent mapping failures) or are not tied to a specific run (connection errors). Retryable: retry_count and last_retry_at drive the retry loop; resolved_at marks closure.

RLS: tenant-isolated on tenant_id.

Column Type Nullable Default Constraints / Notes
id UUID NOT NULL uuid_generate_v4() PK
tenant_id UUID NOT NULL FK → platform.tenant
created_at timestamptz NOT NULL now()
updated_at timestamptz NOT NULL now()
deleted_at timestamptz nullable Soft delete
connector_id UUID NOT NULL FK → integrations.connector
sync_run_id UUID nullable FK → integrations.sync_run — the run this error occurred in, if any. NULL for connection-level errors not tied to a run.
error_type text NOT NULL CHECK IN ('auth','rate_limit','validation','network','mapping','provider','other')
error_message text NOT NULL Error description
payload JSONB nullable The failing record or request payload, preserved for replay. Example: {"entity":"invoice","external_id":"INV-1001","error":"Validation error: tax_code missing","raw":{"DocNumber":"1001","TxnDate":"2026-06-11"}}
retry_count integer NOT NULL 0 Number of retry attempts made
last_retry_at timestamptz nullable When the last retry was attempted
resolved_at timestamptz nullable When the error was resolved (retried successfully, manually resolved, or abandoned)
resolution text nullable CHECK (resolution IS NULL OR resolution IN ('retried','manual','abandoned'))

Indexes:

  • PK on id
  • on (tenant_id)
  • on (connector_id)
  • on (error_type)
  • on (resolved_at) WHERE resolved_at IS NULL — open dead-letter queue sweep
  • on (last_retry_at) WHERE resolved_at IS NULL — retry-worker sweep: unresolved errors due for next retry attempt

integrations.provider_call — 16 cols

Thin mechanical API-call log for provider-executed sends (Resend email, Twilio SMS, FCM/APNS push). Records whether the API call to the provider succeeded, was rate-limited, or entered retry — the connector layer's operational view. This is NOT a delivery record. The provider_message_ref returned by the provider is what NotificationsService stores on notifications.delivery_attempt (the authoritative business record with delivery outcome + engagement).

Reference-don't-copy: provider_call owns mechanical call state; notifications.delivery_attempt owns business delivery + engagement. These serve different concerns: provider_call is for connector-layer debugging (rate limits, retry exhaustion, dead-letter); delivery_attempt is for business delivery reporting (delivered, opened, clicked).

NOT append-onlystatus, http_status, attempt_count, error_detail, completed_at all mutate as the call progresses and retries.

RLS: tenant-isolated on tenant_id.

Column Type Nullable Default Constraints / Notes
id UUID NOT NULL uuid_generate_v4() PK
tenant_id UUID NOT NULL FK → platform.tenant
created_at timestamptz NOT NULL now()
updated_at timestamptz NOT NULL now()
deleted_at timestamptz nullable Soft delete
connector_id UUID NOT NULL FK → integrations.connector
provider text NOT NULL The provider being called (e.g. 'resend', 'twilio', 'fcm', 'apns', 'quickbooks')
call_type text NOT NULL The operation being executed (e.g. 'send_email', 'send_sms', 'send_push', 'sync_invoice')
idempotency_key text nullable Outgoing call dedup key — prevents double-calling the provider on retry
provider_message_ref text nullable The provider's ID returned on success (Resend email ID, Twilio SID, FCM message ID, etc.). This is what NotificationsService stores on notifications.delivery_attempt.provider_message_ref.
status text NOT NULL 'queued' CHECK IN ('queued','sent','succeeded','failed','rate_limited','retrying'). STATUS STATE MACHINE: queuedsent (HTTP request dispatched + provider accepted, e.g. Resend/Twilio 202 Accepted — async, not yet confirmed) → succeeded (confirmed: synchronous 200 OK, or a provider-confirmed delivery response). For fire-and-forget providers (Resend, Twilio), 'sent' is often the terminal state for Integrations — delivery CONFIRMATION flows to notifications.delivery_attempt via inbound webhook, NOT back here. Error path: failed / rate_limited / retrying. Retry logic: retry 'failed'/'rate_limited' only — NOT 'sent' (already accepted by provider).
http_status integer nullable HTTP response code from the provider
attempt_count integer NOT NULL 0 Total attempts made (including retries)
error_detail text nullable Provider error message or code
requested_at timestamptz NOT NULL When the call was initiated
completed_at timestamptz nullable When the call reached a terminal status

Table-level CHECKs:

CHECK (status != 'succeeded' OR provider_message_ref IS NOT NULL)

A succeeded call must carry the provider message reference — this is the value NotificationsService cross-looks-up when storing the ref on notifications.delivery_attempt.

Indexes:

  • PK on id
  • on (tenant_id)
  • on (connector_id)
  • on (status) WHERE status IN ('queued','failed','rate_limited','retrying')
  • on (provider_message_ref) WHERE provider_message_ref IS NOT NULL
  • UNIQUE on (tenant_id, idempotency_key) WHERE idempotency_key IS NOT NULL AND deleted_at IS NULL

integrations.connector_webhook_event — 12 cols

Inbound webhook idempotency log for Integrations-owned connectors (QuickBooks change notifications, Twilio inbound SMS, etc.). Insert-then-update-status: status and processed_at mutate post-insert; no updated_at; no deleted_at — events are never deleted (same pattern as payments.stripe_event_log).

tenant_id is nullable. Some webhooks arrive before tenant resolution (the provider posts to Vrida's endpoint, not a tenant-specific URL). tenant_id is resolved from the connector context and set in the processing step. Same pattern as payments.stripe_event_log.tenant_id.

GLOBAL UNIQUE on external_event_id. Provider event IDs are globally unique (QuickBooks, Twilio, etc. generate UUIDs without tenant scope). The dedup must be global — if a webhook is retried before tenant resolution completes, a tenant-scoped unique would allow a second insert. Guard: do not change this to a partial or tenant-scoped unique.

routed_to: Twilio inbound SMS events are routed to notifications.inbound_message (Integrations receives, Notifications processes). This text field records the routing target for traceability.

RLS: Non-standard. Written by webhook handler via service_role (same as payments.stripe_event_log). tenant_id is nullable. Tenant-scoped reads work for resolved events; unresolved events are service-role only.

Column Type Nullable Default Constraints / Notes
id UUID NOT NULL uuid_generate_v4() PK
tenant_id UUID nullable FK → platform.tenant — nullable; resolved post-receipt
created_at timestamptz NOT NULL now()
connector_id UUID nullable FK → integrations.connector — resolved from webhook source; may be null briefly during processing
provider text NOT NULL Provider that sent the webhook (e.g. 'quickbooks', 'twilio')
event_type text NOT NULL External event type (e.g. 'invoice.updated', 'inbound_sms', 'customer.updated')
external_event_id text NOT NULL Provider's event ID — the global dedup key (NOT tenant-scoped; see Guard above)
status text NOT NULL 'received' CHECK IN ('received','processed','ignored','failed') — mutates post-insert; 'received' is the only insert-time value
payload JSONB nullable Raw webhook payload as received from the provider. Example (QuickBooks change notification): {"eventNotifications":[{"realmId":"1234567","dataChangeEvent":{"entities":[{"name":"Invoice","id":"9001","operation":"Update","lastUpdated":"2026-06-11T10:00:00Z"}]}}]}
routed_to text nullable Where this event was routed for processing (e.g. 'notifications.inbound_message' for Twilio inbound SMS)
processed_at timestamptz nullable When status moved to 'processed' or 'ignored'
received_at timestamptz NOT NULL When the webhook was received from the provider

Indexes:

  • PK on id
  • on (tenant_id)
  • on (connector_id)
  • on (event_type)
  • UNIQUE on (external_event_id) — GLOBAL, not partial, not tenant-scoped. Guard: do not change to a partial unique. Provider event IDs are globally unique; webhooks may arrive before tenant resolution.
  • on (status) WHERE status IN ('received','failed')

integrations.webhook_delivery — 16 cols

Outbound webhook delivery execution — fires Vrida events at tenant-configured external endpoints. Reads admin.webhook_config for the endpoint definition (URL, subscriptions, secret_ref). target_url is snapshotted at send-time so the delivery record remains accurate if the endpoint is reconfigured. next_retry_at drives the retry backoff sweep.

NOT append-onlystatus, http_status, attempt_count, last_attempt_at, next_retry_at, response_body all mutate as delivery is attempted and retried.

RLS: tenant-isolated on tenant_id.

Column Type Nullable Default Constraints / Notes
id UUID NOT NULL uuid_generate_v4() PK
tenant_id UUID NOT NULL FK → platform.tenant
created_at timestamptz NOT NULL now()
updated_at timestamptz NOT NULL now()
deleted_at timestamptz nullable Soft delete
webhook_config_id UUID NOT NULL FK → admin.webhook_config — the configured endpoint being delivered to
event_type text NOT NULL The Vrida event being delivered (e.g. 'pos.sale_completed', 'inventory.stock_low', 'orders.order_fulfilled')
event_ref UUID nullable The source record's id (the triggering entity). Not enforced FK — polymorphic (event_type is the discriminator).
target_url text NOT NULL Snapshot of the endpoint URL at send-time — preserved for accuracy if the config is later changed
status text NOT NULL 'pending' CHECK IN ('pending','delivering','delivered','failed','abandoned')
http_status integer nullable HTTP response code from the external endpoint
attempt_count integer NOT NULL 0 Total delivery attempts (including retries)
last_attempt_at timestamptz nullable When the most recent delivery attempt was made
next_retry_at timestamptz nullable When the next retry should be attempted (exponential backoff). The retry sweep indexes this column.
response_body text nullable Truncated response body from the endpoint (for debugging)
payload JSONB nullable The Vrida event envelope that was sent to the endpoint. Example: {"event":"pos.sale_completed","tenant_id":"<uuid>","occurred_at":"2026-06-11T10:00:00Z","data":{"sale_id":"<uuid>","total_cents":4299,"location_id":"<uuid>"}}

Indexes:

  • PK on id
  • on (tenant_id)
  • on (webhook_config_id)
  • on (status) WHERE status IN ('pending','delivering','failed')
  • on (next_retry_at) WHERE next_retry_at IS NOT NULL — retry sweep
  • on (event_type)

integrations.field_mapping — 11 cols

Transformation config for data-sync connectors — maps external system fields to Vrida fields, optionally with transform rules. Used by both API-sync connectors (QuickBooks chart-of-accounts field map) and file-import connectors (Picas CSV column map). The Picas nursery-specific mapping is data stored here, not a dedicated schema table — generic-first principle in action.

Distinct from AI zero-mapping: field_mapping is the deterministic, human-configured or system-configured mapping for known-format data. AI zero-mapping (where the AI infers the structure from an arbitrary file) belongs to the AI module.

RLS: tenant-isolated on tenant_id.

Column Type Nullable Default Constraints / Notes
id UUID NOT NULL uuid_generate_v4() PK
tenant_id UUID NOT NULL FK → platform.tenant
created_at timestamptz NOT NULL now()
updated_at timestamptz NOT NULL now()
deleted_at timestamptz nullable Soft delete
connector_id UUID NOT NULL FK → integrations.connector
entity_type text NOT NULL The entity being mapped (e.g. 'invoice', 'product', 'customer', 'chart_of_accounts')
direction text NOT NULL CHECK IN ('inbound','outbound','bidirectional')
mapping JSONB NOT NULL The field map definition. Example: {"external_field":"DocNumber","local_field":"invoice_number","transform":"trim"} or an array of such entries.
is_active boolean NOT NULL true Only active mappings are used during sync
version integer NOT NULL 1 Mapping version — incremented when the mapping is revised

Indexes:

  • PK on id
  • on (tenant_id)
  • on (connector_id)
  • on (entity_type)
  • UNIQUE on (tenant_id, connector_id, entity_type, direction, version) WHERE deleted_at IS NULL

Column counts: connector(13) + connector_credential(13) + sync_run(18) + sync_run_item(14) + sync_error(14) + provider_call(16) + connector_webhook_event(12) + webhook_delivery(16) + field_mapping(11) = 127


Last modified: Jun 17, 2026, 8:29 PM PT
On this page
Esc