Skip to main content
By default, Velt stores all collaboration data (comments, reactions, recordings, notifications, activity, attachments) in Velt’s managed backend. Self-hosting lets you split that storage so sensitive content and PII never leave your infrastructure:
  • Velt keeps the structural / non-PII data — IDs, document and organization references, locations and targets, statuses, timestamps, and relationships. This is what Velt needs to position pins, thread comments, drive real-time sync, and render the UI shell.
  • You keep the content / PII — comment text, user information, transcripts, attachment files, and any custom fields — inside your own database and/or file storage.
You enable this by registering one or more data providers (also called resolvers). A data provider is an object you supply that Velt calls at the right moments. For each data type, you implement specific methods (get, save, delete) to interact with your storage, and Velt Components automatically hydrate the data in the frontend by fetching from your providers. This approach gives you complete control and ownership of your data while maintaining all Velt collaboration features and real-time functionality.

How it works

Velt uses a strip-on-write, merge-on-read model:
  • On write (a comment is added, a recording is saved, …): Velt strips the PII out of the record, writes only the structural remainder to Velt’s database, and hands the stripped PII to your provider to persist.
  • On read (loading a document, receiving a real-time update, …): Velt calls your provider to fetch the PII back, then merges it into the structural record so the UI renders exactly as it would in a fully Velt-hosted setup.
For write requests (save, delete), the operation is performed on your database first. Only on a success response does Velt apply the change on its own servers. If the operation fails on your side, Velt does not change its own data and retries if you have configured retries.

Supported features at a glance

FeatureProvider keygetsavedeleteModel
UsersuserMaps userId → user object
CommentscommentStrip on write, merge on read
ReactionsreactionStrip on write, merge on read
AttachmentsattachmentFile storage only
RecordingsrecorderStrip on write + file storage modes
ActivityactivityAppend-only (no delete)
NotificationsnotificationRead-only enrichment (custom notifications only)
Anonymous usersanonymousUserresolve-by-emailMaps email → userId
Mix and match. You can self-host some features and let Velt host the rest. If you don’t register a provider for a feature, that feature stays fully Velt-hosted.
Email notifications via Velt’s SendGrid integration are not available when you self-host comment content. Since the content lives on your infrastructure, Velt cannot construct and send emails via the SendGrid integration. Instead, use Webhooks to receive events (e.g., mentions, replies), fetch the relevant comment/notification content from your database, and send emails from your own email provider.

Registering data providers

All providers are registered through a single method, setDataProviders(), which accepts a VeltDataProvider object. Every key is optional, so you only pass the providers you actually want to self-host.
  • Ensure that the data providers are set prior to calling the identify method.
  • Self-hosting is currently only compatible with the setDocuments method.
  • Every data provider method must return the correct statusCode (e.g., 200 for success, 500 for errors) and success boolean in the response object. This ensures proper error handling and retries.
const dataProviders = {
  comment:       commentDataProvider,
  reaction:      reactionDataProvider,
  recorder:      recorderDataProvider,
  notification:  notificationDataProvider,
  activity:      activityDataProvider,
  attachment:    attachmentDataProvider,
  anonymousUser: anonymousUserDataProvider,
  user:          userDataProvider,
};

<VeltProvider
  apiKey='YOUR_API_KEY'
  dataProviders={dataProviders}
>
</VeltProvider>
The anonymous-user provider also has a standalone setter, setAnonymousUserDataProvider(), if you prefer to register it separately.

Two ways to implement: callback or URL

Each provider operation (get / save / delete) can be satisfied in one of two ways:
  • Callback function — implement the method directly in your frontend code.
  • Config URL — point Velt at an HTTP endpoint (config.getConfig.url, config.saveConfig.url, config.deleteConfig.url) and Velt makes the request for you.
A method is considered “provided” if it has either a function or a corresponding config URL. If a required operation has neither, the provider is rejected and the feature falls back to Velt-hosted behavior. You can mix styles per method.
Function basedEndpoint based
Best forComplex setups requiring middleware logic, dynamic headers, or transformation before sendingStandard REST APIs where you just need to pass the request “as-is” to the backend
ImplementationYou write the fetch() or axios codeYou provide the endpoint url, static or async headers, and optional credentials
FlexibilityHighMedium
SpeedMediumHigh
For URL mode, build your endpoints with Velt’s official server SDKs — Node.js and Python — which ship request parsers, payload types, and response helpers for every resolver operation. You can self-host on any infrastructure (AWS, GCP, Azure, custom) as long as the request/response shapes match.

