NetDocuments / ndMax Integration — Architecture

Design for a bidirectional NetDocuments integration hosted in supernova-api. Kafka is the only channel back to the Centerbase Platform — supernova-api does not call CB Core REST endpoints from event handlers, and CB Core does not call supernova-api REST endpoints for sync work.

High-level architecture

[Diagram]

Field mappings (CB ↔ NetDocs)

CB entity NetDocs entity Field-level mapping Direction
Client Profile (Client) cb.client.idnd.client.code; cb.client.namend.client.description CB → ND
Matter Workspace cb.matter.idnd.workspace.matter; cb.matter.namend.workspace.description; cb.matter.statusnd.workspace.status CB → ND
Practice area Profile attribute cb.matter.practiceAreand.attr.PracticeArea (custom cabinet attribute) CB → ND
Responsible atty Profile attribute cb.matter.responsibleAttorney.emailnd.attr.ResponsibleAttorney (resolved to NetDocs user) CB → ND
Document Document cb.document.idnd.document.envId (stored in nd_id_map); cb.document.namend.document.name; mime/version preserved two-way
Document version Document version NetDocs is source of truth for version chain after upload; CB pulls latest via webhook → sync ND → CB
User NetDocs user Match on cb.user.emailnd.user.email at token-exchange time; no automated provisioning lookup
Tags / labels Cabinet keywords cb.matter.tags[]nd.workspace.keywords[] CB → ND

The nd_id_map table is the system of record for cross-system identity ((tenant_id, cb_entity_type, cb_entity_id, nd_envelope_id, nd_url)). All other mappings are derivable from current state on each side.

Bidirectional flow summary

Three independent paths, each driven by the originating side. There is no synchronous round-trip that crosses the CB ↔ ND boundary in both directions on a single request.

CB → ND (interactive, UI-driven)

Centerbase UI → supernova-api REST → NetDocs REST. Used for actions the user is waiting on: open a document, upload a draft, run an ndMax search. Response flows back the same path. No Kafka involvement.

CB → ND (async, state-sync)

CB Core publishes domain events (matter.created, matter.updated, client.updated, doc.uploaded) to centerbase.events. NetDocsSyncService (an IEventHandler in the existing Kafka consumer) projects them into NetDocs workspace/profile/document calls. Failures retried with backoff; permanent failures land in a DLQ.

ND → CB (async, webhook-driven)

NetDocs webhooks → NetDocsWebhookController → persisted to nd_webhook_events → normalized event published on netdocs.events. CB Core has its own consumer on netdocs.events. This is the only way ND-originated state changes reach the Centerbase Platform. Supernova-api never calls back into CB Core directly.

Direction Trigger Transport Latency target Idempotency key
CB → ND User action in CB UI REST (synchronous) <2s p95 Request-scoped; not retried server-side
CB → ND CB Core domain event Kafka centerbase.events <30s p95 (tenant_id, cb_event_id)
ND → CB NetDocs webhook Kafka netdocs.events <10s p95 (tenant_id, nd_event_id)

Webhook flow

NetDocs sends signed callbacks for the subscribed events. Receiver pattern mirrors the existing Kafka consumer:

NetDocs event What we do
document.created Look up workspace in nd_id_map; emit netdocs.document.created on Kafka with CB matter id
document.updated Pull new version metadata; emit netdocs.document.updated; CB Core decides whether to re-index/notify
document.checked_in Clear "checked-out by" badge in CB UI via downstream consumer
workspace.attribute_changed Detect drift vs CB matter; if matter is system of record, enqueue corrective push back to NetDocs
user.permissions_changed Invalidate cached NetDocs ACL for that user

Webhook controller responsibilities:

  1. Verify X-ND-Signature HMAC against per-tenant shared secret (stored in nd_tokens).
  2. Idempotency: dedupe on (tenant_id, nd_event_id) against nd_webhook_events. 24h TTL in Redis as fast-path.
  3. Persist raw payload to nd_webhook_events for replay.
  4. Publish a normalized event to Kafka topic netdocs.events — CB Core's consumer does the heavy work out of band so the webhook returns 200 within NetDocs' retry window (usually <5s).
  5. Return 401 on signature mismatch, 200 on dedupe hit, 200 on accept.

Kafka contract for netdocs.events:

{
  "schemaVersion": 1,
  "eventType": "netdocs.document.updated",
  "tenantId": "<cb_tenant>",
  "occurredAt": "2026-05-14T13:22:01Z",
  "ndEventId": "<nd_event_id>",
  "subject": {
    "ndEnvelopeId": "...",
    "ndWorkspaceId": "...",
    "cbMatterId": "...",
    "cbDocumentId": "..."
  },
  "data": { /* event-specific */ }
}

