Skip to content
← Back to blog
Latch Journal

Building a Latch Plugin That Reads Handwritten Cheques

A Latch plugin that reads handwritten fields from a cheque — payee, amount, cheque number — validates them, and gates credit or reject actions on the result.

Book a workflow review Plugin SDK →
Move This Into A Governed Workflow

Keep the work, approvals, and evidence in one audit trail.

Bring one workflow that already needs approvals, evidence, or controlled execution. We will map the first governed version with you.

Book a workflow review Plugin SDK → Add plugin actions without hard-coding every downstream workflow into the core product.

Most plugin tutorials show a button that calls an API and returns a result. That is a reasonable starting point. It is also not a complete picture of what the plugin interface can do.

The ChequeDB plugin does something more involved. When a processor accepts a cheque ticket, the plugin fires automatically, fetches the cheque image attached to the ticket, posts it to an OCR API, and writes the extracted fields — bank name, payee, amount, cheque number, validation flags — back into the ticket. Two action buttons then appear on the ticket. They are locked until OCR runs and a cheque number is extracted. If the image quality is too poor to extract one, the buttons stay unavailable.

The interface that makes this possible is small. Three methods. This article walks through what each one does and why even non-trivial enrichment logic stays contained.

This is for:

  • Developers building a Latch plugin that calls an external API and writes structured data back into the ticket
  • Teams that want to gate action availability on ticket state rather than always showing the same buttons
  • Anyone who looked at the plugin contract and wondered how much of the work the SDK actually handles

The Latch Plugin Contract Is Three Methods

A Latch plugin is an HTTP service. The action provider SDK handles routing, HMAC signature verification, and the JSON protocol. You extend ActionProvider and implement three methods:

import { ActionProvider } from "@ticketing/action-provider-sdk";

class ChequeProvider extends ActionProvider {
  // Returns available actions for this ticket, right now
  discover(ctx: ActionContext): Promise<ProviderAction[]> { ... }

  // Handles a button click
  execute(actionId: string, ctx: ActionContext): Promise<ExecuteResponse> { ... }

  // Fires when a ticket is accepted — optional, default is a no-op
  enrichOnAccept(ctx, trigger, request?): Promise<AcceptEnrichmentResponse> { ... }
}

The SDK wires these to the four endpoints Latch calls:

GET  /action/health        — reachability check
POST /action/discover      — what actions are available right now
POST /action/execute/:id   — run an action
POST /action/enrich/accept — fires on ticket acceptance

Registering them in Express is ten lines:

const verifySignature = createVerifySignatureMiddleware(secret);
app.use(jsonWithRawBody());

app.get("/action/health", createHealthHandler(provider, { providerName: "cheque" }));
app.post("/action/discover", verifySignature, createDiscoverHandler(provider));
app.post("/action/execute/:actionId", verifySignature, createExecuteHandler(provider));
app.post("/action/enrich/accept", verifySignature, createAcceptEnrichmentHandler(provider));

Everything else — request validation, response formatting, signature verification — is handled by the SDK. The provider class implements the logic. The entry point wires it to HTTP.


discover() Reads Ticket State and Returns What Is Currently Possible

discover() runs every time the ticket view loads. It receives the current ticket context — including any structured fields already on the ticket — and returns the actions Latch should render.

The ChequeDB plugin uses this to gate Credit and Reject on whether OCR has run and extracted a cheque number:

discover(ctx: ActionContext): Promise<ProviderAction[]> {
  const chequeNumber = resolveChequeNumber(this.resolveIssueFields(ctx));
  const available = chequeNumber.length > 0;
  const unavailableReason = available ? "" : "Available after cheque OCR completes.";

  return Promise.resolve([
    {
      id: "credit",
      label: "Credit Cheque",
      icon: "badge-check",
      color: "success",
      requires_confirmation: true,
      confirmation_message: "Credit this cheque once OCR extraction has been verified?",
      available,
      ...(unavailableReason ? { unavailable_reason: unavailableReason } : {}),
    },
    {
      id: "reject",
      label: "Reject Cheque",
      icon: "circle-x",
      color: "destructive",
      requires_confirmation: true,
      confirmation_message: "Reject this cheque once OCR extraction has been verified?",
      available,
      ...(unavailableReason ? { unavailable_reason: unavailableReason } : {}),
    },
  ]);
}

