API Patterns

Doc Status: Good | ✓ Clear summary | ✓ Easy to read | ✓ Matches code | ✓ Good structure | ✓ Professional look | ✓ Visual components

Two Distinct API Surfaces

SurfaceToolUsed for
App dataConvex functionsStudents, courses, attendance, payments
External 3rd-party APIsVendor SDK or fetch from a Convex actionLark, Meta CAPI, ZNS

App Data — Convex

Query from Client

'use client';
import { useQuery } from 'convex/react';
import { api } from '@packages/backend/convex/_generated/api';

export function StudentRow({ id }: { id: Id<'students'> }) {
  const student = useQuery(api.students.get, { id });
  if (student === undefined) return <Skeleton />;
  if (student === null) return null;
  return <span>{student.fullName}</span>;
}
useQuery auto-subscribes — UI updates when data changes. No manual cache invalidation, no polling.

Mutation from Client

'use client';
import { useMutation } from 'convex/react';

const createStudent = useMutation(api.students.create);

await createStudent({ fullName: 'Nguyen Van A', email: 'a@example.com', centerId });

Pagination

const { results, status, loadMore } = usePaginatedQuery(
  api.students.listByCenter,
  { centerId },
  { initialNumItems: 20 }
);

External APIs — Convex Action

External vendors (Lark, Meta CAPI, ZNS) are called from a Convex action using the vendor’s own SDK or fetch:
// packages/backend/convex/students.ts
'use node';
import { action } from './_generated/server';
import { v } from 'convex/values';

export const syncFromLark = action({
  args: { recordId: v.string() },
  handler: async (ctx, { recordId }) => {
    const res = await fetch(`https://open.larksuite.com/open-apis/bitable/v1/.../${recordId}`, {
      headers: { Authorization: `Bearer ${process.env.LARK_TOKEN}` },
    });
    if (!res.ok) throw new Error(`LARK_FETCH_FAILED: ${res.status}`);
    return await res.json();
  },
});

Inbound Webhooks — CF Worker → Convex

CF Workers are HTTP entry points only — they verify the signature and forward to a Convex httpAction. They do not call the external API.
// apps/lark-sync/src/index.ts
app.post('/webhook/lark', async (c) => {
  // 1. Verify signature
  const signature = c.req.header('x-lark-signature');
  if (!verifyLarkSignature(signature, await c.req.text(), env.LARK_WEBHOOK_SECRET)) {
    return c.json({ error: 'Invalid signature' }, 401);
  }

  // 2. Parse payload
  const body = await c.req.json();

  // 3. Forward to Convex — NO business logic here
  const convexUrl = `https://${env.CONVEX_DEPLOYMENT}.convex.cloud/api/httpaction/lark/sync`;
  await fetch(convexUrl, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${env.CONVEX_DEPLOY_KEY}`,
    },
    body: JSON.stringify(body),
  });

  return c.json({ ok: true });
});
// packages/backend/convex/http.ts
export const sync = httpAction({
  args: {
    table_id: v.string(),
    record_id: v.string(),
    fields: v.record(v.string(), v.unknown()),
  },
  handler: async (ctx, { table_id, record_id, fields }) => {
    // Business logic lives HERE in Convex
    return { ok: true };
  },
});

Function Types Reference

TypeFileUse forCan touch ctx.db
query.tsRead-only, auto-subscribedNo
mutation.tsRead + write, transactionalYes
internalMutation.tsServer-only, from actions/cronsYes
action.tsHTTP / external APIsNo (use runMutation)
httpAction.tsCF Worker → Convex entryYes

Forbidden

  • Raw fetch / axios for app data — use useQuery / useMutation from convex/react
  • TanStack Query wrapping Convex — use useQuery directly
  • Business logic in CF Workers — keep in Convex httpAction
  • Calling external APIs directly from CF Workers — use Convex action