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 definitions
dataModel.d.tsSchema types
lib/Shared utilities
authedFunctions.tsSecure-by-default builders
sessionOrganization.tsSession, roles & permissions
sendProviders/Provider-agnostic send pipeline
ses/SES sending
resend/Resend provider
mta/Self-hosted MTA
routing.tsProvider routing & dispatch
emailProviders/Domain identity & verification
sesIdentity.tsSES domain identity
mtaIdentity.tsMTA domain identity
domainVerification.tsSPF/DKIM/DMARC checks
schema/Per-domain table modules
schema.tsMerges domain tables
auth/BetterAuth config, API keys & onboarding
http.tsHTTP route handlers
crons.tsScheduled jobs
<domain>/API functions by domain

Secure-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.

BuilderAuth floorUse for
authedQueryAuthenticated organization member (active org + role)Reactive reads for signed-in users
authedMutationAuthenticated organization member (active org + role)The default for writes; layer a requirePermission(...) for privileged ones
authedActionAuthenticated organization member (enforced via the internal auth.membership.assertOrgMember query)Actions that call external services
adminMutationOwner/admin member (organization:manage)Admin-only writes — the role check is baked in
ownerMutationOwner role (organization:delete)Destructive org-level writes even admins can't do
adminQueryOwner/admin member (organization:manage)Sensitive reads (API keys, webhook secrets, shared inbox)
authedIdentityMutationAuthenticated identity only (no org membership)The narrow pre-org signup path only
publicQuery / publicMutation / publicActionNone — explicit opt-outToken-gated links, signature-verified webhooks, tracking pixels, the pre-auth setup page
Public functions need a reason

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 & identities
topics/Topics & DOI flows
campaigns/Campaigns & scheduling
emailTemplates/Template CRUD
emailBlocks/Saved blocks
segments.tsSegment filtering
automations/Trigger-based workflows
transactional/Transactional send API
domains/Domains, DNS & warming
delivery/Send pipeline & lifecycle
webhooks/Outbound webhooks & logs
inbox/Shared inbox & threading
mail/SMTP/IMAP mailboxes & drafts
emails.tsRendering & sending

Frontend 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' });