Product Updates7 min read

Introducing the FoxReach TypeScript SDK

Build type-safe integrations with zero dependencies using the official FoxReach TypeScript SDK, from Node.js or plain JavaScript.

Danish Azam
Danish Azam

Data Infrastructure & Deliverability

Share
Introducing the FoxReach TypeScript SDK

Overview

The official FoxReach TypeScript SDK gives you type-safe access to the FoxReach API. It has zero runtime dependencies, uses the built-in fetch, and ships CommonJS and ES module builds with type declarations, so it works from Node.js 18+ in TypeScript or plain JavaScript. For edge runtimes you can pass your own fetch implementation through the client config. It is one of the official clients on the integrations page, next to the Python SDK, the CLI, and the n8n node.

Install it with npm:

npm install foxreach

The current release on npm is 0.2.0.

Quick Start

The client needs a workspace API key. Keys start with otr_ and are created in the dashboard under Settings > Integrations > API Keys; the API keys and webhooks guide shows how to create one and which scopes to give it. The SDK sends the key in the X-API-Key header on every request to https://api.foxreach.io/api/v1, and the constructor throws if the key does not start with otr_.

import { FoxReach } from "foxreach";

const client = new FoxReach({ apiKey: "otr_your_key" });

// List active leads (first page)
const leads = await client.leads.list({ status: "active" });
for (const lead of leads.data) {
 console.log(`${lead.email} - ${lead.company}`);
}

// Create a new lead
const lead = await client.leads.create({
 email: "jane@example.com",
 firstName: "Jane",
 lastName: "Smith",
 company: "TechCorp",
});
console.log(`Created lead: ${lead.id}`);

What's Included

The SDK covers the v1 API resources:

  • Leads - client.leads.list(), create(), get(), update(), delete(), activity()
  • Campaigns - create(), update(), start(), pause(), resume(), delete(), plus addLeads(), addAccounts(), removeLead(), removeAccount()
  • Sequences - client.campaigns.sequences.list(), create(), update(), delete()
  • Templates - client.templates.list(), get(), create(), update(), delete()
  • Email Accounts - client.emailAccounts.list(), get(), delete()
  • Inbox - client.inbox.listThreads(), get(), update(), getConversation(), sendReply(), stats()
  • Analytics - client.analytics.overview(), campaign()
  • Webhooks - client.webhooks.listEvents(), list(), create(), update(), delete()

Full Type Safety

Every request and response is fully typed. Your editor gives you autocomplete, inline docs, and compile-time error checking:

const campaign = await client.campaigns.create({
 name: "Q1 Enterprise Outreach",
 timezone: "America/New_York",
 sendingDays: [1, 2, 3, 4, 5],
 sendingStartHour: 9,
 sendingEndHour: 17,
 dailyLimit: 50,
});
// campaign is typed as Campaign with all fields

Field names are camelCase end to end, matching the API, so what you see in the network tab is what you write in code. Every type (Lead, LeadCreate, Campaign, Thread, Webhook, and so on) is exported from the package root if you want to annotate your own functions.

Pagination

List endpoints resolve to one page at a time as a PaginatedResponse. data holds the items and meta tells you where you are:

// Get a single page
const page = await client.leads.list({ page: 1, pageSize: 50 });
console.log(`Total: ${page.meta.total}, Page: ${page.meta.page} of ${page.meta.totalPages}`);

if (page.hasNextPage()) {
 const next = await page.nextPage();
}

// Iterate all pages with an async iterator
const active = await client.leads.list({ status: "active" });
for await (const lead of active.autoPagingIter()) {
 console.log(lead.email);
}

autoPagingIter() requests the next page only when you have consumed the current one, so breaking out of the loop early does not fetch pages you never read. pageSize accepts up to 100.

Lead Tags

Leads carry a list of tags, which is the natural way to group an imported list. Pass tags on create or update, and filter with a comma-separated tags string on list (a lead matches if it has any of the named tags):

