Integration guides

Node.js and TypeScript

A small moderation module for Node.js 18 or later, with timeouts and a decision that fails closed.

This guide uses the fetch that ships with Node.js 18 and later. It has no dependency.

Set the key

Put the key in the environment of your server, never in the code:

OPENMOD_API_KEY=om_live_...

The module

moderation.ts
const base = "https://api.openshield.dev"

export type Action = "accept" | "review" | "reject"

export interface Reason {
  id: string
  text: string
  message: string
  score: number
  threshold: number
  source: string
}

export interface Decision {
  action: Action
  rejectReasons: Reason[]
  reviewReasons: Reason[]
  requestId: string
}

export class ModerationError extends Error {
  constructor(
    readonly status: number,
    readonly type: string,
    message: string,
    readonly retryAfter?: number,
  ) {
    super(message)
  }
}

async function send(path: string, init: RequestInit): Promise<Decision> {
  const key = process.env.OPENMOD_API_KEY
  if (!key) throw new Error("OPENMOD_API_KEY is not set")

  const response = await fetch(`${base}${path}`, {
    ...init,
    headers: { ...init.headers, Authorization: `Bearer ${key}` },
    method: "POST",
    signal: AbortSignal.timeout(10_000),
  })
  const body = await response.json()

  if (!response.ok) {
    const retryAfter = Number(response.headers.get("retry-after")) || undefined
    throw new ModerationError(response.status, body?.error?.type ?? "unknown", body?.error?.message ?? response.statusText, retryAfter)
  }
  return {
    action: body.summary.action,
    rejectReasons: body.summary.reject_reason,
    reviewReasons: body.summary.review_reason,
    requestId: body.request_id,
  }
}

// `workflow` names a workflow of yours: `default` when it is left out.
export function checkText(text: string, ref?: string, workflow?: string): Promise<Decision> {
  return send("/v1/text", {
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ text, ref, workflow }),
  })
}

export function checkImage(bytes: Uint8Array, ref?: string, workflow?: string): Promise<Decision> {
  const query = new URLSearchParams()
  if (ref) query.set("ref", ref)
  if (workflow) query.set("workflow", workflow)
  const suffix = query.toString()
  return send(`/v1/image${suffix ? `?${suffix}` : ""}`, { body: bytes })
}

Use it, and fail closed

Decide what happens to the content for each outcome, and what happens when the check itself fails.

import { checkText, ModerationError } from "./moderation"

export async function moderateComment(text: string, commentId: string): Promise<"publish" | "hold" | "refuse"> {
  try {
    const decision = await checkText(text, commentId)
    if (decision.action === "accept") return "publish"
    if (decision.action === "review") return "hold"
    return "refuse"
  } catch (error) {
    // The content cannot be accepted as it is: refuse it.
    if (error instanceof ModerationError && [400, 413, 415].includes(error.status)) return "refuse"
    // Anything else, a timeout included: keep it hidden until someone looks at it.
    return "hold"
  }
}

For rejected content, show your user a message chosen from decision.rejectReasons[0].id, in your own words and your own languages. Best practices says more.