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:
| Code | Stage | Meaning |
|---|---|---|
missing_issue_type_context | startup | Ticket does not have the Cheque Processing issue type |
missing_attachment | attachment_select | No image attachment on the ticket |
attachment_download_failed | attachment_download | Plugin could not fetch the image bytes |
ocr_failed | ocr_upload | ChequeDB 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.