await client.leads.create({ email: "jane@example.com", tags: ["dental", "q3-list"] });
await client.leads.update("cld_abc123", { tags: ["dental", "warm"] });

const tagged = await client.leads.list({ tags: "dental,warm" });
for await (const lead of tagged.autoPagingIter()) {
 console.log(lead.email, lead.tags);
}

Omitting tags on an update leaves the current set untouched; passing an empty array clears it.

Inbox

The inbox resource reads replies and answers them from the same account and thread the lead wrote to:

// Unread replies that the categorizer marked interested
const threads = await client.inbox.listThreads({ category: "interested", isRead: false });
for (const t of threads.data) {
 console.log(t.fromEmail, t.subject);
}

// The whole back-and-forth with that lead
const conversation = await client.inbox.getConversation("rpl_abc123");

// Mark as read, then reply in-thread
await client.inbox.update("rpl_abc123", { isRead: true });
await client.inbox.sendReply("rpl_abc123", { body: "Thanks Jane, does Thursday at 10am work?" });

// Counts by category
const stats = await client.inbox.stats();
console.log(stats.unread, stats.interested);

listThreads() also filters by campaignId, accountId, isStarred, and search.

Error Handling

API errors are thrown as typed exceptions. Every one carries statusCode and responseBody, and message is the API's message:

import { FoxReachError, NotFoundError, ValidationError, RateLimitError, AuthenticationError } from "foxreach";

try {
 const lead = await client.leads.get("cld_nonexistent");
} catch (error) {
 if (error instanceof NotFoundError) {
 console.log("Lead not found");
 } else if (error instanceof ValidationError) {
 console.log(`Invalid request: ${error.message}`);
 } else if (error instanceof RateLimitError) {
 console.log(`Rate limited, retry after ${error.retryAfter}s`);
 } else if (error instanceof AuthenticationError) {
 console.log("Invalid or revoked API key");
 } else if (error instanceof FoxReachError) {
 console.log(`API error ${error.statusCode}: ${error.message}`);
 }
}

The mapping is 400 BadRequestError, 401 AuthenticationError, 403 PermissionError (the key lacks the read or write scope the endpoint needs), 404 NotFoundError, 422 ValidationError, 5xx ServerError, and ConnectionError for network failures and timeouts.

The v1 API allows 100 requests per minute per key. On a 429 the SDK waits for the number of seconds in the Retry-After header and retries, up to maxRetries times (default 3), before throwing RateLimitError. You can tune that, the timeout, the base URL, and the fetch implementation on the client:

const client = new FoxReach({
 apiKey: "otr_your_key",
 baseUrl: "https://api.foxreach.io/api/v1", // default
 timeout: 30000, // ms
 maxRetries: 3, // retries on 429
 fetch: customFetch, // optional, for edge runtimes
});

End-to-End Example: Campaign Setup

Here's a complete script that creates leads, builds a campaign, and starts it:

import { FoxReach } from "foxreach";

const client = new FoxReach({ apiKey: "otr_your_key" });

// 1. Create leads
const contacts = [
 { email: "sarah@techcorp.io", firstName: "Sarah", lastName: "Chen", company: "TechCorp" },
 { email: "mike@startupco.com", firstName: "Mike", lastName: "Johnson", company: "StartupCo" },
 { email: "lisa@enterprise.com", firstName: "Lisa", lastName: "Wang", company: "Enterprise Ltd" },
];

const leads = await Promise.all(
 contacts.map((contact) => client.leads.create({ ...contact, tags: ["q1-outreach"] }))
);
console.log(`Created ${leads.length} leads`);

// 2. Create a campaign
const campaign = await client.campaigns.create({
 name: "Q1 Outreach",
 timezone: "America/New_York",
 sendingDays: [1, 2, 3, 4, 5],
 sendingStartHour: 9,
 sendingEndHour: 17,
 dailyLimit: 50,
});

