The Inbox Check Node.js SDK gives TypeScript-first access to the placement API: typed models for every request and response, an async iterable for streaming results, a webhook signature helper, and drop-in handlers for Express and Next.js route handlers. Ten minutes from an empty project to a running placement test.
Node.js 18+ (for the built-in fetch and ReadableStream), and an Inbox Check API key. TypeScript is optional but recommended — the SDK ships its own .d.ts and works out of the box with any reasonable tsconfig.
npm install
npm install inbox-check
# or with pnpm:
pnpm add inbox-check
# or yarn:
yarn add inbox-checkThe package is published as inbox-check. Dual ESM/CJS build — use import in ESM projects and require in CJS; both work.
TypeScript setup
Any reasonable tsconfig.json works. The SDK is happy with moduleResolution set to bundler, node, or nodenext. If you are on a fresh project:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"lib": ["ES2022", "DOM"]
}
}Client initialisation
Put the key in .env and read it from process.env:
// client.ts
import { InboxCheck } from 'inbox-check';
export const ic = new InboxCheck({
apiKey: process.env.INBOX_CHECK_API_KEY!,
// optional:
baseUrl: 'https://check.live-direct-marketing.online',
timeout: 30_000, // ms
maxRetries: 3, // retry on 5xx
});First placement test
Three fields required — sender domain, subject, HTML body. Everything else is optional. The response is fully typed:
import { ic } from './client';
const test = await ic.tests.create({
senderDomain: 'news.acme.io',
subject: 'Weekly digest',
html: '<html><body><p>Hello from the monitor.</p></body></html>',
text: 'Hello from the monitor.', // plain-text fallback
from: 'hello@news.acme.io', // optional
});
console.log(test.id); // "t_01H9X..."
console.log(test.status); // "queued"Polling with types
The built-in helper polls until the test completes and returns the typed result. No manual loop, no setTimeout bookkeeping.
const result = await ic.tests.wait(test.id, {
intervalMs: 3_000, // poll every 3s (default)
timeoutMs: 5 * 60_000, // 5 minutes (default)
});
// result is typed as CompleteTestResult
console.log(result.summary.inboxRate); // number in [0, 1]
console.log(result.summary.inboxCount, '/', result.summary.total);
for (const p of result.providers) {
console.log(`${p.name}: ${p.folder}`);
}Streaming with AsyncIterable
If you want per-seed events as they arrive — a nice UX for a CLI or a dashboard — use the stream iterator. Under the hood it consumes the server's SSE feed; you get a regular for await ... of loop with discriminated events.
for await (const event of ic.tests.stream(test.id)) {
switch (event.type) {
case 'placement': {
console.log(`[${event.data.name}] ${event.data.folder}`);
break;
}
case 'complete': {
const s = event.data.summary;
console.log(`Done: ${(s.inboxRate * 100).toFixed(1)}% inbox`);
return;
}
case 'error': {
throw new Error(event.data.message);
}
}
}Breaking out of the for await loop automatically releases the underlying connection. If you call ic.tests.stream(id) and never iterate, you leak the socket. Always either iterate it or call .return() on the returned iterator.
Webhook helper
When you register a webhook URL in the dashboard, Inbox Check POSTs a signed payload on every test completion. Verifying the signature in your handler takes one line:
import { verifyWebhook } from 'inbox-check/webhooks';
// In an Express or Next.js route handler:
const ok = verifyWebhook({
payload: rawBody, // raw Buffer / string
signature: req.headers['inbox-check-signature']!, // hex digest
secret: process.env.INBOX_CHECK_WEBHOOK_SECRET!,
toleranceSeconds: 300, // default 300
});
if (!ok) {
return res.status(401).end('bad signature');
}
const event = JSON.parse(rawBody);
if (event.type === 'test.completed') {
console.log(event.data.summary.inboxRate);
}Common error classes
Every failure maps to a typed exception so your catch blocks are informative:
import {
AuthError, // 401
RateLimitError, // 429 (exposes .retryAfter)
ValidationError, // 400
NotFoundError, // 404
ServerError, // 5xx after retries
InboxCheckError, // base class
} from 'inbox-check';
try {
const test = await ic.tests.create({ /* ... */ });
const result = await ic.tests.wait(test.id);
} catch (err) {
if (err instanceof RateLimitError) {
console.warn(`Backoff ${err.retryAfter}s`);
} else if (err instanceof AuthError) {
console.error('Bad or revoked API key');
} else if (err instanceof ValidationError) {
console.error(`Invalid payload: ${err.message}`);
} else if (err instanceof InboxCheckError) {
console.error('Other SDK error', err);
} else {
throw err;
}
}Integrating with Express / Next.js API routes
A typical pattern: a dashboard endpoint that returns the latest placement for one sender. The SDK hides the HTTP plumbing so your handler stays small.
// app/api/placement/route.ts (Next.js 14 App Router)
import { NextResponse } from 'next/server';
import { InboxCheck } from 'inbox-check';
const ic = new InboxCheck({ apiKey: process.env.INBOX_CHECK_API_KEY! });
export async function GET(req: Request) {
const url = new URL(req.url);
const domain = url.searchParams.get('domain');
if (!domain) return NextResponse.json({ error: 'domain required' }, { status: 400 });
const { items } = await ic.tests.list({ senderDomain: domain, limit: 1 });
const latest = items[0];
if (!latest) return NextResponse.json({ placement: null });
return NextResponse.json({
placement: {
at: latest.createdAt,
rate: latest.summary.inboxRate,
spf: latest.auth.spf,
dkim: latest.auth.dkim,
dmarc: latest.auth.dmarc,
},
});
}In Express, the same body inside an app.get('/api/placement', ...) handler works unchanged (minus the NextResponse wrapper).
A complete 50-line dashboard endpoint
Aggregates 7 days of placement per sender, returns a single JSON suitable for a chart. Paste this into a route handler and you have the backend for a mini-dashboard.
// app/api/dashboard/route.ts
import { NextResponse } from 'next/server';
import { InboxCheck } from 'inbox-check';
const ic = new InboxCheck({ apiKey: process.env.INBOX_CHECK_API_KEY! });
export async function GET() {
const since = new Date(Date.now() - 7 * 86400_000).toISOString();
const { items } = await ic.tests.list({ since, limit: 200 });
const perDomain = new Map<string, {
domain: string;
tests: number;
inbox: number;
total: number;
rate: number;
perProvider: Record<string, { inbox: number; spam: number; missing: number }>;
}>();
for (const t of items) {
const entry = perDomain.get(t.senderDomain) ?? {
domain: t.senderDomain,
tests: 0, inbox: 0, total: 0, rate: 0, perProvider: {},
};
entry.tests += 1;
entry.inbox += t.summary.inboxCount;
entry.total += t.summary.total;
for (const p of t.providers) {
entry.perProvider[p.name] ??= { inbox: 0, spam: 0, missing: 0 };
entry.perProvider[p.name][p.folder] += 1;
}
perDomain.set(t.senderDomain, entry);
}
const rows = Array.from(perDomain.values()).map((r) => ({
...r,
rate: r.total === 0 ? 0 : +(r.inbox / r.total).toFixed(3),
}));
return NextResponse.json({
window: '7d',
senders: rows.sort((a, b) => a.rate - b.rate),
});
}