openphn
v0.3.1
Published
TypeScript SDK for OpenPhn — agentic voice calls with structured outcome JSON
Downloads
30
Maintainers
Readme
OpenPhn — TypeScript SDK
Agentic voice calls with structured JSON outcomes.
npm install openphnWorks 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 boundary —
consentTypeis 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_idWebhooks
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 verificationVerify 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(nonode-fetch/axiosdep) - TypeScript 5.5+ recommended for best inference
- ESM + CJS bundles (both are shipped)
License
MIT.
Links
- Docs: docs.openphn.com
- REST reference: api.openphn.com/docs
- Source: github.com/knsgill/openphn
- Changelog: docs.openphn.com/docs/changelog