resolveChequeNumber reads cheque_number from ctx.ticket.issue_type.fields. That field is populated by the enrichment hook. Before enrichment runs, both actions render as unavailable with the reason shown in the UI. After it runs, they become active.

The plugin checks no external systems in discover(). It reads what is on the ticket and returns what is currently possible. That is the full scope of the method.


execute() Declares Effects Rather Than Calling Back Into Latch

When a processor clicks Credit Cheque, Latch calls POST /action/execute/credit. The execute method returns a result. If the result includes an effects block, Latch applies those effects to the ticket.

The plugin does not call back into the Latch API to write a comment or change the status. It declares what should happen and Latch executes it:

case "credit":
  return Promise.resolve({
    success: true,
    message: `Cheque ${chequeNumber} credited.`,
    effects: {
      add_comment: {
        content: `Cheque ${chequeNumber} credited by ${ctx.user.email}.`,
        is_public: false,
      },
      change_status: {
        status: "Resolved",
        reason: `Cheque ${chequeNumber} credited`,
      },
    },
    data: {
      cheque_number: chequeNumber,
      outcome: "credit",
    },
  });

The private comment records who credited which cheque number. The status change resolves the ticket. Neither requires a Latch API credential in the plugin. The plugin returns intent; Latch owns the write path.

Reject is structurally identical. Both actions re-check cheque_number at execution time as a guard — if enrichment did not run, the action returns failure before attempting anything.


enrichOnAccept() Is Where the Non-Trivial Work Happens

The enrichment hook fires once, when a ticket moves to the accepted state. This is where the ChequeDB plugin calls the OCR API and populates the ticket's structured fields.

The full logic is four steps.

Resolve the attachment. Latch passes the ticket's attachments in the request payload, so the plugin does not need to call back to fetch them:

const requestAttachments = parseDiscoveryAttachments(ctx.ticket.attachments);
attachment = selectNewestImageAttachment(requestAttachments);