Async headers and credentials

On any endpoint config (getConfig, saveConfig, deleteConfig) of any provider, headers can be an async function that is resolved on every request — including each retry — so a short-lived per-request token stays fresh. Set credentials: 'include' to forward cookies for cross-origin cookie/session auth. When credentials is unset, fetch() keeps its default behavior.
const getFreshToken = async () => {
  const response = await fetch('/api/velt/resolver-token');
  const { token } = await response.json();
  return token;
};

const commentResolverConfig = {
  saveConfig: {
    url: 'https://your-backend.com/api/velt/comments/save',
    headers: async () => ({ Authorization: `Bearer ${await getFreshToken()}` }),
    credentials: 'include'
  }
};

const commentDataProvider = {
  config: commentResolverConfig
};

<VeltProvider
  apiKey='YOUR_API_KEY'
  dataProviders={{ comment: commentDataProvider }}
>
</VeltProvider>

The provider contract

All providers share a common config object and a common response shape.

config (ResolverConfig)

Type: ResolverConfig.
FieldTypePurpose
resolveTimeoutnumber (ms)Max time Velt waits for a read before giving up. Default: 60,000 ms (1 minute).
getRetryConfigRetryConfigRetry policy for read (get) calls.
saveRetryConfigRetryConfigRetry policy for save calls.
deleteRetryConfigRetryConfigRetry policy for delete calls.
getConfigResolverEndpointConfigHTTP endpoint for reads (URL style).
saveConfigResolverEndpointConfigHTTP endpoint for saves (URL style).
deleteConfigResolverEndpointConfigHTTP endpoint for deletes (URL style).
additionalSaveEventsAdditionalSaveEventConfig[]Comment resolver only. Opt-in non-core comment lifecycle events for the existing save endpoint.
additionalFieldsstring[]Custom fields to copy into your DB while keeping them in Velt’s. See Excluding & extending fields.
fieldsToRemovestring[]Custom fields to move out of Velt’s DB into yours. See Excluding & extending fields.
additionalSaveEventsCommentResolverSaveEvent[]Comment resolver only. Additional non-core comment save events to send to your save endpoint.
RetryConfig supports retryCount (how many times to retry on failure), retryDelay (delay between retries in ms), and revertOnFailure (roll back Velt’s optimistic local change if your call fails).
The notification provider uses a slightly reduced config, NotificationResolverConfig, with only resolveTimeout, getRetryConfig, deleteRetryConfig, getConfig, and deleteConfig. It does not support fieldsToRemove, additionalFields, or additionalSaveEvents.

Response shape (ResolverResponse)

Every callback and every HTTP endpoint must return / respond with the ResolverResponse<T> shape:
interface ResolverResponse<T> {
  data?:       T;        // the resolved payload (for `get`); omit for save/delete
  success:     boolean;
  statusCode:  number;   // 200 = success; anything else is treated as a failure
  message?:    string;
  timestamp?:  number;
}
statusCode: 200 means success. Any other status triggers Velt’s error handling and, if revertOnFailure is set, rolls back the optimistic local update.

HTTP endpoint contract (URL style)

When you use a config URL, Velt issues:
  • A POST to your url
  • Content-Type: application/json plus any headers you supplied
  • A request body equal to the JSON request object for that operation (the same object your callback would receive)
Your endpoint should respond with HTTP 2xx and a JSON body of { "data": … } for reads (the data is unwrapped into the resolver response). For saves and deletes, a 2xx is enough.
Attachment and recording uploads are the exception — they use multipart/form-data rather than JSON, because a binary file is involved. See Attachment & recording storage.

Metadata your provider receives

