npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

openphn

v0.3.1

Published

TypeScript SDK for OpenPhn — agentic voice calls with structured outcome JSON

Downloads

30

Readme

OpenPhn — TypeScript SDK

Agentic voice calls with structured JSON outcomes.

npm install openphn

Works in Node 18+, browsers, and edge runtimes (Cloudflare Workers, Vercel Edge, Deno, Bun). Native Fetch — no HTTP library dependency.

Quickstart

import { OpenPhn } from "openphn";

const client = new OpenPhn({ apiKey: process.env.OPENPHN_API_KEY! });

const result = await client.call({
  to: "+14155551234",
  objective: "Confirm order A-14421 ships today",
  outcomeSchema: {
    will_ship_today: { type: "boolean" },
    tracking:        { type: "string",   optional: true },
    eta:             { type: "datetime", optional: true },
  },
  consentType: "existing_business_relationship",
});

console.log(result.outcome);
// { will_ship_today: true, tracking: "1Z...", eta: "2026-04-23T18:00:00-07:00" }

client.call() blocks until the call reaches a terminal status by default. Pass wait: false for fire-and-forget (pair with a webhook).

Why OpenPhn

  • Structured JSON per call — you define outcomeSchema; we return typed fields with per-field confidence and ambiguity flags.
  • Compliance at the API boundaryconsentType is required; 8am–9pm call-hour rules are enforced server-side; DNC scrubbing is native.
  • Hosted MCP server — Claude Desktop / Cursor can place calls via OpenPhn as a tool.
  • Published-and-frozen flows — edit graphs freely; publish freezes the artifact so runtime doesn't silently drift.

Docs: docs.openphn.com. REST reference: api.openphn.com/docs.

Typed errors

Every non-2xx becomes a typed subclass of OpenPhnError. Switch on what actually happened:

import {
  OpenPhn,
  DNCBlockedError,
  RateLimitError,
  ValidationError,
  VerificationPendingError,
  OpenPhnError,
} from "openphn";

try {
  await client.call({ to: "+14155551234", ... });
} catch (err) {
  if (err instanceof DNCBlockedError) {
    return; // destination on suppression — don't retry
  }
  if (err instanceof VerificationPendingError) {
    return; // account not yet approved
  }
  if (err instanceof RateLimitError) {
    await sleep((err.retryAfterSeconds ?? 30) * 1000);
    return client.call(...);
  }
  if (err instanceof OpenPhnError) {
    console.error(err.statusCode, err.errorCode, err.message);
  }
  throw err;
}

| Class | Status | When | |---|---|---| | AuthenticationError | 401 | Missing / malformed / revoked key | | PermissionDeniedError | 403 | Valid key, insufficient scope or number pinning | | DNCBlockedError | 403 (dnc_blocked) | Destination on suppression list | | VerificationPendingError | 403 (verification_pending) | Account not approved yet | | NotFoundError | 404 | Resource doesn't exist for your tenant | | ConflictError | 409 | Idempotency key reused with different body | | ValidationError | 422 | Semantically-invalid request | | ConsentError | 422 (consent_*) | Consent type missing / invalid | | RateLimitError | 429 | Over quota. Has .retryAfterSeconds. | | ServerError | 5xx | Transient. Safe to retry idempotent requests. |

Auto-retry

The client retries on 429 and 5xx for idempotent requests (GET, DELETE, and any POST with idempotencyKey set). Exponential backoff with jitter; honors Retry-After.

// Disable or tune:
new OpenPhn({ apiKey: "sk_live_...", maxRetries: 0 });
new OpenPhn({ apiKey: "sk_live_...", maxRetries: 5, timeoutMs: 60_000 });

Idempotency

call() and createBatch() accept idempotencyKey. Re-sending a request with the same key within 24h returns the original response — no duplicate call.

import { randomUUID } from "node:crypto";

const key = `order-${orderId}-${randomUUID()}`;
const r1 = await client.call({ to: "+14155551234", ..., idempotencyKey: key });
// network blip. safe retry:
const r2 = await client.call({ to: "+14155551234", ..., idempotencyKey: key });
// r1.call_id === r2.call_id

Webhooks

const wh = await client.createWebhook(
  "https://example.com/webhooks/openphn",
  { description: "Prod call-completion handler" },
);
console.log(wh.secret); // SHOWN ONCE — save it for HMAC verification

Verify HMAC-SHA256 on delivery:

import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyOpenPhnSignature(
  rawBody: string,
  timestamp: string,
  signature: string,
  secret: string,
): void {
  const expected =
    "sha256=" +
    createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
  if (!timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) {
    throw new Error("bad signature");
  }
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
    throw new Error("timestamp too old");
  }
}

Inspect delivery history (retries + response bodies) and manually replay:

const deliveries = await client.listWebhookDeliveries("wh_01HV...");
for (const d of deliveries) {
  console.log(d.attempt, d.status_code, d.latency_ms);
}
await client.retryWebhookDelivery("wh_01HV...", "dlv_01HV...");

DNC (Do Not Call)

Upload up to 10,000 phone numbers per CSV. E.164 normalized, NANP-only, deduped.

// Node:
import { readFile } from "node:fs/promises";
const csv = await readFile("suppressions.csv");
const result = await client.uploadDnc(csv, "suppressions.csv");
console.log(result.added_count);

// Browser (from a <input type="file">):
const file = input.files![0];
await client.uploadDnc(file, file.name);

Subsequent call() to any listed number throws DNCBlockedError.

Paginate through calls

for await (const call of client.iterCalls({ status: "delivered" })) {
  console.log(call.call_id, call.outcome);
}

BYO Twilio (outbound)

Outbound calls run on your own Twilio subaccount.

const verified = await client.verifyTwilio(
  "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  "your-twilio-auth-token",
);
// verified.numbers is the list of SIDs on that subaccount

await client.selectNumber("+14155551234");

Configure an inbound number

await client.updateNumber("num_01HV...", {
  greeting_text: "Hi, this is the virtual receptionist for Northside Dental.",
  transfer_destinations: [
    { label: "Front desk",      phone: "+14155551111", enabled: true, order: 0 },
    { label: "On-call manager", phone: "+14155552222", enabled: true, order: 1 },
  ],
  recording_enabled: true,
});

Analytics summary

const s = await client.analyticsSummary({ range: "30d" });
console.log(`${s.total_calls} calls · ${(s.success_rate * 100).toFixed(1)}% success · `
          + `greeting p50 ${s.greeting_latency_p50_ms}ms`);

Whoami

const me = await client.me();
console.log(me.email, me.verification_status, me.scopes);

Base URL override

For staging or a self-hosted deploy:

new OpenPhn({
  apiKey: "sk_live_...",
  baseUrl: "https://api.staging.openphn.com",
});

Edge / non-Node runtimes

On runtimes without globalThis.fetch, inject your own:

new OpenPhn({ apiKey: "sk_live_...", fetch: customFetch });

Compatibility

  • Node 18+
  • Native fetch (no node-fetch / axios dep)
  • TypeScript 5.5+ recommended for best inference
  • ESM + CJS bundles (both are shipped)

License

MIT.

Links