@assertkit/node
v0.6.0
Published
Official Node SDK for AssertKit — wait for emails, extract OTPs, run flows, manage inboxes and custom domains.
Downloads
2,183
Maintainers
Readme
@assertkit/node
Official Node SDK for AssertKit — the QA toolkit for ship-day confidence. Wait for emails in tests without polling, extract OTPs and magic links, run flows from CI, manage inboxes and custom domains.
npm install @assertkit/nodeQuick start
import { AssertKit } from "@assertkit/node";
const ak = new AssertKit({ apiKey: process.env.ASSERTKIT_API_KEY });
const inbox = ak.inbox("qa-signup");
// Trigger your signup flow…
await fetch("https://api.your-app.com/signup", {
method: "POST",
body: JSON.stringify({ email: inbox.address }),
});
// Then wait for the OTP — returns the moment it arrives.
const otp = await inbox.waitForOtp({ from: "[email protected]" });
console.log("Got OTP:", otp);Auth
Most operations need an API key from /dashboard/api-keys. Read-only wait/list against the public disposable-mail surface works without one.
// Authenticated (uses /api/v1/*, plan-based rate limits, custom domains)
const ak = new AssertKit({ apiKey: "ak_live_..." });
// Or set ASSERTKIT_API_KEY in your env and just:
const ak = new AssertKit();
// Anonymous (uses /api/public/*, IP rate-limited, default domain only)
const ak = new AssertKit();Core API
client.inbox(localPart) — fluent inbox handle
const inbox = ak.inbox("qa-signup");
inbox.address; // "[email protected]"inbox.wait(filters) — long-poll for next matching message
Returns the moment a matching message arrives. Long-polls up to 25s (configurable) on a single HTTP request — no client-side polling loop.
const r = await inbox.wait({
from: "[email protected]", // substring match
subjectContains: "verify", // substring match
kind: "otp", // only return if an OTP was extracted
timeoutMs: 15_000,
since: new Date(Date.now() - 5 * 60 * 1000), // last 5 min
});
if ("timeout" in r) {
// No matching mail arrived within the window
} else {
const otp = r.codes.find(c => c.kind === "otp")?.value;
}inbox.waitForOtp(filters) — direct OTP value
Convenience over wait(). Throws on timeout or no-OTP-found.
const otp = await inbox.waitForOtp({ from: "noreply", timeoutMs: 10_000 });inbox.waitForMagicLink(filters) — direct magic-link URL
const link = await inbox.waitForMagicLink({ from: "[email protected]" });
await page.goto(link); // click through to verifyinbox.messages() — list current messages
const msgs = await inbox.messages();
// [{ id, subject, from_addr, snippet, received_at, ... }]inbox.codes() — all currently-extracted codes
const codes = await inbox.codes();
// [{ kind: "otp", value: "472913", ... }]inbox.wipe() — clear all messages
Only works on public disposable inboxes (owned/private inboxes are protected — wipe via the dashboard or per-message delete).
const { deleted } = await inbox.wipe();client.send(input) — outbound email
Admin/Pro+ only. From-allowlist server-validated.
await ak.send({
fromLocal: "support",
to: "[email protected]",
subject: "Welcome",
text: "Hi there",
html: "<p>Hi there</p>",
});client.runFlow({ flowId, waitForCompletion }) — trigger a flow
const run = await ak.runFlow({
flowId: "signup-smoke",
waitForCompletion: true,
timeoutMs: 60_000,
});
if (run.status === "failed") throw new Error("Flow failed");Test-isolation helper
uniqueLocalPart() generates unique inbox names so parallel runs don't collide:
import { AssertKit, uniqueLocalPart } from "@assertkit/node";
test("signup", async () => {
const inbox = ak.inbox(uniqueLocalPart("signup"));
// → "[email protected]" — unique per test
...
});Custom domains
If you've verified a custom MX domain at /dashboard/domains:
const ak = new AssertKit({
apiKey: process.env.ASSERTKIT_API_KEY,
domain: "mail.your-company.com",
});
ak.inbox("support").address; // "[email protected]"Rate limits + retries
The SDK auto-retries on 429 Too Many Requests and 503 Service Unavailable — up to 3 times by default, honoring the server's Retry-After header (clamped to 10s per retry). Other 4xx/5xx are NOT retried (writes shouldn't retry blindly; client errors won't fix themselves).
const ak = new AssertKit({
apiKey: "...",
maxRetries: 3, // 0 = disable auto-retry
maxBackoffMs: 10_000, // cap on a single Retry-After wait
});After retries are exhausted the AssertKitError surfaces with status: 429 so your code can fall back / circuit-break.
Plan tiers + endpoint requirements
| Operation | Plan required |
|---|---|
| inbox.wait() / waitForOtp() / waitForMagicLink() | Free (API-key) or anonymous (public surface) |
| inbox.messages() / inbox.codes() | Free |
| inbox.wipe() | Free (public disposable only — owned/private inboxes refuse) |
| client.listInboxes() | Free (API-key required) |
| client.getMessage() / deleteMessage() | Free |
| client.send() | Pro+ (features.outboundSendApi) |
| client.runFlow() | Free — quota: flowRunsPerDay (Free 5 / Pro 100 / Team unlimited) |
| Custom-MX domain | Pro+ (features.customDomains) — pass domain in ClientOptions |
When you hit a tier gate, the API returns 402 Payment Required with an error body. The SDK propagates this as AssertKitError with status: 402.
Input validation
The SDK rejects bad inputs locally (before consuming a rate-limit token):
- Local-parts must match
/^[a-z0-9._-]{1,64}$/i.ak.inbox("foo bar")throws. timeoutMsclamped to[250, 25_000].runFlow({ pollIntervalMs })clamped to[250, 30_000]; max 600 poll iterations (10 min at 1s).maxRetriesclamped to[0, 10].maxBackoffMsclamped to[100, 60_000].
Errors
All non-2xx responses throw AssertKitError:
import { AssertKitError } from "@assertkit/node";
try {
await inbox.waitForOtp({ from: "noreply" });
} catch (e) {
if (e instanceof AssertKitError && e.status === 429) {
// rate-limited — back off
}
throw e;
}Common pitfalls
| Pitfall | What happens | Fix |
|---|---|---|
| Passing a full email to client.inbox() | Now works — auto-splits on @. The embedded domain overrides the client's default for that handle. | client.inbox("[email protected]") ✓ — or client.inbox("support") if you want to use the client's default. |
| Passing a full email to send({ fromLocal }) | Throws — fromLocal is just the local-part. | Use the new inbox.send({ to, subject, text }) shortcut to skip the split entirely, OR pass parseAddress("[email protected]") to get { localPart, domain }. |
| Pasting Outlook-style ; -separated recipients | Now works — to/cc/bcc accept comma, semicolon, OR newline. | inbox.send({ to: "[email protected]; [email protected]\[email protected]" }) ✓ |
| Typing kind: "magic-link" (with a dash) | Now throws locally with the valid options listed. | Use kind: "magic_link" (underscore). |
| Passing a non-UUID to getMessage / runFlow / getFlowRun | Now throws locally with a clear "not a UUID" error. | Pass the 36-char UUID from the message/flow/run object. |
| Logging the client (e.g. for debugging) leaking the API key | console.log(client) masks the key by default. | Nothing — already handled. |
| client.send() from a non-Pro plan | Throws AssertKitError with code: "upgrade_required" and an upgrade-page hint. | Branch on e.code to handle gracefully. |
| Test suite hangs forever on a stuck flow | runFlow({ waitForCompletion: true, timeoutMs }) throws after the wall-clock timeout AND after 600 poll iterations as belt-and-braces. | Either is fine; tighten timeoutMs for faster failure. |
| Tests leak pending requests past their teardown | Call client.close() in your test teardown; subsequent ops throw AssertKitError("client_closed") so leaks surface as real failures. | Add afterAll(() => client.close()). |
| ASSERTKIT_API_KEY unset, silently falling back to anonymous | AssertKit.fromEnv() throws if the env var isn't set. | Use AssertKit.fromEnv() in test setup instead of new AssertKit(). |
Helper exports
import { parseAddress, uniqueLocalPart } from "@assertkit/node";
parseAddress("[email protected]");
// → { localPart: "support", domain: "your.com" }
uniqueLocalPart("signup");
// → "signup-3f8a2b1d4c5e6f70" (64-bit random suffix)License
MIT