Every get / save / delete request includes a metadata object (BaseMetadata) describing the organization/document context, so you can scope the data correctly in your backend. Velt sends you a client-facing subset: it maps Velt’s internal IDs back to the IDs you originally passed to Velt and removes purely internal bookkeeping fields.
FieldMeaning
apiKeyYour Velt API key.
documentIdYour document ID (the one you supplied to Velt).
organizationIdYour organization ID (the one you supplied to Velt).
folderIdFolder ID, when present.
documentMetadataYour document metadata, when present.
sdkVersionThe SDK version that produced the event.
documentId and organizationId are your IDs, not Velt’s internal hashed IDs. Use them directly as keys in your own database. Internal bookkeeping fields (e.g., clientDocumentId, clientOrganizationId, veltFolderId, pageInfo) are not sent to your provider.
Delete requests are minimized further. For delete operations, Velt sends only the bare minimum needed to locate the record:
{ apiKey, documentId, organizationId, folderId? }   // folderId only when present

Per-feature reference

Each provider has its own page with the full interface, request types, and both callback and endpoint examples. PII payload types are documented in Data models; the field-by-field inventory of what gets stored where is in Complete field inventory.
ProviderMethodsPII payloadNotes
commentget / save / deletePartialCommentAnnotationStrip on write, merge on read.
reactionget / save / deletePartialReactionAnnotationStrip on write, merge on read.
recorderget / save / delete (+ storage)PartialRecorderAnnotationBring-your-own file storage via storage.
attachmentsave / deletebinary file → { url }File storage only; no get.
activityget / savePartialActivityRecordAppend-only (no delete). May contain PII from other providers.
notificationget / deletePartialNotificationRead-only enrichment for custom-source notifications only.
anonymousUserresolveUserIdsByEmail{ email: userId } (transient)Maps email → userId when @mentioning unknown users.
usergetRecord<userId, User>Resolves user objects for every other provider.

Complete field inventory

For an exhaustive, ground-truthed breakdown of every persisted field — with types, example values, descriptions, and per-feature strip rules — covering both sides of the split (Velt’s DB and your DB) and expanding every nested structural object into its own sub-table, see the dedicated reference:

Complete Field Inventory

Every persisted field for comments, reactions, recordings, notifications, activity, and attachments — Velt’s DB vs. your DB, with types, examples, and notes.
A few rules apply across all features:
  • User objects are reduced to { userId } in Velt’s DB for most features (comments, recordings, activity). The full user object (name, email, avatar, …) lives in your backend, resolved via your user provider. It is the data source that all the other resolvers rely on when they strip user objects down to { userId }.
  • The client-facing metadata (see above) accompanies every PII payload you store.
  • Velt sets an internal flag on the structural record (e.g., isCommentResolverUsed, isReactionResolverUsed, isRecorderResolverUsed, isNotificationResolverUsed, isActivityResolverUsed) so the UI knows to wait for resolver data. You don’t need to store these.

Attachment & recording storage

Attachments and recordings involve binary files, not just JSON records. To keep them on your own infrastructure, provide a storage provider: Velt hands you the raw File, you upload it to your bucket (S3, GCS, Azure Blob, …), and return { url }. Velt never touches the bytes. Implement it as an AttachmentDataProvidersave / delete callbacks, or saveConfig / deleteConfig URLs.

Storage scopes

Comment attachments and recording files are configured separately, so you can route them to different destinations:
ScopeConfigured viaUsed for
Comment attachmentsdataProviders.attachmentFiles attached to comments.
Recording filesdataProviders.recorder.storageVideo/audio recording files.
You can point both scopes at the same bucket or at different ones. The example below self-hosts both:
await Velt.setDataProviders({
  // Comment attachments → YOUR storage
  attachment: {
    async save({ file, name, metadata }) {
      const url = await myBucket.put(file, name);
      return { data: { url }, success: true, statusCode: 200 };
    },
    async delete({ attachmentId, metadata }) {
      await myBucket.remove(attachmentId);
      return { success: true, statusCode: 200 };
    },
  },

  // Recording metadata → YOUR DB, and recording FILES → YOUR storage
  recorder: {
    async get(req)    { /* … */ },
    async save(req)   { /* … */ },
    async delete(req) { /* … */ },
    storage: {
      async save({ file, name }) {
        const url = await myBucket.put(file, name);
        return { data: { url }, success: true, statusCode: 200 };
      },
      async delete({ attachmentId }) {
        await myBucket.remove(attachmentId);
        return { success: true, statusCode: 200 };
      },
    },
  },
});

The upload contract (storage providers)

