---
title: "Node.js and TypeScript"
description: "A small moderation module for Node.js 18 or later, with timeouts and a decision that fails closed."
canonical_url: "https://openshield.dev/docs/integration-guides/nodejs"
---
# 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:

```bash
OPENMOD_API_KEY=om_live_...
```

## The module

```ts [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.

```ts
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](https://openshield.dev/docs/best-practices) says more.


## Sitemap

See the full [sitemap](https://openshield.dev/sitemap.md) for all pages.
