@saplingai/sapling-js
v1.0.55
Published
JavaScript client for the Sapling.ai API (grammar checking, AI content detection, rephrasing and more) plus the browser SDK.
Readme
Sapling JS
Node.js client for the Sapling.ai API.
Try out a grammar check demo or an AI detection demo. The SDK demo shows both running live in a textarea through the browser SDK bundled with this package. Compare against grammar checking tools and APIs like Grammarly (Grammerly), LanguageTool, ProWritingAid and Ginger.
Functionality
Sapling is an AI writing assistant. The API offers spelling and grammar checking, AI content detection, rephrasing, tone detection and more; the bundled browser SDK adds in-page grammar suggestions, autocomplete and AI detection to textareas and contenteditables.
Benefits include:
- 60% more grammar corrections: Compared to other systems at similar accuracy using state-of-the-art generative AI models.
- Low Latency: Achieve the same real-time performance that users of Sapling's own interface experience.
- Over 20 error types: Error categories such as preposition, noun form, and verb tense, including both high-level and fine-grained error information.
- Custom Models: Get corrections optimized for your content type, from academic writing, to medical notes, to user-generated reviews.
- Enterprise Security: Contact us for our no data retention policies, self-hosted/on-premises deployment options, and other cybersecurity policies and procedures.
- Rich Text Editor Support: TinyMCE, CKEditor, QuillJS, Trix, ProseMirror, WordPress, Draft.js, Froala, Lexical and others. Sapling's HTML SDK can also be directly imported into HTML pages as JavaScript.
Node.js
Sapling can be run in a Node.js server or script environment.
See this folder for an example of grammar checking without UI.
React
Sapling can be imported directly into a React project.
See this React App for a React demo app.
Angular
For Angular support, refer to this Angular App for an Angular demo app.
TypeScript
For TypeScript declarations and how Sapling can be imported directly into a TypeScript project, see this TypeScript App for a demo app.
Installation
npm install @saplingai/sapling-js- Create a key by following these instructions.
Backend Quickstart
import { Client } from "@saplingai/sapling-js/client";
const apiKey = '<YOUR_API_KEY>';
const client = new Client(apiKey);
client
.edits('I have a apple!')
.then(function (response) {
console.log(response.data);
})You should see something like the following output:
{
edits: [
{
end: 8,
error_type: "R:DET:ART",
general_error_type: "Grammar",
id: "740071c9-8ea3-583d-a86b-7ef80e5fc91e",
replacement: "an",
sentence: "I have a apple",
sentence_start: 0,
start: 7
}
]
}If you only need to apply the corrections, pass includeErrorCategories: false. The API then
skips error classification (faster and lighter), and each edit comes back without
error_type, general_error_type or classifier-generated descriptions:
client.edits('I have a apple!', undefined, { includeErrorCategories: false, autoApply: true })edits() and spellcheck() responses are typed (EditsResponse, SpellcheckResponse).
If a model stage can't process some sentences in time, the response still returns
everything that did complete and adds a degraded object. Those sentences may be
missing some or all of their edits, and the object gives counts only (not which
sentences), so retry the whole request after a short backoff if you need a
complete result:
const { data } = await client.edits(text);
if (data.degraded) {
// { sentences_degraded: 1, sentences_total: 12, reason: 'model_timeout' }
// reason: model_timeout | model_unavailable | model_error
}If no sentence could be checked, the request rejects with a 503 and a
Retry-After header instead (see Error handling).
Text classification
Zero-shot classification with your own labels (plain names or { name, description } objects, 2–20):
client
.classify(
'I was charged twice and the app crashes on launch.',
['billing', { name: 'technical issue', description: 'Bugs, crashes, errors.' }, 'other'],
{ multiLabel: true, threshold: 0.5, context: 'Support tickets for a SaaS billing product' },
)
.then(function (response) {
console.log(response.data);
// { label: 'billing', labels: ['billing', 'technical issue'],
// scores: [{ label: 'billing', score: 0.92 }, ...], rationale: '...', multi_label: true }
})AI content detection
const { data } = await client.aidetect(documentText);
// { score: 0.05, ai_fraction: 0.51, verdict: 'mixed',
// segments: [{ start: 0, end: 240, score: 0.96 }],
// low_confidence: false, low_confidence_reasons: [], text: '...', version: '...',
// sentence_scores: [{ sentence: '...', score: 0.97, start: 0, end: 112 }, ...],
// tokens: [...], token_probs: [...] }score (0–1) rates the whole text, so a document that is only partly AI-written can score low;
ai_fraction is the share of the text in sentences scoring ≥ 0.5 (null when nothing could be
scored), which flags those documents. verdict sums both up as 'human', 'mixed' or 'ai'
('ai' needs score ≥ 0.5 and ai_fraction ≥ 0.8; 'mixed' is anything else with score ≥ 0.5
or ai_fraction ≥ 0.2; with a null ai_fraction it is 'ai' or 'human' by score alone), and
is omitted when the text couldn't be scored. segments lists each run
of consecutive sentences scoring ≥ 0.5 as { start, end, score }, even with sent_scores: false.
low_confidence is true for inputs with a higher false-positive rate, named in
low_confidence_reasons: 'short_text' (under 500 characters) and 'list_format' (mostly bullet or
numbered lines); show those results hedged rather than as a bare score. sentence_scores use the
same model and 0.5 threshold, and their start/end (like a segment's) index text in Unicode code points (end exclusive) — slice with
Array.from(text).slice(start, end).join('') if the text may contain emoji. A response may also
carry an advisory language ({ code, name, confidence }) when the text is confidently
identified. English, Arabic, Chinese, Dutch, French, German, Italian, Japanese, Korean, Polish,
Portuguese, Romanian, Russian, Spanish, Swedish and Turkish are supported: unpinned text detected as ar,
de, es, fr, it, ja, ko, nl, pl, pt, ro, ru, sv, tr or zh is scored by a
multilingual model (trained on all fifteen) and answers version: 'ml20261104', while other
languages are scored by the English model and are less reliable. Options: sent_scores (default true), score_string (HTML
heatmap), lang ('en', 'ar', 'de', 'es', 'fr', 'it', 'ja', 'ko', 'nl', 'pl', 'pt', 'ro', 'ru', 'sv', 'tr' or 'zh', case-insensitive, region
subtags such as 'pt-BR' accepted: use that language's model instead of detecting it, which helps
with short texts; omit it or pass 'auto' to detect; other languages get a 400), version (pin
a model; a pin always overrides the language routing and lang). language still reports the
detected language when lang is set, so a mismatch is visible. Pass an array of 1–10 texts for the
batch form, which answers { results: [...] } in input order; lang applies to every item.
Pass zero_retention: true to keep no copy of the text. The request is still scored and billed,
but its result is not written to the shared result cache and usage is logged without the text (a
hash of it is kept so a repeat isn't billed twice). Teams with data retention disabled get this on
every request. fileAidetect() and urlAidetect() take the same option.
Pass create_report: true (single text only, not a batch) to also save the result as a shareable
report: the response adds report ({ id, url, shared_at, expires_at }), a public link to the
text and its scores that you can revoke from your AI detector history and that expires 90 days
after creation. It needs your private API key, and can't be combined with zero_retention
(both get a 400). When no report can be made (for example, history or text retention is turned
off, or the history is full), report is null and report_skip_reason says why; the request is
still scored and billed. fileAidetect() and urlAidetect() take it too, naming the report after
the file or the final URL.
const { data } = await client.aidetect(reviewText, { lang: 'pt-BR' }); // version: 'ml20261104'To score a document without extracting its text yourself, upload it with fileAidetect(). It
accepts .pdf, .docx, .pptx, .odt, .txt, .md, .html or .htm files up to 30MB, and the
format is chosen by file extension. The first 200,000 characters of the document's text are scored
like aidetect(), with sentence_scores and score_string always included. The language is
detected and routed automatically; there is no lang or version option, but zero_retention
works as on aidetect(). Billing is the same as aidetect().
import { readFileSync } from 'node:fs';
const { data } = await client.fileAidetect(readFileSync('essay.pdf'), { filename: 'essay.pdf' });
// In the browser, pass the File from an <input type="file"> (its own name is used):
// await client.fileAidetect(input.files[0]);To score a web page, pass its URL to urlAidetect(). The API fetches the page and scores its main
readable text: navigation, headers, footers, forms and hidden elements are dropped, and the largest
<article> (else <main>) is preferred, so a scraped page's boilerplate isn't scored or billed. A
URL that serves a .pdf, .docx, .pptx, .odt, .txt or .md document is read like
fileAidetect(). Options and billing are the same as aidetect(), and the response adds source:
the final URL after redirects, the page title, its content type, the characters scored, and
truncated (HTML past 2MB, documents past 15MB and text past 200,000 characters are not scored).
Only public http(s) URLs on ports 80/443/8080/8443 are fetched, at most 30 per minute per account. A
page that is fetched but can't be scored (a non-2xx status, unsupported type, no readable text)
answers 422, and one that can't be fetched (timeout, connection failure, or a site that blocks
automated requests) answers 502.
const { data } = await client.urlAidetect('https://example.com/blog/post', { sent_scores: false });
// { score: 0.93, ai_fraction: 0.88, text: '...', version: '...', ...,
// source: { url: 'https://example.com/blog/post', title: '...', content_type: 'text/html',
// characters: 5120, truncated: false } }Resume and job description overlap
jdMatch() measures how closely a resume overlaps with the job description it was submitted for:
const { data } = await client.jdMatch(resumeText, jobDescriptionText);
// { score: 0.786, level: 'high', keyword_coverage: 0.571,
// matched_keywords: [{ term: 'python', job_description_count: 1, resume_count: 1 }, ...],
// missing_keywords: [{ term: 'terraform', job_description_count: 1 }, ...],
// copied_fraction: 0.312, has_copied_span: true, copied_spans_total: 1,
// copied_spans: [{ start: 136, end: 207, text: '...', words: 10 }],
// resume_words: 32, job_description_words: 43, version: '...' }score (0–1) is a heuristic over keyword coverage and passages of 6+ words shared verbatim, not a
probability. It says nothing about who wrote the resume, and high overlap can simply mean a good fit,
so pair it with aidetect() and don't use it alone to decide an application. It's unbilled and
tuned for English. The API caps the resume at 50,000 characters and the posting at 20,000, and a
posting with fewer than 5 distinct keywords gets a 400. Span offsets index the resume in Unicode code
points.
Rephrase, tone, sentiment and readability statistics
const { data: rephrased } = await client.rephrase('hey, can u send it over', {
mapping: 'informal_to_formal', // default 'paraphrase'
num_results: 3, // 1–8, default 5
});
// { results: [{ hash: '...', original: '...', replacement: 'Could you please send it over?',
// rephrase_type: 'informal_to_formal', model_version: '...' }, ...] }
const { data: tone } = await client.tone('Really stoked about this! Will it be ready by next week?');
// { overall: [[0.92, 'curious', '🤓'], [0.05, 'excited', '😀'], [0.01, 'confused', '😕']],
// results: [[[0.99, 'excited', '😀'], ...], [[0.55, 'confused', '😕'], ...]],
// sents: ['Really stoked about this!', 'Will it be ready by next week?'] }
const { data: sentiment } = await client.sentiment('Love the new release. The install was painful.');
// { overall: [[0.71, 'POSITIVE'], [0.29, 'NEGATIVE']],
// results: [[[0.99, 'POSITIVE']], [[0.95, 'NEGATIVE'], ...]],
// sents: ['Love the new release.', 'The install was painful.'] }
const { data: stats } = await client.statistics(documentText, { lang: 'en' });
// { chars: 1234, words: 210, sentences: 12, reading_time_sec: 47.55, reading_time_min: 1,
// readability: 8.2, readability_scores: { flesch_reading_ease: 64.1, gunning_fog: 10.3, ... } }rephrase() mappings: paraphrase, reword, expand, shorten, summarize,
passive_to_active, active_to_passive, informal_to_formal, formal_to_informal,
sentence_split, generate_title, generate_acronym, generate_meta_description,
switch_tone (with tone_mapping) and switch_tense (with tense_mapping). Tones come as
[probability, tone name, emoji] triples: overall holds up to three for the whole text, most
likely first, with probabilities summing to 1, and results holds one list per sentence. A text
the API cannot split into sentences gets a 200 with [], and the text is capped at 8000
characters. Sentiment labels
are POSITIVE, NEUTRAL and NEGATIVE, as [probability, label] pairs, most likely first;
a text with no sentences (markup or punctuation only) gets a 204 with an empty body (data === ''),
and one the API cannot split into sentences gets a 200 with [].
statistics() is unbilled; lang is one of en, de, es, fr, it, nl, pl, ru, ar
or auto and picks the readability formulas (readability_scores is empty for auto).
Autocomplete
const { data: completion } = await client.complete('Thanks for getting back to me so');
if (!Array.isArray(completion)) {
// { output: ' quickly.', query: 'Thanks for getting back to me so', hash: '...' }
// When the user takes the suggestion:
await client.acceptComplete(completion.hash, completion.query, completion.output);
}complete() needs an API key with autocomplete enabled and a query of at most 200 characters.
It answers [] when it has no completion. query in the response is the part of the sent
query the prediction continues: the API keeps only the text after the last run of three
spaces. Pass hash, query and output to acceptComplete() unchanged, because the API
checks them against the hash and answers 400 when they don't match.
Language detection
const { data: detected } = await client.langdetect('Bonjour tout le monde, comment allez-vous ?');
// { lang: 'fr', conf: 0.99, name: 'French', reliable: true, script: 'Latn',
// candidates: [{ lang: 'fr', conf: 0.99, name: 'French' }, ...] }
const { data: short } = await client.langdetect('Hola');
// { lang: 'es', conf: 0.95, name: 'Spanish', reliable: false, script: 'Latn', ... }Act on lang only when reliable is true. It is true only when the confidence is high and the
text has enough letters, so short inputs like "OK" or "Hola" are never reliable however high
conf is. script is the text's dominant writing system as an ISO 15924 code: Latn, Cyrl,
Arab, Deva, Hang, Hani for Chinese, and Jpan once the text contains any kana. It is Zzzz
for a script outside the map, and null when the text has no letters. Options are top_k (1–10
candidates, default 3) and segments: true, which adds a per-sentence segments breakdown (start,
end, text plus the same fields) for mixed-language text. Pass an array of 1–10 texts for the
batch form, { results: [...] }. Language detection is unbilled.
Similar terms, profanity and the custom dictionary
const { data: similar } = await client.similarTerms('pet');
// { neighbors: [[0.81, 'dog'], [0.78, 'cat'], [0.75, 'puppy']],
// synonyms: ['darling', 'favorite', 'favourite', ...] }
const { data: profanity } = await client.profanity('This is so damn stupid.');
// { toks: ['This', 'is', 'so', 'damn', 'stupid.'], labels: [0, 0, 0, 1, 1] }
// Words the grammar and spelling checks should accept (product names, jargon).
const { data: entry } = await client.addDictionaryEntry('Sapling', { case_sensitive: true });
// { id: '...', entry: 'Sapling', case_sensitive: true, created_at: '...', updated_at: '...' }
const { data: entries } = await client.dictionaryEntries(); // a bare array, sorted by entry
await client.deleteDictionaryEntry(entry.id); // 204, empty bodysimilarTerms() returns up to five [similarity, term] neighbors (similarity 0–1, most similar
first) plus WordNet synonyms (multi-word ones use underscores); only the first 100 characters of
the query are used. profanity() splits the text on whitespace and labels each token 1 (profane)
or 0. Both are unbilled. addDictionaryEntry() answers 400 for a duplicate entry or one with more
than two digits; deleteDictionaryEntry() answers 400 (not 404) for an id that is not this
account's.
Custom mappings and custom filters
// Always suggest `mapping` wherever `entry` matches (brand names, house style).
const { data: mapping } = await client.addCustomMapping('sapling', 'Sapling', {
description: 'Our product name is capitalized.', // shown with the suggestion; it then can't be ignored
});
// { id: '...', entry: 'sapling', mapping: 'Sapling', description: '...', case_sensitive: true, ... }
const { data: { custom_mappings } } = await client.customMappings(); // sorted by entry
await client.deleteCustomMapping(mapping.id); // 204, empty body
// Stop suggesting edits that rewrite `input` to `output`; '' on either side is a wildcard.
const { data: filter } = await client.addCustomFilter('Patient', 'The patient');
// { id: '...', input: 'Patient', output: 'The patient', case_sensitive: true, ... }
const { data: { custom_filters } } = await client.customFilters(); // sorted by input
await client.deleteCustomFilter(filter.id); // 204, empty bodyIn a mapping's entry, ^ and $ anchor the match to the start and end of a sentence, and #
matches a number (a # in mapping puts it back). case_sensitive defaults to true. The API
answers 400 for a duplicate, an entry equal to its mapping (or a filter whose two sides are equal),
more than two digits in the entry or input, and a case-insensitive rule whose two sides differ only
in case (iphone → iPhone must be case sensitive). A filter needs at least one non-blank side.
Deleting an id that is not this account's answers 400, not 404. All six calls are unbilled.
Style-guide rulesets
Check text against style rules inline, or save a named ruleset once (owned by your API key's account — its team, for team-scoped keys) and check against it by name:
// Define the house style once…
await client.saveStyleguideRuleset('House Style', [
'no exclamation marks',
{ name: 'no jargon', description: 'No corporate buzzwords or filler.' },
]);
// …then check against it by name (names match case-insensitively).
const response = await client.styleguide('Our synergy-driven solution!!', 'House Style');
console.log(response.data);
// { violations: [{ rule: 'no jargon', text: 'synergy-driven solution', ... }],
// rules: ['no exclamation marks', 'no jargon'], compliant: false }
// List and delete:
await client.styleguideRulesets(); // { rulesets: [{ name, rules, ... }] }
await client.deleteStyleguideRuleset('House Style');Frontend Quickstart
The SDK provides a way to automatically apply edit suggestions to any HTML contenteditable and textarea elements.
Do not serve a page like this publicly, as it exposes the API key. See documentation Deploying to Production for an example of setting up the API key with a separate backend environment.
import { useEffect } from 'react';
import { Sapling } from "@saplingai/sapling-js/observer";
function App() {
useEffect(() => {
Sapling.init({
key: '<YOUR_API_KEY>',
endpointHostname: 'https://api.sapling.ai',
editPathname: '/api/v1/edits',
statusBadge: true,
mode: 'dev',
});
const editor = document.getElementById('editor');
Sapling.observe(editor);
});
return (
<div id="editor" sapling-ignore="true" contentEditable="true">
Lets get started!
</div>
);
}Structured Data Extraction
Pull caller-named fields out of unstructured text — invoices, emails, resumes, support tickets.
Pass 1–20 fields (plain names, or { name, type, description, required } objects):
client
.extract(
'Invoice INV-1042 for Acme Corp, total $1,299.00, due March 5, 2026.',
[
'invoice_number',
{ name: 'total', type: 'number', description: 'Amount due', required: true },
{ name: 'due_date', type: 'date' },
'purchase_order',
],
{ context: 'A vendor invoice' },
)
.then(function (response) {
console.log(response.data);
// { data: { invoice_number: 'INV-1042', total: 1299.0, due_date: '2026-03-05', purchase_order: null },
// fields: [{ name: 'invoice_number', type: 'string', value: 'INV-1042',
// evidence: 'Invoice INV-1042', found: true }, ...],
// missing: ['purchase_order'] }
})data always contains every field you asked for, null where the text didn't state it, and each
entry in fields carries the verbatim evidence span its value came from. Values are coerced to
the declared type ('string' (default), 'number', 'integer', 'boolean', 'date' returned
as YYYY-MM-DD, or 'list'), and every value must be grounded in a quote from the text — this is
extraction, not inference, so a value that isn't stated is reported in missing rather than
guessed.
Extracting from documents
fileExtract() runs the same extraction over an uploaded document. It takes the same file types
and 30MB limit as fileAidetect(), and the same fields and context as extract(). Only the
first 10,000 characters of the document's text are read. The response adds a document block that
says how much text was used; characters_used is what is billed.
const { data } = await client.fileExtract(
readFileSync('invoice.pdf'),
['invoice_number', { name: 'total', type: 'number' }, { name: 'due_date', type: 'date' }],
{ filename: 'invoice.pdf', context: 'A vendor invoice' },
);
// { data: { invoice_number: 'INV-1042', total: 1299.0, due_date: '2026-03-05' }, fields: [...],
// missing: [],
// document: { file_type: 'pdf', characters: 2310, characters_used: 2310, truncated: false } }When truncated is true, fields that appear only after the first 10,000 characters are reported
in missing. To read a later section, extract its text yourself and pass it to extract().
Document Summarization
fileSummarize() summarizes an uploaded document: the same summary, key_points and length
as the API's /summarize endpoint, for the same file types and 30MB limit as fileAidetect().
length is 'short', 'medium' (the default) or 'long'. Only the first 20,000 characters of the
document's text are read, and the document block says how much was used; characters_used is
what is billed.
const { data } = await client.fileSummarize(readFileSync('report.pdf'),
{ filename: 'report.pdf', length: 'short' });
// { summary: 'Revenue rose 12% ...', key_points: ['...', '...'], length: 'short', result: '...',
// document: { file_type: 'pdf', characters: 48210, characters_used: 19996, truncated: true } }All three upload methods take a File/Blob (in the browser, or Node 18+) or the file's bytes
(Buffer, Uint8Array, ArrayBuffer). Bytes need options.filename, because the extension picks
the format. File problems come back as 400 { error: '...' } and an oversized upload as 413.
Uploads are limited to 60 per minute per account across all three methods.
Chunking
chunkText() splits long text into chunks of at most maxLength characters, e.g. to keep each
piece under another endpoint's length limit. Each split prefers a page break, then a paragraph
break, a line break, a tab, ending punctuation and any whitespace, before falling back to a hard
cut. Chunks don't overlap, so joining them gives back the original text. chunkHtml() does the
same for HTML after stripping the tags. Both are unbilled.
const { data } = await client.chunkText('Here is one paragraph.\n\nHere is another.', 25);
// { chunks: ['Here is one paragraph.\n\n', 'Here is another.'] }maxLength must be a positive integer. The optional step_size sets how far back from each cut
to look for a split point; it defaults to 10% of the text length. See
Chunking.
pdfToText() and docxToText() return the plain text of an uploaded PDF or Word document, for
example to chunk it before sending it to another endpoint. Pass a File/Blob, or the file's
bytes with filename, which must end in .pdf or .docx. Both are unbilled.
import { readFile } from 'node:fs/promises';
const { data } = await client.pdfToText(await readFile('report.pdf'), { filename: 'report.pdf' });
const { data: chunks } = await client.chunkText(data.text, 4000);On a file the API can't read (password-protected, empty or corrupt), the 400 body is { error }
rather than { msg }. See File Uploads.
Translation
Translate text into a target language, named by ISO 639 code ('fr', 'zh-TW') or English
name ('French'). Line breaks and markup are preserved, and the detected source language is
reported alongside the translation:
client
.translate('Hello world.\nSee you at 5pm.', 'fr', { formality: 'formal' })
.then(function (response) {
console.log(response.data);
// { translation: 'Bonjour le monde.\nÀ 17h.',
// source_lang: 'en', source_lang_name: 'English',
// target_lang: 'fr', target_lang_name: 'French' }
})Region variants zh-CN/zh-TW/zh-HK, pt-BR/pt-PT, en-GB/en-US, fr-CA and
es-419 are supported as targets; the optional source_lang hint never overrides what the
text actually is, and formality ('formal'/'informal') picks the register where the
target language distinguishes them.
Inclusive Language
Flag non-inclusive or biased wording (gendered defaults, ableist idioms, ageist framing, outdated terms for groups) with offsets, a category, a severity and a neutral replacement:
client
.inclusive('The chairman said the plan was crazy.', { categories: ['gender', 'disability'] })
.then(function (response) {
console.log(response.data);
// { issues: [
// { category: 'gender', severity: 'low', text: 'The chairman', start: 0, end: 12,
// note: 'Gendered job title...', suggestion: 'The chair' },
// { category: 'disability', severity: 'low', text: 'crazy', start: 31, end: 36,
// note: 'Ableist idiom...', suggestion: 'unworkable' } ],
// categories: ['gender', 'disability'], inclusive: false }
})Categories: gender, race_ethnicity, disability, age, lgbtq, religion,
socioeconomic, appearance (default: all). Pass an array of up to 10 texts for the batch
form ({ results: [...] }).
Further Documentation
- Full Sapling HTTP API and SDK documentation.
- Create a key by following these instructions.
- JavaScript, HTML, and Python starter code.
Usage reporting
Construct the client with returnUsage: true and every successful response additionally
reports the usage the request submitted for metering, as a top-level usage field:
const client = new Client(apiKey, { returnUsage: true });
const response = await client.classify('I was charged twice.', ['billing', 'other']);
console.log(response.data.usage);
// { characters: 20 }characters is the count submitted for billing (one count per distinct item of a batch
request; 0 for unbilled requests). It is captured before the billing pipeline's dedup
window applies, so it is an upper bound on the final charge. Batch responses carry usage
on the { results: [...] } wrapper, not on the per-item results.
accountStatus() and apiQuotaUsage() are unaffected: both are unbilled and their responses
already have their own usage field, which the option leaves untouched.
Responses that are not a JSON object cannot carry the field, so dictionaryEntries() (a bare
array), deleteDictionaryEntry(), deleteCustomMapping() and deleteCustomFilter() (204,
empty body), sentiment()'s 204 / [] edge cases and the [] answers of tone() and
complete() never report it.
Error handling
Every client method returns the axios promise, so API errors (a 400 for
over-limit input, 401 for a bad key, 429 for rate limits, network errors and
timeouts) reject with the axios error;
inspect error.response.status / error.response.data:
try {
const response = await client.edits(text);
} catch (error) {
console.error(error.response?.status, error.response?.data ?? error.message);
}API error bodies are JSON objects with a human-readable msg, for example
{ msg: 'Invalid API key.' }. The one exception is the 4MB 413, which uses
message/result keys. When a 402 or 429 body has a numeric retry_after,
the request can succeed after waiting that many seconds. Without it, retrying
unchanged won't help. A 503 means the service is temporarily saturated: wait
for its Retry-After header (error.response.headers['retry-after'], in
seconds), then retry. See
Rate Limits & Errors.
To find out why a key is blocked, and for how long, call accountStatus(). It
is unbilled and works while the key is blocked. It reports account-wide blocks:
an expired key or subscription, depleted credits, a billing limit, the trial
daily allowance or the monthly quota. It does not report a single endpoint's
short-term rate limit, because each endpoint has its own allowance. For a
Capacity used. 429, use that response's retry_after instead:
const { data } = await client.accountStatus();
if (!data.api_access) {
// blocked_reason: key_expired | subscription_expired | credits_depleted |
// monthly_billing_limit | trial_daily_limit | monthly_quota_exhausted
const retry = data.retry_after ? `, retry in ${data.retry_after}s` : '';
console.log(`${data.blocked_reason}: ${data.message}${retry}`);
}Every response includes api_access, blocked_reason, message and
usage.monthly_quota_chars. Keys that aren't team-scoped also get the
account's subscription, prepaid credits, usage.chars_this_month and, on a
free trial, trial. See Account Status.
Each call counts as 2,000 characters against the endpoint's own rate-limit
bucket, so call it when something fails rather than before every request.
Usage and quota reports
Four unbilled methods report this key's usage over a window of days, set with
start_days_back / end_days_back (non-negative integers, days before today):
const { data } = await client.apiUsage({ start_days_back: 30 });
console.log(data.total_characters, data.total_cost_usd, data.by_endpoint);apiUsage(): characters and list-price cost by masked key id, endpoint, day and month. The window defaults to the current month. A team-scoped key covers the whole team; other keys also getcredit_balance_data.apiEndpointUsage({ interval }): characters per endpoint, bucketed'Daily'(default),'Weekly'or'Monthly'.apiQuotaUsage(): the monthlyquota(-1for none) and the window'susage.apiUserActivity(): edits shown, accepted and ignored, plus active users. Accepts and ignores are only counted if you report them withacceptEdit()/rejectEdit().
A window over a year gets a 403. Unlike accountStatus(), these need a key that isn't
blocked. See API Reporting.
Health checks
version() and liveness() are unbilled and need no API key. The client doesn't send its key
on these calls.
const { data } = await client.version();
console.log(data.version, data.build); // 2026.6 v4626
await client.liveness(); // { msg: 'alive' }version() reports which release a hostname is serving, which is useful on-prem. build is
'unknown' when the image carries no build number. liveness() only says the API process is up,
so it can succeed while a service behind the API is down. To check that a key works, use
accountStatus().
