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

apps/api/convex/
├── _generated/           # Auto-generated (never edit)
│   ├── api.d.ts          # API type definitions
│   └── dataModel.d.ts    # Schema types
├── lib/                  # Shared utilities
│   ├── emailProviders/   # Email provider implementations
│   │   ├── ses.ts        # SES email sending provider
│   │   ├── sesIdentity.ts # SES domain identity management
│   │   ├── resend.ts     # Resend email provider
│   │   ├── domainVerification.ts # Domain validation helpers
│   │   └── types.ts      # Provider interfaces
│   ├── permissions.ts    # Role-based access control
│   ├── sessionOrganization.ts  # Session helpers
│   └── waitlist.ts       # Waitlist feature flag helpers
├── schema.ts             # Database schema
├── auth.ts               # BetterAuth configuration
├── http.ts               # HTTP route handlers
├── sesActions.ts         # SES domain registration actions
└── *.ts                  # API functions by domain

Function Types

Queries (Read-only, Real-time)

Queries are reactive - clients automatically receive updates when data changes.

import { query } from './_generated/server';
import { v } from 'convex/values';

export const list = query({
    args: { organizationId: v.string() },
    handler: async (ctx, args) => {
        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.

import { mutation } from './_generated/server';
import { v } from 'convex/values';

export const create = mutation({
    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.

import { action } from './_generated/server';
import { v } from 'convex/values';

export const sendEmail = action({
    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.

import { httpAction } from 'convex/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

To avoid passing organizationId in every request, use session-based helpers:

import { getTeamIdFromSession, getMutationContext } from './lib/sessionOrganization';

// Query using session context
export const listFromSession = query({
    args: {},
    handler: async (ctx) => {
        const organizationId = await getTeamIdFromSession(ctx);
        return ctx.db
            .query('contacts')
            .withIndex('by_org', (q) => q.eq('organizationId', organizationId))
            .collect();
    },
});

// Mutation using session context
export const createFromSession = mutation({
    args: { email: v.string() },
    handler: async (ctx, args) => {
        const { teamId: organizationId, userId, role } = await getMutationContext(ctx);

        // Check permissions if needed
        if (!isAdminRole(role)) {
            throw new Error('Admin access required');
        }

        return ctx.db.insert('contacts', {
            organizationId,
            email: args.email,
            source: 'api',
            createdAt: Date.now(),
            updatedAt: Date.now(),
        });
    },
});

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_team_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, can delete team
  • admin - Full access, cannot delete team
  • editor - Limited access, cannot manage team settings

Permission Checks

import { isAdminRole, requirePermission } from './lib/permissions';

export const deleteContact = mutation({
    args: { contactId: v.id('contacts') },
    handler: async (ctx, args) => {
        const { teamId: organizationId, role } = await getMutationContext(ctx);

        // Check admin permission
        requirePermission(role, 'admin');

        await ctx.db.delete(args.contactId);
    },
});

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)

import { internal } from './_generated/api';

// Schedule a job to run later
await ctx.scheduler.runAfter(
    60000, // 1 minute
    internal.automationStepExecutor.executeStep,
    { runId, stepIndex }
);

// Schedule at a specific time
await ctx.scheduler.runAt(
    scheduledAt, // timestamp
    internal.campaigns.sendBatch,
    { campaignId }
);

File Storage

// Generate upload URL (for client upload)
export const generateUploadUrl = mutation({
    handler: async (ctx) => {
        return ctx.storage.generateUploadUrl();
    },
});

// Get file URL
export const getUrl = query({
    args: { storageId: v.id('_storage') },
    handler: async (ctx, args) => {
        return ctx.storage.getUrl(args.storageId);
    },
});

// Delete file
export const deleteFile = mutation({
    args: { storageId: v.id('_storage') },
    handler: async (ctx, args) => {
        await ctx.storage.delete(args.storageId);
    },
});

Key API Files

FilePurpose
contacts.tsContact CRUD operations
mailingLists.tsMailing list management
campaigns.tsCampaign creation and sending
emailTemplates.tsTemplate CRUD
emailBlocks.tsSaved block management
segments.tsSegment filtering
automations.tsWorkflow management
transactionalApi.tsAPI email sending
domains.tsDomain CRUD and type definitions
sesActions.tsSES domain registration/deletion
dnsVerification.tsDNS record verification action
dnsVerificationQueries.tsInternal mutations for domain updates
organizationSettings.tsOrganization settings
emails.tsEmail rendering and sending
waitlist.tsWaitlist queries and mutations

Frontend Integration

// In Vue component
import { useConvexQuery, useConvexMutation } from '~/composables/convex';
import { api } from '@owlat/api/convex/_generated/api';

// Query (reactive)
const contacts = useConvexQuery(api.contacts.listFromSession, {});

// Mutation
const createContact = useConvexMutation(api.contacts.createFromSession);
await createContact({ email: 'user@example.com' });