Convex Backend
Owlat uses Convex as its serverless backend, providing real-time subscriptions, ACID transactions, and TypeScript-first development.
Owlat uses Convex as its serverless backend, providing real-time subscriptions, ACID transactions, and TypeScript-first development.
Directory Structure
The backend is organized into domain folders (contacts/, campaigns/, topics/, mail/, automations/, inbox/, domains/, delivery/, webhooks/, …). Convex function paths mirror the folder layout: a query in contacts/contacts.ts is reached via api.contacts.contacts.<funcName>. Conventions for splitting files, naming, and where new code goes live in apps/api/convex/CONVENTIONS.md.
apps/api/convex/_generated/Auto-generated (never edit)api.d.tsAPI type definitionsdataModel.d.tsSchema typeslib/Shared utilitiesauthedFunctions.tsSecure-by-default builderssessionOrganization.tsSession, roles & permissionssendProviders/Provider-agnostic send pipelineses/SES sendingresend/Resend providermta/Self-hosted MTArouting.tsProvider routing & dispatchemailProviders/Domain identity & verificationsesIdentity.tsSES domain identitymtaIdentity.tsMTA domain identitydomainVerification.tsSPF/DKIM/DMARC checksschema/Per-domain table modulesschema.tsMerges domain tablesauth/BetterAuth config, API keys & onboardinghttp.tsHTTP route handlerscrons.tsScheduled jobs<domain>/API functions by domainSecure-by-default builders
Convex publishes every non-internal query / mutation / action on the deployment's public client API — any anonymous caller who knows the deployment URL can invoke them. Reaching for the bare builders from _generated/server therefore means "I have personally verified this is safe to expose unauthenticated", which is almost never true.
To make the safe path the default, every public function goes through one of the wrappers in apps/api/convex/lib/authedFunctions.ts instead of the raw builders. The apps/api/scripts/check-public-functions.sh lint (wired into bun run lint in apps/api) bans the bare query( / mutation( / action( builders everywhere except authedFunctions.ts, so a forgotten gate fails CI rather than silently shipping an open endpoint.
| Builder | Auth floor | Use for |
|---|---|---|
authedQuery | Authenticated organization member (active org + role) | Reactive reads for signed-in users |
authedMutation | Authenticated organization member (active org + role) | The default for writes; layer a requirePermission(...) for privileged ones |
authedAction | Authenticated organization member (enforced via the internal auth.membership.assertOrgMember query) | Actions that call external services |
adminMutation | Owner/admin member (organization:manage) | Admin-only writes — the role check is baked in |
ownerMutation | Owner role (organization:delete) | Destructive org-level writes even admins can't do |
adminQuery | Owner/admin member (organization:manage) | Sensitive reads (API keys, webhook secrets, shared inbox) |
authedIdentityMutation | Authenticated identity only (no org membership) | The narrow pre-org signup path only |
publicQuery / publicMutation / publicAction | None — explicit opt-out | Token-gated links, signature-verified webhooks, tracking pixels, the pre-auth setup page |
Every use of publicQuery / publicMutation / publicAction MUST carry a // public: <reason> comment at the call site — it makes "this is public on purpose" an explicit, greppable, lint-allowlisted choice rather than the default.
A separate lint, apps/api/scripts/check-permissions.sh, additionally requires every state-changing authedMutation / authedAction to make an explicit authorization decision (a role-bearing wrapper, an in-handler requirePermission(...), or an // authz: <reason> / // all-members: <reason> opt-out comment). See apps/api/convex/CONVENTIONS.md § Permissions.
Function Types
Queries (Read-only, Real-time)
Queries are reactive — clients automatically receive updates when data changes.
import { authedQuery } from './lib/authedFunctions';
import { getUserIdFromSession } from './lib/sessionOrganization';
import { v } from 'convex/values';
export const list = authedQuery({
args: { organizationId: v.string() },
handler: async (ctx, args) => {
await getUserIdFromSession(ctx); // the wrapper already rejects anonymous callers
return ctx.db
.query('contacts')
.withIndex('by_org', (q) => q.eq('organizationId', args.organizationId))
.collect();
},
});
Mutations (Read-write, Transactional)
Mutations are ACID — all changes succeed or fail together. authedMutation enforces an authenticated organization member before the handler runs.
import { authedMutation } from './lib/authedFunctions';
import { v } from 'convex/values';
export const create = authedMutation({
args: {
organizationId: v.string(),
email: v.string(),
firstName: v.optional(v.string()),
},
handler: async (ctx, args) => {
const contactId = await ctx.db.insert('contacts', {
...args,
source: 'api',
createdAt: Date.now(),
updatedAt: Date.now(),
});
return contactId;
},
});
Actions (External APIs, Side Effects)
Actions can call external services but don't have transactions. authedAction requires an authenticated organization member (enforced up front via the internal auth.membership.assertOrgMember query); per-permission/role checks still happen in the mutations/queries the action calls via ctx.runQuery / ctx.runMutation.
import { authedAction } from './lib/authedFunctions';
import { v } from 'convex/values';
export const sendEmail = authedAction({
args: {
to: v.string(),
subject: v.string(),
html: v.string(),
},
handler: async (ctx, args) => {
const provider = getEmailProvider();
return provider.sendEmail(args);
},
});
HTTP Actions (REST Endpoints)
HTTP actions handle external API requests. They run on a separate, separately-authenticated runtime, so they use the raw httpAction builder directly (the secure-by-default wrappers do not apply). An HTTP route that needs to touch session-gated data calls an internal* sibling.
import { httpAction } from './_generated/server';
export const handleWebhook = httpAction(async (ctx, request) => {
const body = await request.json();
// Process webhook
return new Response('OK', { status: 200 });
});
// In http.ts
http.route({
path: '/api/v1/webhook',
method: 'POST',
handler: handleWebhook,
});
Session-Based Functions
Owlat is single-organization-per-deployment: each deployment hosts exactly one BetterAuth organization (bootstrapped at setup, and auth/organization/create is disabled). Because there is no per-request org to pass, session helpers read the user's identity and role from the active session rather than from a request argument. assertSingletonOrgInvariant in lib/sessionOrganization.ts is the runtime defense — it confirms exactly one org exists and that the session's active org matches it (process-cached after the first hit).
import { authedQuery, authedMutation } from './lib/authedFunctions';
import { getUserIdFromSession, getMutationContext } from './lib/sessionOrganization';
import { v } from 'convex/values';
// Query using session context — `authedQuery` rejects anonymous callers
export const listFromSession = authedQuery({
args: {},
handler: async (ctx) => {
await getUserIdFromSession(ctx);
// Single org per deployment: no organizationId argument to thread through.
return ctx.db.query('contacts').collect();
},
});
// Mutation using session context — `authedMutation` enforces an authenticated org member
export const createFromSession = authedMutation({
args: { email: v.string() },
handler: async (ctx, args) => {
const { userId, role } = await getMutationContext(ctx);
// `userId` / `role` available for auditing and permission checks.
return ctx.db.insert('contacts', {
email: args.email,
source: 'api',
createdAt: Date.now(),
updatedAt: Date.now(),
});
},
});
getMutationContext(ctx) returns { userId, role } (type MutationSessionContext) — there is no organizationId in the return value.
Database Patterns
Always Use Indexes
// Good - uses index
const contacts = await ctx.db
.query('contacts')
.withIndex('by_org', (q) => q.eq('organizationId', organizationId))
.collect();
// Bad - full table scan
const contacts = await ctx.db
.query('contacts')
.filter((q) => q.eq(q.field('organizationId'), organizationId))
.collect();
Schema Definition
import { defineSchema, defineTable } from 'convex/server';
import { v } from 'convex/values';
export default defineSchema({
contacts: defineTable({
organizationId: v.string(),
email: v.string(),
firstName: v.optional(v.string()),
lastName: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_org', ['organizationId'])
.index('by_org_and_email', ['organizationId', 'email']),
});
Common Field Patterns
// ID references
organizationId: v.string();
contactId: v.id('contacts');
// Timestamps (always milliseconds)
createdAt: v.number();
updatedAt: v.number();
// Status enums
status: v.union(v.literal('draft'), v.literal('published'), v.literal('archived'));
// Optional with default
description: v.optional(v.string());
// JSON stored as string
filters: v.string(); // Parse with JSON.parse()
Permission System
Roles
owner- Full access
- Delete team
- Transfer ownership
admin- Full access
- Manage members
- Cannot manage owners
editor- Create & edit content
- No team management
Permissions are a typed Permission union (campaigns:send, campaigns:manage, contacts:manage, topics:manage, segments:manage, templates:manage, automations:manage, organization:manage, settings:manage, organization:delete, chat:participate, …) mapped to roles in lib/sessionOrganization.ts. Check a capability with hasPermission(role, '<scope>:<verb>') rather than testing roles directly.
Permission Checks
For a privileged write, take the mutation context and assert the specific capability with requirePermission(hasPermission(role, '<scope>:<verb>'), message):
import { authedMutation } from './lib/authedFunctions';
import { getMutationContext, requirePermission, hasPermission } from './lib/sessionOrganization';
export const deleteContact = authedMutation({
args: { contactId: v.id('contacts') },
handler: async (ctx, args) => {
const { role } = await getMutationContext(ctx);
requirePermission(
hasPermission(role, 'contacts:manage'),
'Only owners and admins can delete contacts',
);
await ctx.db.delete(args.contactId);
},
});
For writes that are always admin-only, prefer adminMutation — it bakes the organization:manage check into the wrapper, so no in-handler requirePermission is needed:
import { adminMutation } from './lib/authedFunctions';
export const purgeContacts = adminMutation({
args: {},
handler: async (ctx) => {
// Floor is "owner/admin"; reaching the body means the check passed.
},
});
requirePermission(hasPermission, message) takes a boolean (the result of hasPermission(...)) and an optional message — not a role and a permission string. The old isAdminRole / isOwnerRole helpers were removed; use hasPermission(role, '<scope>:<verb>').
Error Handling
// Throw descriptive errors
if (!contact) {
throw new Error('Contact not found');
}
if (!domain.verified) {
throw new Error(`Cannot send email: domain ${domain.name} is not verified`);
}
// Validation errors
if (!args.email.includes('@')) {
throw new Error('Invalid email address');
}
Scheduler (Background Jobs)
Internal targets are addressed through the generated internal object, which mirrors the domain-folder layout (internal.<domain>.<file>.<func>).
import { internal } from './_generated/api';
// Schedule the next automation step (e.g. after a delay step).
// Real target: apps/api/convex/automations/stepWalker.ts → executeStep
await ctx.scheduler.runAfter(delayMs, internal.automations.stepWalker.executeStep, {
automationRunId,
stepRunId,
});
// Kick off background work immediately (fire-and-forget).
// Real target: apps/api/convex/segments.ts → refreshSingleSegmentCount
await ctx.scheduler.runAfter(0, internal.segments.refreshSingleSegmentCount, {
segmentId,
});
For recurring jobs, register them in crons.ts rather than self-rescheduling.
File Storage
import { authedQuery, authedMutation } from './lib/authedFunctions';
import { v } from 'convex/values';
// Generate upload URL (for client upload)
export const generateUploadUrl = authedMutation({
args: {},
handler: async (ctx) => {
return ctx.storage.generateUploadUrl();
},
});
// Get file URL
export const getUrl = authedQuery({
args: { storageId: v.id('_storage') },
handler: async (ctx, args) => {
return ctx.storage.getUrl(args.storageId);
},
});
// Delete file
export const deleteFile = authedMutation({
args: { storageId: v.id('_storage') },
handler: async (ctx, args) => {
await ctx.storage.delete(args.storageId);
},
});
Key API Files
contacts/CRM contacts & identitiestopics/Topics & DOI flowscampaigns/Campaigns & schedulingemailTemplates/Template CRUDemailBlocks/Saved blockssegments.tsSegment filteringautomations/Trigger-based workflowstransactional/Transactional send APIdomains/Domains, DNS & warmingdelivery/Send pipeline & lifecyclewebhooks/Outbound webhooks & logsinbox/Shared inbox & threadingmail/SMTP/IMAP mailboxes & draftsemails.tsRendering & sendingFrontend Integration
In apps/web, reactive reads use the useConvexQuery composable and writes go through useBackendOperation (both auto-imported). Function references come from the generated api object and follow the domain-folder path.
// In a Vue component or composable
import { api } from '@owlat/api';
// Query (reactive) — returns { data, error, isLoading } refs
const { data: contacts } = useConvexQuery(api.contacts.contacts.list, {});
// Mutation — useBackendOperation normalizes errors and returns a `run()`
const { run: createContact } = useBackendOperation(api.contacts.contacts.create, {
label: 'Create contact',
});
await createContact({ email: 'user@example.com' });