External APIs

Doc Status: Good | ✓ Clear summary | ✓ Easy to read | ~ Matches code | ✓ Good structure | ✓ Professional look | ✓ Visual components
CheckStatusWhat 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.
VendorDirectionHow
Lark BaseOutbound + Inboundfetch from Convex action
Meta CAPIOutbound (events)fetch from Convex action
ZNSOutbound (notifications)Vendor SDK from Convex action
Lark webhooksInboundCF Worker → Convex httpAction
Meta webhooksInboundCF 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:
RoleAuthoritative sourceWrite directionConvex field for Lark ID
SOURCEConvexConvex → Lark (push)larkRecordId: v.string()
MIRRORLarkLark → Convex (webhook / cron)larkSourceId: v.string()

Phiếu Forms — Audit Log Pattern

The 5 public enrollment forms use a dual-store pattern:
  1. Submission logged to formSubmissions table in Convex first (durable record)
  2. POSTed to Lark workflow webhook
  3. 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 };
  },
});

Form Admin

Admins can view submissions via formSubmissions.list (paginated, filterable by formType and larkStatus), and retry failures via formSubmissions.markForRetry.

All 5 Forms

Form routeActionformType value
/phieu-thong-tin-hoc-viensubmitInfoFormstudent-info
/phieu-nghi-phepsubmitLeaveRequestleave-request
/phieu-chuyen-nhuongsubmitTransferRequesttransfer
/phieu-bao-luusubmitHoldRequesthold
/phieu-chuyen-doisubmitChangeRequestchange
All POST to the same LARK_FORM_WEBHOOK_URL. The Lark workflow discriminates by formType.