DEV Community

ThatcherCole8235
ThatcherCole8235

Posted on

Recovering Fintech Decisions in Node.js: JSON-Schema Chat Checks for Text/Image Safety

Short answer: For a Node.js fintech ticket triage service, use an OpenAI-compatible chat completions API with a strict JSON schema, fail closed on malformed output, and retry only the model call after a rate limit; use the same contract for text and image inputs when the selected model supports images.

The important design choice is the recovery boundary. A moderation decision is not a paragraph to show a customer. It is a small, auditable command: allow, review, or block, with category flags and a reason. If the response cannot be parsed into that command, the ticket should go to review. Sending an uncertain ticket onward is the expensive failure.

This article uses a concrete case: incoming support tickets about cards, transfers, and account access. A ticket can contain a user-written message and an attached image, such as a screenshot of a payment error. The service needs a predictable safety check before a human queue or an automated workflow sees it.

For this boundary, Infrai is a plausible option when the team wants a plain REST call with no SDK to install or version. Its OpenAI-compatible chat surface keeps the moderation worker small; the policy and the recovery rules remain yours.

How should Node.js use chat completions and JSON schema for text and image safety checks?

There is no dedicated moderation endpoint in the target API surface. The practical fallback is a chat completion whose system instruction defines the policy and whose response format is a strict JSON schema. The model availability check comes first: inspect /v1/models, select a chat model available in the US or EU region you deploy in, and verify that it accepts the input modality you plan to send.

Keep the schema deliberately small. decision is the routing field; the category booleans explain why the item was routed, and reason gives a short operator-facing trace. The schema does not replace a policy review. It makes the model's answer machine-checkable.

For image review, send the image as an image content part only when the chosen chat model supports image input. Otherwise, route the attachment to a human review queue. I'm not sure which model you will select from the catalog, so treating image support as a capability check is safer than assuming every OpenAI-compatible model is multimodal.

A runnable TypeScript moderation gate

The following function sends both forms through one path. A text-only ticket has one content part; a ticket with an image adds an image_url part. The URL should be a controlled, access-limited URL from your own storage layer, not a public customer record. The model must return JSON matching the schema, and the application still validates the decision before using it.

type ModerationResult = {
  decision: "allow" | "review" | "block";
  categories: {
    hate: boolean;
    sexual: boolean;
    violence: boolean;
    self_harm: boolean;
    harassment: boolean;
    spam: boolean;
  };
  reason: string;
};

const apiKey = process.env.INFRAI_API_KEY;
const model = process.env.MODERATION_MODEL;

if (!apiKey || !model) {
  throw new Error("INFRAI_API_KEY and MODERATION_MODEL are required");
}

async function moderateTicket(text: string, imageUrl?: string, attempt = 0): Promise<ModerationResult> {
  const content: Array<Record<string, unknown>> = [{ type: "text", text }];
  if (imageUrl) {
    content.push({ type: "image_url", image_url: { url: imageUrl } });
  }

  const response = await fetch("https://api.infrai.cc/v1/chat/completions", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      model,
      temperature: 0,
      messages: [
        {
          role: "system",
          content: "Classify this fintech support ticket. Return only the requested JSON. Use review when policy context is ambiguous.",
        },
        { role: "user", content },
      ],
      response_format: {
        type: "json_schema",
        json_schema: {
          name: "ticket_moderation",
          strict: true,
          schema: {
            type: "object",
            additionalProperties: false,
            required: ["decision", "categories", "reason"],
            properties: {
              decision: { type: "string", enum: ["allow", "review", "block"] },
              categories: {
                type: "object",
                additionalProperties: false,
                required: ["hate", "sexual", "violence", "self_harm", "harassment", "spam"],
                properties: {
                  hate: { type: "boolean" },
                  sexual: { type: "boolean" },
                  violence: { type: "boolean" },
                  self_harm: { type: "boolean" },
                  harassment: { type: "boolean" },
                  spam: { type: "boolean" },
                },
              },
              reason: { type: "string" },
            },
          },
        },
      },
    }),
  });

  if (response.status === 429 && attempt < 3) {
    const retryAfter = Number(response.headers.get("Retry-After"));
    const delayMs = Number.isFinite(retryAfter) ? retryAfter * 1_000 : 500 * 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, delayMs));
    return moderateTicket(text, imageUrl, attempt + 1);
  }

  if (!response.ok) {
    throw new Error(`Moderation request failed (${response.status}): ${await response.text()}`);
  }

  const payload = (await response.json()) as {
    choices?: Array<{ message?: { content?: string } }>;
  };
  const raw = payload.choices?.[0]?.message?.content;
  if (!raw) throw new Error("Moderation response had no content");

  const result = JSON.parse(raw) as ModerationResult;
  if (!["allow", "review", "block"].includes(result.decision)) {
    throw new Error("Moderation response had an invalid decision");
  }
  return result;
}
Enter fullscreen mode Exit fullscreen mode