selectNewestImageAttachment filters for image/* MIME types and picks the most recently uploaded one. No image found means a structured failure with error_code: "missing_attachment".

Download the image. Latch provides a signed URL for each attachment. The plugin fetches the raw bytes before sending them to OCR:

fileBytes = await this.downloadAttachment(attachment);

Call the OCR API. The ChequeDB client posts the image as a multipart upload to POST /api/v1/ocr/cheque/upload and parses the response into a typed struct:

ocrResult = await this.ocrClient.uploadCheque({
  fileName: attachment.fileName,
  fileType: attachment.fileType,
  bytes: fileBytes,
});

Return the extracted fields. The plugin maps the OCR response to the schema fields and returns them in a typed_issue block:

function buildIssueFieldsFromOCR(ocr: OcrChequeResponse): IssueFields {
  return {
    bank_name: ocr.bank_name,
    branch: ocr.branch,
    cheque_number: ocr.cheque_number,
    date: ocr.date,
    payee: ocr.payee,
    amount_in_words: ocr.amount_in_words,
    amount_in_numerals: ocr.amount_in_numerals,
    ...(ocr.validation ? { validation: ocr.validation } : {}),
  };
}

return {
  success: true,
  message: `Cheque OCR extracted ${chequeNumber}.`,
  typed_issue: {
    issue_type_id: issueType.id,
    issue_type_schema_name: "Cheque OCR Fields",
    issue_fields: buildIssueFieldsFromOCR(ocrResult),
  },
};

Latch writes these fields to the ticket's typed issue schema. The next time discover() runs, cheque_number is populated and the action buttons become available.

The hook is also idempotent. If cheque_number is already present when the ticket is accepted, the plugin skips the OCR call and returns the existing fields:

const existingChequeNumber = resolveChequeNumber(existingFields);
if (existingChequeNumber) {
  return {
    success: true,
    message: `Cheque OCR already available for ${existingChequeNumber}.`,
    typed_issue: { ...existing fields... },
  };
}

Re-accepting a ticket does not re-run OCR or overwrite data the processor may have corrected.


Each Failure Returns a Structured Error Code

Every step in enrichOnAccept() can fail. The plugin handles each failure path with a structured response that includes an error_code field:

CodeStageMeaning
missing_issue_type_contextstartupTicket does not have the Cheque Processing issue type
missing_attachmentattachment_selectNo image attachment on the ticket
attachment_download_failedattachment_downloadPlugin could not fetch the image bytes
ocr_failedocr_uploadChequeDB API returned an error

These map directly to logger.error("accept_enrichment_failure", { error_code, stage }) calls in the code. When debugging a failed enrichment, the plugin logs tell you exactly where it stopped.

This is worth replicating in any enrichment plugin: name the stages, name the codes, log them consistently. The alternative is silent failures that are hard to trace from the ticket side.


One Limit Worth Knowing

The enrichment hook runs synchronously during the accept flow. The processor waits for it to complete before the ticket view updates. For the ChequeDB plugin, an OCR call that takes 3–5 seconds is acceptable. An API that routinely takes 30 seconds or more is not.

If your enrichment depends on a slow or unreliable external call, consider returning success: true with an empty field set immediately and handling the enrichment separately. The hook does not have to block on every case — but for OCR on images that arrive in accepted state, synchronous is the right model.


Register and Run

git clone https://github.com/latchworkflow/action-providers
cd action-providers/cheque
pnpm install && pnpm build

Minimum .env:

WEBHOOK_SECRET=your-shared-secret
CHEQUE_PROVIDER_TICKETING_API_URL=https://your-workspace.latchworkflow.com
CHEQUE_PROVIDER_TICKETING_API_REFRESH_TOKEN=your-refresh-token
CHEQUE_PROVIDER_OIDC_AUTHORITY=https://auth.latchworkflow.com
CHEQUE_PROVIDER_OIDC_CLIENT_ID=your-client-id
CHEQUE_PROVIDER_OCR_API_URL=https://your-chequedb-endpoint.example.com

Start and verify:

pnpm start:cheque
curl http://localhost:3003/action/health
# → {"status":"ok","provider":"cheque",...}

Register in your Latch workspace at Admin → Plugins → Add Plugin. Provide the externally reachable endpoint URL and the webhook secret. Latch calls /action/health to confirm reachability, then begins routing discover and enrichment calls to the service.

Attach the plugin to a Cheque Processing issue type with a Cheque OCR Fields schema. Accept a test ticket with a cheque image. Enrichment fires. Fields populate. The Credit and Reject buttons appear.


The Operating Rule

A plugin has one responsibility: given a ticket, return what is currently possible. The enrichment hook extends that responsibility to one more moment: given an acceptance event, return what the ticket should look like after the plugin has run.

The plugin reads ticket state and returns structured results. Latch owns the write path — it applies effects, writes fields, and records the audit trail. The plugin never needs a direct write credential to the ticketing system for those operations.

That separation is what keeps the interface small even when the enrichment logic is not.

Continue exploring
Next product path Plugin SDK Add plugin actions without hard-coding every downstream workflow into the core product. Related path Product Overview See how unified triage, approvals, audit trails, and plugins connect.
Related reads
How to Add External Actions Without Creating Integration Debt Use extensible plugin architecture to preserve discovery, authorization, and result capture without long-term integration debt. Why Approval, Auth, and Audit Logic Must Stay in the Core Approval, authorization, and audit logic should be a carefully tested core, while AI-assisted plugins evolve around it safely. The First Three Workflows to Govern with Action Providers Plugins should start with workflows that need governed approvals, immutable audit logs, and safe external execution in production.
Ready to move beyond reading?

Map your first governed workflow with us.

Bring one workflow that needs approvals, evidence, or controlled execution. We will map a concrete governed version with you in one session.

Book a workflow review See the platform →