Node.js and TypeScript

A small typed client around fetch with retries, idempotent submits and a wait helper, ready to drop into a service or a serverless function.

No SDK is needed; the API is plain JSON over HTTPS and fetch is built into Node 18 and later. This page is a complete client you can copy.

The client

silvertext.ts
export type CheckOptions = Partial<{
  plagiarism: boolean;
  grammarSpelling: boolean;
  citations: boolean;
  aiContent: boolean;
  style: boolean;
  register: 'academic' | 'general';
}>;

export interface CheckSummary {
  id: string;
  status:
    | 'queued'
    | 'extracting'
    | 'analyzing'
    | 'searching'
    | 'scoring'
    | 'done'
    | 'rejected'
    | 'cancelled'
    | 'failed';
  progress: number;
  phase: string;
  title: string | null;
  wordCount: number | null;
  originalityScore: number | null;
  coverage: number | null;
  scores: {
    plagiarism: number;
    grammarSpelling: number | null;
    citation: number | null;
    aiContent: number | null;
    clarity: number | null;
  } | null;
  issueCounts: { grammar: number; citations: number } | null;
  rejection: { code: string; message: string } | null;
}

const FINAL = new Set(['done', 'rejected', 'failed', 'cancelled']);

export class Silvertext {
  constructor(
    private readonly key = process.env.SILVERTEXT_API_KEY!,
    private readonly base = 'https://api.silvertext.com',
  ) {}

  async request<T>(path: string, init: RequestInit = {}, attempts = 4): Promise<T> {
    let delay = 1000;
    for (let attempt = 1; ; attempt++) {
      let res: Response | undefined;
      try {
        res = await fetch(this.base + path, {
          ...init,
          headers: {
            Authorization: `Bearer ${this.key}`,
            'Content-Type': 'application/json',
            ...(init.headers ?? {}),
          },
        });
      } catch (err) {
        if (attempt >= attempts) throw err;
      }
      if (res?.ok) return (res.status === 204 ? null : await res.json()) as T;
      const retryable = !res || res.status === 429 || res.status >= 500;
      if (!retryable || attempt >= attempts) {
        const body = await res?.json().catch(() => null);
        throw new SilvertextError(
          res?.status ?? 0,
          body?.error?.code ?? 'network',
          body?.error?.message ?? 'request failed',
        );
      }
      const retryAfter = Number(res?.headers.get('Retry-After'));
      await new Promise((r) =>
        setTimeout(r, retryAfter > 0 ? retryAfter * 1000 : delay + Math.random() * 500),
      );
      delay = Math.min(delay * 2, 30_000);
    }
  }

  /** Submit text. `idempotencyKey` makes a retry safe. */
  createCheck(
    body: {
      text?: string;
      file?: string;
      filename?: string;
      title?: string;
      options?: CheckOptions;
      citationStyle?: 'APA' | 'MLA' | 'Chicago';
    },
    idempotencyKey?: string,
  ) {
    return this.request<{ id: string }>('/v1/checks', {
      method: 'POST',
      body: JSON.stringify(body),
      headers: idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {},
    });
  }

  getCheck<T = CheckSummary>(
    id: string,
    query: { view?: 'summary' | 'full'; include?: string[] } = {},
  ) {
    const q = new URLSearchParams();
    if (query.include) q.set('include', query.include.join(','));
    else if (query.view) q.set('view', query.view);
    return this.request<T>(`/v1/checks/${id}${q.size ? `?${q}` : ''}`);
  }

  cancelCheck(id: string) {
    return this.request<null>(`/v1/checks/${id}/cancel`, { method: 'POST' });
  }

  deleteCheck(id: string) {
    return this.request<null>(`/v1/checks/${id}`, { method: 'DELETE' });
  }

  usage() {
    return this.request<{ balanceAvailableCredits: number; balanceHeldCredits: number }>(
      '/v1/me/usage',
    );
  }

  /** Poll until the check is final, cancelling it at the deadline so no credits stay reserved. */
  async waitForCheck(
    id: string,
    { deadlineMs = 10 * 60_000, signal }: { deadlineMs?: number; signal?: AbortSignal } = {},
  ) {
    const started = Date.now();
    let delay = 3000;
    for (;;) {
      const check = await this.getCheck(id, { view: 'summary' });
      if (FINAL.has(check.status)) return check;
      if (signal?.aborted || Date.now() - started > deadlineMs) {
        await this.cancelCheck(id).catch(() => {});
        throw new Error(`check ${id} did not finish in time`);
      }
      await new Promise((r) => setTimeout(r, delay));
      if (Date.now() - started > 60_000) delay = Math.min(delay + 2000, 10_000);
    }
  }
}

export class SilvertextError extends Error {
  constructor(
    public status: number,
    public code: string,
    message: string,
  ) {
    super(message);
  }
}

Using it

import { readFile } from 'node:fs/promises';
import { Silvertext } from './silvertext';

const st = new Silvertext();

// Text
const { id } = await st.createCheck(
  { title: 'Q3 launch post', text: draft, options: { citations: false } },
  `post:${postId}:v${version}`,
);
const summary = await st.waitForCheck(id);
if (summary.status !== 'done') throw new Error(summary.rejection?.message ?? summary.status);

// The findings
const report = await st.getCheck<{
  report: { issues: Array<{ category: string; fix?: { replacement: string } }> };
}>(id, {
  include: ['issues', 'sources'],
});
const grammar = report.report.issues.filter((i) => i.category === 'grammar');

// A file
const bytes = await readFile('thesis-draft.docx');
const file = await st.createCheck({
  file: bytes.toString('base64'),
  filename: 'thesis-draft.docx',
});

Serverless and edge functions

A check outlives a single short-lived function. Submit from one invocation, store the id, and read the result from a later one (a queue message, a scheduled function, or the user's next request), rather than holding a function open to poll. The Idempotency-Key makes the submit step safe to run twice when a queue redelivers.

On this page