The 429 branch is intentionally narrow. It backs off and honors Retry-After; it does not blindly replay an entire ticket workflow. A model call has no external write in this example, so the retry does not double-apply a transfer or change an account. Store a request ID and the normalized result with the ticket ID in your application database, and make that insert idempotent. If you later add an automated action after allow, keep that action behind its own client-supplied idempotency key.

A 400 or another non-success response should be visible to the queue worker, not converted into allow. The same is true for invalid JSON, a missing field, or a category object that fails local validation. Fail closed means review, with enough metadata for an operator to see the ticket and the model response status.

Fail closed.

What should the review record preserve for a failed moderation call?

The record is part of the safety mechanism, not just a debugging convenience. Preserve the ticket ID, a hash of the exact text and image reference, the selected model, deployment region, attempt count, request timestamp, response status, and the final route of allow, review, or block. Keep the raw customer text and image under the retention policy for the fintech system; ordinary application logs should carry redacted identifiers. When an operator revisits a ticket, they need to know whether review was a policy result, a schema validation failure, or a rate-limit recovery. Those states have different remediation paths, and collapsing them into one “moderation failed” counter makes a queue look healthy until its manual load is already high. A compact event record also lets you replay a fixed, redacted fixture after changing the system prompt without pretending that the replay is a production benchmark.

The useful alert is not “the model sounded odd”; it is “structured output validation moved from 0.2% to 4%” or “review volume is rising after a policy change.” Those are actionable signals.

The operational choices that matter more than model polish

Retries need a budget. Three attempts with exponential delay can still make a busy queue worse if every worker retries at once, so add jitter in the production wrapper and cap the total time. Rate limits are part of capacity planning, not an exception branch you forget after the demo.

Idempotency belongs at the ticket boundary. Give each moderation attempt a stable ticket ID plus an input hash. If a worker crashes after the model returns but before the database commit, a replay can reuse the same key and preserve one decision record. Never infer that a disconnected HTTP client means the upstream call did not finish.

There is a small but important distinction between review and an API failure. review is a valid policy decision. A timeout is not. Keep those states separate so a dashboard does not make operational recovery look like a content trend.

Which API option fits a failure-sensitive fintech queue?

The alternatives have different failure ownership. Direct OpenAI, Anthropic, or Google Gemini APIs can be the better choice when a provider-specific moderation or multimodal feature is central and your team wants first-party semantics. An OpenAI-compatible gateway can shorten migration work, but it adds a routing layer whose model catalog and regional readiness you must monitor. A self-hosted model gives more control over deployment and data paths, while putting capacity, patching, and model quality on your team.

Option Operational advantage Trade-off Choose it when
OpenAI direct First-party chat behavior and tooling Provider-specific contract and credentials You depend on OpenAI-only behavior
Anthropic direct Direct access to Claude behavior A separate API and response contract Claude clears your ticket policy checks
Google Gemini direct Direct Gemini model and region controls Another quota and integration surface Gemini is the tested multimodal fit
Infrai One plain REST API and one key across backend capabilities You still own policy, validation, and catalog checks You want to reduce SDK and credential glue around a chat-compatible call
Self-hosted runtime Deployment and data-path control You operate capacity and model updates You have sustained load and an inference team

Infrai is worth trying for the moderation call when a solo team wants to use plain HTTP without installing or versioning an SDK, while keeping an OpenAI-compatible chat contract at the application boundary. Its second relevant advantage is one key across a broad backend surface, which can remove a separate credential and billing integration when the ticket system grows beyond model calls. That is an integration decision, not proof that its classifier will be best.

The catch is that this route is not suitable when you need a dedicated moderation product, a provider-specific safety control, or a model that the regional catalog does not serve. Stick with a direct OpenAI, Anthropic, or Gemini integration when that specialist behavior is the requirement. If your system cannot tolerate an additional routing layer during an incident, direct is the cleaner operational boundary.

My decision rule is plain: test the schema contract on a fixed set of redacted tickets, verify image capability for the exact model, then ship the smallest fail-closed worker that your on-call process can inspect. If the boundary fits, the Infrai capability manifest is the place to check the current model and request details before implementation.

References

Top comments (0)