// 3. Add sequence steps
await client.campaigns.sequences.create(campaign.id, {
 subject: "Quick question about {{company}}",
 body: "Hi {{firstName}},\n\nI noticed {{company}} is growing fast...",
 delayDays: 0,
});

await client.campaigns.sequences.create(campaign.id, {
 subject: "Re: Quick question about {{company}}",
 body: "Hi {{firstName}},\n\nJust following up...",
 delayDays: 3,
});

// 4. Add leads to campaign
await client.campaigns.addLeads(campaign.id, leads.map((l) => l.id));

// 5. Assign email account
const accounts = await client.emailAccounts.list();
await client.campaigns.addAccounts(campaign.id, [accounts.data[0].id]);

// 6. Start the campaign
await client.campaigns.start(campaign.id);
console.log(`Campaign '${campaign.name}' is now active!`);

Subscribe to Webhooks from Code

You can create the webhook subscription itself with the SDK, which is what the n8n trigger node and the Zapier and Make apps do behind the scenes:

console.log(await client.webhooks.listEvents());
// ["email.sent", "email.failed", "email.bounced", ..., "reply.received", "reply.categorized", ...]

const webhook = await client.webhooks.create({
 url: "https://example.com/webhooks/foxreach",
 events: ["reply.received", "email.bounced"],
});
console.log(webhook.secret); // returned once, on create only

Store the secret somewhere safe: list() and update() do not return it again. webhook.consecutiveFailures tells you when your endpoint has been failing.

Use with Express for Webhook Handling

The SDK pairs well with Express for receiving webhook events. Each delivery is a JSON object with type, payload, and timestamp fields, sent with an X-Webhook-Signature header that holds the HMAC-SHA256 hex digest of the raw body computed with your webhook's secret (the same delivery also carries X-Event-Type and X-Event-Id). Verify the signature against the raw bytes before parsing:

import express from "express";
import crypto from "crypto";

const app = express();
const WEBHOOK_SECRET = process.env.FOXREACH_WEBHOOK_SECRET!;

app.post("/webhooks/foxreach", express.raw({ type: "application/json" }), (req, res) => {
 const signature = req.headers["x-webhook-signature"] as string;
 const expected = crypto
 .createHmac("sha256", WEBHOOK_SECRET)
 .update(req.body)
 .digest("hex");

 if (!signature || signature.length !== expected.length ||
 !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) {
 return res.status(401).send("Invalid signature");
 }

 const event = JSON.parse(req.body.toString());
 console.log(`Received event: ${event.type}`);

 // Handle events
 switch (event.type) {
 case "reply.received":
 console.log(`New reply from ${event.payload.fromEmail}: ${event.payload.subject}`);
 break;
 case "email.bounced":
 console.log(`${event.payload.bounceType} bounce for ${event.payload.toEmail}`);
 break;
 }

 res.sendStatus(200);
});

app.listen(3000);

Respond with a 2xx quickly and do the real work afterwards. FoxReach waits ten seconds for a response and retries a failed delivery twice more before marking it failed.

Resources

Getting Started

From zero to agent-driven outreach in under an hour

Free plan with full MCP + SDK access. Generate an API key, connect Claude Desktop, and ship your first campaign today.

Was this article helpful?

Your feedback helps us improve what we write.

Frequently asked questions

Run npm install foxreach (or the yarn or pnpm equivalent). It needs Node.js 18 or newer because it uses the built-in fetch, and it has zero runtime dependencies. Create a client with new FoxReach({ apiKey: "otr_..." }) using a workspace API key from Settings > Integrations > API Keys.

Topics

TypeScriptSDKdeveloper toolsAPINode.js
Danish Azam

Written by

Danish Azam

Data Infrastructure & Deliverability

Danish works on the data and deliverability side of cold email. He writes about email authentication, sending patterns, and the infrastructure behind high-inbox outbound.

View all articles by Danish

Stay ahead of the inbox

Cold email patterns for AI agents, deliverability updates, and product releases.

Browse more posts