Storage providers are the one place the contract differs from the JSON model, because a binary file is involved. Callback style — your save receives a ResolverAttachment:
interface ResolverAttachment {
  attachmentId: number;
  file: File;                          // the raw bytes
  name?: string;
  mimeType?: string;
  metadata?: AttachmentResolverMetadata;
}
// You return: { data: { url }, success: true, statusCode: 200 }
URL style — Velt POSTs multipart/form-data (not JSON) to your saveConfig.url:
  • a file part containing the raw file, and
  • a request part containing JSON: { attachment: { attachmentId, name, mimeType }, metadata, event }.
Your endpoint stores the file and responds { "data": { "url": "https://…" } }. (Velt deliberately omits Content-Type so the browser sets the correct multipart boundary.) Deletes (both scopes) send the minimized metadata { apiKey, documentId, organizationId, folderId? } plus the attachmentId, so you can remove the right file.

How recording files are uploaded

When a recording storage provider (recorder.storage) is set, Velt uploads the entire recording to your storage once — after the recording stops and the annotation is saved — and then patches the returned file URL onto the annotation. Velt also skips its own server-side encoding/transcription post-processing in this case; you own those files end to end. See Recordings and Attachments for full examples.

Excluding & extending fields

Velt already strips its built-in PII automatically (comment text, user info, transcripts, …) — you don’t configure that. fieldsToRemove and additionalFields are for your own custom fields that you attach to an annotation and want handled a particular way. Both are set on the provider’s config.
const commentDataProvider = {
  config: {
    fieldsToRemove:   ['internalTicketId', 'priorityScore'],   // move OUT of Velt's DB
    additionalFields: ['teamName'],                            // copy to your DB, keep in Velt's
  },
  async get(req)    { /* … */ },
  async save(req)   { /* … */ },
  async delete(req) { /* … */ },
};
  • fieldsToRemove — data sovereignty (move). Each listed field is copied into the payload sent to your backend and deleted from Velt’s database. On read, Velt restores the field from your backend and merges it back into the record. Use this when a custom field must not be stored by Velt at all.
  • additionalFields — replication (copy). Each listed field is deep-copied into the payload sent to your backend, but kept in Velt’s database. On read there is nothing to merge (the field is already present). Use this when you want a copy of a field in your own backend (e.g., for analytics or search) without removing it from Velt.
AspectfieldsToRemoveadditionalFields
Effect on Velt’s DBRemovedKept
Sent to your backendYes (moved)Yes (copied)
Merged back on readYes (restored from you)No (already in Velt’s DB)
Falsy values (0, "", false)Copied only if truthyPreserved
Processing orderFirstSecond
If a field is in both listsfieldsToRemove wins (removed first)

Where these are supported

ProviderfieldsToRemoveadditionalFields
comment
reaction
recorder
activity✅ (for custom activity types)
notification
fieldsToRemove is only for your own custom fields. Never list a field that Velt relies on to query, scope, position, sync, or render an annotation. If you remove a structural field, Velt can no longer find or place the annotation, and comments/reactions/recordings will silently fail to load, appear in the wrong place, or break filtering and visibility.In particular, do not put any of these in fieldsToRemove:
  • metadata and its sub-fields — apiKey, documentId, organizationId, folderId, documentMetadata.
  • Identifiers / keysannotationId, id, commentId, annotationNumber, targetEntityId, targetSubEntityId, notificationId, commentAnnotationId.
  • Location & positioninglocation, locationId, context, contextId, position, positionX/positionY, targetElement, targetElementId, targetTextRange, pageInfo.
  • Query / filter / state fieldsstatus, priority, type, commentType, featureType, actionType, from, assignedTo, resolvedByUserId, timestamp, createdAt, lastUpdated, forYou, notificationSource, targetAnnotationId.
  • Resolver flagsisCommentResolverUsed, isReactionResolverUsed, isRecorderResolverUsed, isNotificationResolverUsed, isActivityResolverUsed.
If in doubt, prefer additionalFields (which keeps the field in Velt’s DB) so you never accidentally break querying.

Worked example

