Skip to content

JavaScript SDKSoon

The JavaScript package is in development and cannot be installed yet. Until it ships, call the REST API with fetch, or copy the small client on this page.

An official JavaScript package is in development. It is not published: @infrared/sdk cannot be installed today, and nothing should depend on that name yet.

Until it ships, call the REST API directly. Every request in these docs is shown in Node.js with fetch, next to cURL and Python, and the API is regular enough that a short file of your own covers it.

A client you can copy

The file below is example code, not a package. Copy it into your project, read it and change it: from then on it is yours to maintain. It is an ES module for Node.js 18 or later and has no dependencies.

It does four things:

  • request sends one request with your key and returns the parsed body.
  • A refused request throws an InfraredError that carries the error's code, its status and its request_id.
  • list reads a list page after page.
  • constructEvent verifies a webhook signature.
infrared.jsJavaScript
import crypto from "node:crypto";

export class InfraredError extends Error {
  constructor(response, body) {
    const error = body?.error ?? {};
    super(error.message ?? `Infrared answered ${response.status}`);
    this.name = "InfraredError";
    this.status = response.status; // 401, 404, 422, 429, 500
    this.code = error.code ?? null; // "rate_limited", "validation_error"
    this.details = error.details ?? []; // [{ field, message }]
    this.request_id = error.request_id ?? response.headers.get("x-request-id");
    this.retry_after = Number(response.headers.get("retry-after")) || null; // seconds, on a 429
  }
}

export function createClient(key = process.env.INFRARED_KEY, { baseUrl = "https://api.useinfrared.com" } = {}) {
  // One request. Returns the parsed body, or throws an InfraredError.
  async function request(method, path, { query, body, headers } = {}) {
    const url = new URL(path, baseUrl);
    for (const [name, value] of Object.entries(query ?? {})) {
      if (value !== undefined && value !== null) url.searchParams.set(name, value);
    }
    const response = await fetch(url, {
      method,
      headers: { Authorization: `Bearer ${key}`, ...(body === undefined ? {} : { "Content-Type": "application/json" }), ...headers },
      body: body === undefined ? undefined : JSON.stringify(body),
    });
    const text = await response.text();
    let data = null;
    try {
      data = text ? JSON.parse(text) : null;
    } catch {
      // Not JSON: something between you and the API answered.
    }
    if (!response.ok) throw new InfraredError(response, data);
    return data;
  }

  // Every item of a list, page after page.
  async function* list(path, query = {}) {
    let cursor;
    do {
      const page = await request("GET", path, { query: { ...query, cursor } });
      yield* page.data;
      cursor = page.has_more ? page.next_cursor : undefined;
    } while (cursor);
  }

  return { request, list };
}

export class SignatureError extends Error {}

// rawBody: the request body as received, a Buffer or a string, before any JSON parsing
// header:  the value of the Infrared-Signature header
// secret:  the endpoint's signing secret, or an array of two while you replace an endpoint
export function constructEvent(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = {};
  for (const item of String(header ?? "").split(",")) {
    const at = item.indexOf("=");
    if (at > 0) parts[item.slice(0, at).trim()] = item.slice(at + 1).trim();
  }
  if (!/^[0-9]{1,15}$/.test(parts.t ?? "") || !parts.v1) throw new SignatureError("malformed signature header");

  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(parts.t));
  if (age > toleranceSeconds) throw new SignatureError("timestamp outside the tolerance");

  const body = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody), "utf8");
  const received = Buffer.from(parts.v1, "utf8");
  let valid = false;
  for (const candidate of [secret].flat()) {
    const expected = Buffer.from(crypto.createHmac("sha256", candidate).update(`${parts.t}.`).update(body).digest("hex"), "utf8");
    // timingSafeEqual throws when the lengths differ, so compare them first.
    if (received.length === expected.length && crypto.timingSafeEqual(received, expected)) valid = true;
  }
  if (!valid) throw new SignatureError("signature mismatch");

  return JSON.parse(body.toString("utf8"));
}

Send a request

request takes the method, the path and, when the endpoint has them, a query, a body and extra headers. This call screens a message and passes an Idempotency-Key, so it is safe to run again after a timeout.

moderate.jsJavaScript
import { createClient } from "./infrared.js";

const infrared = createClient(); // reads INFRARED_KEY from the environment

const moderation = await infrared.request("POST", "/v1/moderations", {
  body: { type: "text", input: "Is this still available? I can collect on Saturday.", content_id: "message_7702" },
  headers: { "Idempotency-Key": "message-7702-v2" },
});

console.log(moderation.id, moderation.recommendation);
OutputShell
mod_4iiStmy545F7ONwOybqtsD allow

Handle an error

A refused request throws. Branch on code, and keep request_id in your logs: it is what support asks for.

errors.jsJavaScript
import { createClient, InfraredError } from "./infrared.js";

const infrared = createClient();

try {
  await infrared.request("GET", "/v1/moderations/mod_1B7lY4kzM9n2Qx8sRt3wVu");
} catch (error) {
  if (!(error instanceof InfraredError)) throw error; // no answer at all: the network failed

  console.error(error.status, error.code, error.request_id);
  if (error.code === "rate_limited") console.error(`retry in ${error.retry_after} seconds`);
}
OutputShell
404 not_found req_8cb281489b904ef89f9564f04661e5e6

An error that is not an InfraredError means no answer arrived. For a moderation, send the request again with the same Idempotency-Key.

Read a whole list

list follows the cursor for you and yields one item at a time.

events.jsJavaScript
import { createClient } from "./infrared.js";

const infrared = createClient();

for await (const event of infrared.list("/v1/webhooks/events", { type: "moderation.completed", limit: 100 })) {
  console.log(event.id, event.data.moderation.recommendation);
}

Verify a webhook

constructEvent is the function from Verify signatures, unchanged. The receiver on the Webhooks page works with this file as it is: import constructEvent and SignatureError from ./infrared.js.

Other languages

Nothing here is specific to JavaScript. In Python the same client is a few lines around requests, and every request in these docs has a Python tab to start from.

Next

Something missing or unclear on this page? Write to support@useinfrared.com.