API Patterns
Doc Status: Good | ✓ Clear summary | ✓ Easy to read | ✓ Matches code | ✓ Good structure | ✓ Professional look | ✓ Visual components
Two Distinct API Surfaces
| Surface | Tool | Used for |
|---|
| App data | Convex functions | Students, courses, attendance, payments |
| External 3rd-party APIs | Vendor SDK or fetch from a Convex action | Lark, 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: '[email protected]', centerId });
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
| Type | File | Use for | Can touch ctx.db |
|---|
query | .ts | Read-only, auto-subscribed | No |
mutation | .ts | Read + write, transactional | Yes |
internalMutation | .ts | Server-only, from actions/crons | Yes |
action | .ts | HTTP / external APIs | No (use runMutation) |
httpAction | .ts | CF Worker → Convex entry | Yes |
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