Store a custom priorityScore only in your own database:
await Velt.setDataProviders({
  comment: {
    config: { fieldsToRemove: ['priorityScore'] },
    async get({ commentAnnotationIds, documentIds, organizationId }) {
      const rows = await db.comments.find({ organizationId, documentIds, commentAnnotationIds });
      const data = {};
      for (const r of rows) {
        // r.partial already includes priorityScore — Velt merges it back in
        data[r.annotationId] = r.partial;
      }
      return { data, success: true, statusCode: 200 };
    },
    async save({ commentAnnotation }) {
      // each PartialCommentAnnotation here includes priorityScore
      await db.comments.upsertMany(commentAnnotation);
      return { success: true, statusCode: 200 };
    },
    async delete({ commentAnnotationId }) {
      await db.comments.remove(commentAnnotationId);
      return { success: true, statusCode: 200 };
    },
  },
});
After this, priorityScore is never written to Velt’s database — it lives only in your db.comments and is merged back into the comment whenever Velt loads it.

Operational behavior

  • Timeouts. Reads are wrapped in a timeout (default 1 minute, configurable via config.resolveTimeout). On timeout, Velt proceeds gracefully and renders whatever structural data it already has — it never blocks the UI indefinitely.
  • Retries. retryCount / retryDelay let you retry transient failures. Combine with revertOnFailure to roll back Velt’s optimistic local change if your save/delete ultimately fails.
  • Degrade, don’t drop. If resolution fails, Velt keeps the structural record and renders it without the PII rather than dropping it. Your data is never lost because a resolver call failed.
  • Not every update calls save. Velt only sends a save when the PII actually changed and the action maps to a meaningful resolver event (add/update/delete of the comment body, etc.). Pure structural changes (status, priority, assignment) are handled by Velt and won’t necessarily call your save.
  • Custom notifications only. The notification resolver applies only to notifications whose source is custom. Standard comment/recording notifications are resolved through their own feature providers. See Notifications.
  • Cross-organization notifications. “For You” notifications can come from organizations other than the active one. Velt calls your notification/comment get endpoints with each entry’s own organizationId — make sure your endpoints honor the organizationId in the request rather than assuming the current org.
  • Activity is multi-feature. A single activity record may contain PII owned by several providers. Velt resolves them in sequence (user → comment → reaction → recorder → activity), so register all relevant providers for activity feeds to render fully.

Backend implementation

For endpoint (URL) mode, Velt provides official server SDKs that handle request parsing, payload types, and response formatting for every resolver operation:

Node SDK

Self-hosting backend with built-in MongoDB + AWS S3 support, or call Velt’s REST APIs directly.

Python SDK

Server-side SDK for self-hosting backend implementation and REST API access.

End-to-end example

A minimal comment provider that moves a custom field out of Velt’s DB, with both callback and URL styles. See the per-feature pages above for recorder, notification, activity, attachment, and anonymousUser examples.
// Callback style
await Velt.setDataProviders({
  comment: {
    config: { fieldsToRemove: ['internalTicketId'] },
    async get({ organizationId, documentIds, commentAnnotationIds }) {
      const data = await myApi.getComments({ organizationId, documentIds, commentAnnotationIds });
      return { data, success: true, statusCode: 200 };
    },
    async save({ commentAnnotation, metadata, event }) {
      await myApi.saveComments(commentAnnotation, metadata, event);
      return { success: true, statusCode: 200 };
    },
    async delete({ commentAnnotationId, metadata }) {
      await myApi.deleteComment(commentAnnotationId, metadata);
      return { success: true, statusCode: 200 };
    },
  },
});

// URL style — same shape, expressed as endpoints
await Velt.setDataProviders({
  comment: {
    config: {
      fieldsToRemove: ['internalTicketId'],
      getConfig:    { url: 'https://api.example.com/velt/comments/get' },
      saveConfig:   { url: 'https://api.example.com/velt/comments/save' },
      deleteConfig: { url: 'https://api.example.com/velt/comments/delete' },
    },
  },
});

Debugging

You can subscribe to dataProvider events to monitor and debug get, save, and delete operations across all providers. The event includes a moduleName field that identifies which module triggered the resolver call, helping you trace data provider requests. You can also use the Velt Chrome DevTools extension to inspect and debug your Velt implementation.
import { useVeltClient } from '@veltdev/react';

const { client } = useVeltClient();

useEffect(() => {
  if (!client) return;

  const subscription = client.on('dataProvider').subscribe((event) => {
    console.log('Data Provider Event:', event);
    console.log('Module Name:', event.moduleName);
  });

  return () => subscription?.unsubscribe();
}, [client]);