CB-side identifiers (cbMatterId, cbDocumentId) are resolved via nd_id_map before publishing — CB Core consumers never need to call back into supernova-api to translate IDs.

Auth flow (OAuth2 Authorization Code)

  1. CB user clicks "Connect NetDocuments" → POST /netdocs/authorization returns NetDocs auth URI scoped to the tenant.
  2. User logs in at NetDocs; redirected to POST /netdocs/oauth/callback with code.
  3. NetDocsAuthController exchanges code for access_token + refresh_token; persists to nd_tokens keyed by (tenant_id, cb_user_id).
  4. NetDocsTokenProvider is the single read path: Redis cache (60s TTL) → DB → refresh-and-store if expired. Refresh failures throw NetDocsTokenInvalidException (mirrors how GraphServiceTokenProvider handles AAD).
  5. Token revocation: webhook user.access_revoked clears both stores.

Database design

Postgres, owned by supernova-api, snake_case via EF Core's UseSnakeCaseNamingConvention() (matches existing convention from Supernova.Infrastructure/DependencyInjection.cs:69). All tables are tenant-scoped — tenant_id is the FK to the existing tenants table from the single-schema migration.

Schema

[Diagram]

nd_tokens

OAuth credentials and per-tenant webhook signing secret.

Column Type Notes
id bigserial PK
tenant_id bigint FK tenants(id). Tenant-scoped.
cb_user_id text nullable Auth0 user id. NULL when token_kind = 'app'.
token_kind text user for delegated OAuth, app for tenant-wide service account.
access_token_encrypted text AES-encrypted via EncryptionHelper (same pattern as Auth0 secrets).
refresh_token_encrypted text nullable NULL for app kind.
access_token_expires_at timestamptz Used to pre-emptively refresh.
scopes text Space-delimited granted scopes.
webhook_secret_encrypted text nullable Shared HMAC secret for verifying inbound webhooks. Tenant-wide.
created_at / updated_at timestamptz Standard audit columns.

Indexes:

nd_id_map

System of record for cross-system identity. Every Kafka event publish goes through this table to resolve CB-side IDs before fanning out.

Column Type Notes
id bigserial PK
tenant_id bigint FK
cb_entity_type text matter, client, document.
cb_entity_id text Stringified CB id (matters use uuid, documents use long).
nd_entity_type text workspace, client, document.
nd_envelope_id text nullable Set for documents; NetDocs document envelope id.
nd_workspace_id text nullable Set for matters and workspace-scoped documents.
nd_url text Stable deep link for UI.
nd_last_seen_at timestamptz Touched by every webhook + sync confirmation; drives drift checks.

Indexes:

nd_webhook_events

Append-only audit + replay store for everything NetDocs pushes us.

Column Type Notes
id bigserial PK
tenant_id bigint FK
nd_event_id text Idempotency key (NetDocs guarantees uniqueness per event).
event_type text document.created, document.updated, etc.
raw_payload jsonb Full body for replay/debug.
signature text The X-ND-Signature we verified, kept for audit.
status text receivedpublished (Kafka ack'd) → optional rejected/duplicate.
kafka_offset text nullable Set when published, lets us tie audit rows to consumer offsets.
error_message text nullable Populated on rejected.
attempt_count smallint Increments on retry.
received_at timestamptz
processed_at timestamptz nullable Set on terminal status transition.

Indexes:

Retention: 90 days hot, then archived to cold storage by a scheduled cleanup job.

nd_subscriptions

Tracks webhook subscriptions we own at NetDocs. Webhook subscriptions expire (typically every few days) so we need to renew them on a schedule.

Column Type Notes
id bigserial PK
tenant_id bigint FK
nd_subscription_id text Returned by NetDocs on creation.
resource text What we subscribed to (e.g., documents, workspaces).
expires_at timestamptz Renewal sweeper triggers ~1h before this.
status text active, renewing, expired.
created_at / updated_at timestamptz

Indexes:

nd_sync_outbox

CB → ND async sync (the Kafka centerbase.events consumer path) writes to this table inside the same DB transaction as the source event's offset commit, then a worker drains it. Lets us retry NetDocs failures without re-processing the Kafka message and without poisoning the consumer.

Column Type Notes
id bigserial PK
tenant_id bigint FK
cb_event_id text CB event id from centerbase.events. Idempotency key.
operation text create_workspace, update_workspace, upload_document, etc.
payload jsonb Normalized request body — derived from CB event, snapshot at enqueue time.
status text pendingin_flightsucceeded/failed/dead.
attempt_count smallint Capped at N (e.g., 8); rolls over to dead past that.
last_error text nullable
next_attempt_at timestamptz Exponential backoff base; worker picks WHERE status='pending' AND next_attempt_at <= now().
created_at timestamptz
completed_at timestamptz nullable

Indexes:

Conventions

Open questions