External APIs
Doc Status: Good | ✓ Clear summary | ✓ Easy to read | ~ Matches code | ✓ Good structure | ✓ Professional look | ✓ Visual components
| Check | Status | What Was Fixed |
|---|
| Matches code | ~ | CF Worker → httpAction pattern shown is example code. Actual lark/sync.ts not yet implemented. Phiếu form actions (submitInfoForm, etc.) are verified implemented in students.ts. |
External 3rd-party APIs (Lark, Meta CAPI, ZNS) are called from Convex action functions. There is no shared OpenAPI contract — each vendor brings its own SDK or you call the HTTP API with fetch.
| Vendor | Direction | How |
|---|
| Lark Base | Outbound + Inbound | fetch from Convex action |
| Meta CAPI | Outbound (events) | fetch from Convex action |
| ZNS | Outbound (notifications) | Vendor SDK from Convex action |
| Lark webhooks | Inbound | CF Worker → Convex httpAction |
| Meta webhooks | Inbound | CF Worker → Convex httpAction |
Outbound — Convex Action
External vendors are called from Convex action using fetch or a vendor SDK:
// packages/backend/convex/students.ts
'use node';
import { action } from './_generated/server';
import { v } from 'convex/values';
export const appendRow = action({
args: {
tableId: v.string(),
fields: v.record(v.string(), v.unknown()),
},
handler: async (_ctx, { tableId, fields }) => {
const res = await fetch(
`https://open.larksuite.com/open-apis/bitable/v1/apps/.../tables/${tableId}/records`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.LARK_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ fields }),
}
);
if (!res.ok) throw new Error(`LARK_APPEND_FAILED: ${res.status}`);
return await res.json();
},
});
Inbound — CF Worker → httpAction
CF Workers are HTTP entry points only — they verify signatures and forward the raw payload to a Convex httpAction. No business logic lives in CF Workers.
// apps/lark-sync/src/index.ts
app.post('/webhook/lark', async (c) => {
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);
}
const body = await c.req.json();
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 });
});
The Convex httpAction handles business logic:
// 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 here — write to Convex DB
return { ok: true };
},
});
SOURCE vs MIRROR Tables
Every Convex table synced with a Lark Bitable has a sync role:
| Role | Authoritative source | Write direction | Convex field for Lark ID |
|---|
| SOURCE | Convex | Convex → Lark (push) | larkRecordId: v.string() |
| MIRROR | Lark | Lark → Convex (webhook / cron) | larkSourceId: v.string() |
The 5 public enrollment forms use a dual-store pattern:
- Submission logged to
formSubmissions table in Convex first (durable record)
- POSTed to Lark workflow webhook
- Row patched to
sent or failed based on Lark response
This means Lark outages don’t lose submissions. Admins can list failed submissions and retry.
// packages/backend/convex/students.ts
'use node';
import { action } from './_generated/server';
export const submitInfoForm = action({
args: {
studentName: v.string(),
parentName: v.string(),
parentPhone: v.string(),
// ...
},
handler: async (ctx, args) => {
// 1. Log to Convex first
const submissionId = await ctx.runMutation(internal.formSubmissions.log, {
formType: 'student-info',
payload: { formType: 'student-info', submittedAt: new Date().toISOString(), ...args },
contactName: args.parentName,
contactPhone: args.parentPhone,
});
// 2. POST to Lark webhook
const webhookUrl = process.env.LARK_FORM_WEBHOOK_URL;
const response = await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
formType: 'student-info',
submittedAt: new Date().toISOString(),
...args,
}),
});
// 3. Patch status
if (response.ok) {
await ctx.runMutation(internal.formSubmissions.markSent, { submissionId });
} else {
await ctx.runMutation(internal.formSubmissions.markFailed, {
submissionId,
error: `Lark webhook ${response.status}`,
});
}
return { ok: true };
},
});
Admins can view submissions via formSubmissions.list (paginated, filterable by formType and larkStatus), and retry failures via formSubmissions.markForRetry.
| Form route | Action | formType value |
|---|
/phieu-thong-tin-hoc-vien | submitInfoForm | student-info |
/phieu-nghi-phep | submitLeaveRequest | leave-request |
/phieu-chuyen-nhuong | submitTransferRequest | transfer |
/phieu-bao-luu | submitHoldRequest | hold |
/phieu-chuyen-doi | submitChangeRequest | change |
All POST to the same LARK_FORM_WEBHOOK_URL. The Lark workflow discriminates by formType.