@osmapi/osmtalk-sdk 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,11 +2,15 @@
2
2
 
3
3
  Official TypeScript / JavaScript SDK for the [osmTalk](https://osmtalk.com) voice AI platform.
4
4
 
5
+ [![npm](https://img.shields.io/npm/v/@osmapi/osmtalk-sdk.svg)](https://www.npmjs.com/package/@osmapi/osmtalk-sdk)
6
+
5
7
  ```bash
6
8
  npm install @osmapi/osmtalk-sdk
7
9
  # or: pnpm add @osmapi/osmtalk-sdk
8
10
  ```
9
11
 
12
+ Works in **Node 18+, Deno, Bun, Cloudflare Workers, and browsers**.
13
+
10
14
  ## Quick start
11
15
 
12
16
  ```ts
@@ -34,11 +38,25 @@ console.log("Call started:", call.callId);
34
38
  | Resource | Operations |
35
39
  |---|---|
36
40
  | `client.agents` | `list`, `get`, `create`, `update`, `delete`, `connect`, `publishVersion`, `listVersions`, `getVersion`, `rollbackToVersion` |
37
- | `client.calls` | `list`, `get`, `outbound`, `end`, `transfer` |
41
+ | `client.calls` | `list`, `get`, `outbound`, `end`, `transfer`, **`waitUntilEnded`** |
38
42
  | `client.campaigns` | `list`, `get`, `create`, `update`, `delete`, `start`, `pause`, `resume`, `stop`, `report`, `uploadLeadsCsv`, `uploadLeads`, `listLeads` |
39
43
  | `client.dnc` | `list`, `add`, `bulkAdd`, `remove` |
40
44
  | `client.eval` | `simulate`, `createTestCase`, `listTestCases`, `runTestCase`, `runAll`, `listRuns`, `getRun` |
41
45
  | `client.settings` | `get`, `getStorage`, `updateStorage`, `getWebhook`, `updateWebhook`, `getCompliance`, `updateCompliance` |
46
+ | `client.platform` | `getRates`, `listProviders`, `getPresets`, `getModelHealth`, `getTemplates`, `getTemplate`, `saveTemplate`, `deleteTemplate` |
47
+
48
+ Plus the standalone helpers `verifyWebhookSignature` / `verifyWebhookSignatureAsync` (see below).
49
+
50
+ ## What's new in 0.3.0
51
+
52
+ - **Auto-retry** on 5xx, 429, and network errors with `Retry-After` honored and exponential backoff. No more hand-rolling retry wrappers.
53
+ - **`client.calls.waitUntilEnded(callId)`** — one-line polling helper for the "place call → wait → get result" pattern.
54
+ - **`AbortSignal`** support on every method via `RequestOptions.signal`.
55
+ - **`User-Agent`** header sent automatically.
56
+ - **Per-org request override** via `RequestOptions.organizationId`.
57
+ - **`OsmtalkError.isRetryable` / `.isClientError` / `.retryAttempts`** for cleaner error branching.
58
+
59
+ Full version history: [CHANGELOG.md](./CHANGELOG.md).
42
60
 
43
61
  ## Examples
44
62
 
@@ -68,6 +86,45 @@ const report = await client.campaigns.report(camp.id);
68
86
  console.log(report.counts.byStatus);
69
87
  ```
70
88
 
89
+ ### Wait for a call to finish (without writing a poll loop)
90
+
91
+ ```ts
92
+ const { callId } = await client.calls.outbound({
93
+ agentId: "agent_xxx",
94
+ phoneNumberId: "pn_xxx",
95
+ destination: "+919876543210",
96
+ });
97
+
98
+ // Default: poll every 5s, give up after 30 minutes. All configurable.
99
+ const final = await client.calls.waitUntilEnded(callId, {
100
+ pollIntervalMs: 5_000,
101
+ timeoutMs: 15 * 60 * 1000,
102
+ });
103
+
104
+ console.log("Final status:", final.status);
105
+ console.log("Duration: ", final.durationSeconds, "s");
106
+ console.log("Disposition: ", final.disposition);
107
+ console.log("Recording: ", final.recordingUrl);
108
+ ```
109
+
110
+ For production, prefer webhooks — see the receiver example below.
111
+
112
+ ### Cancel an in-flight request
113
+
114
+ ```ts
115
+ const ctrl = new AbortController();
116
+ setTimeout(() => ctrl.abort(), 2_000);
117
+
118
+ try {
119
+ await client.agents.list({ signal: ctrl.signal });
120
+ } catch (err) {
121
+ if (ctrl.signal.aborted) console.log("Cancelled by us");
122
+ else throw err;
123
+ }
124
+ ```
125
+
126
+ `signal`, `timeoutMs`, and `organizationId` are accepted on every method via the trailing `RequestOptions` argument.
127
+
71
128
  ### Publish a new agent version and A/B test
72
129
 
73
130
  ```ts
@@ -75,7 +132,12 @@ console.log(report.counts.byStatus);
75
132
  const v2 = await client.agents.publishVersion("agent_xxx", { label: "Tighter qualifier" });
76
133
 
77
134
  // Call with v2 explicitly
78
- await client.calls.outbound({ agentId: "agent_xxx", phoneNumberId: "pn_xxx", destination: "+919876543210", agentVersion: v2.version });
135
+ await client.calls.outbound({
136
+ agentId: "agent_xxx",
137
+ phoneNumberId: "pn_xxx",
138
+ destination: "+919876543210",
139
+ agentVersion: v2.version,
140
+ });
79
141
  ```
80
142
 
81
143
  ### Simulate before going live
@@ -91,33 +153,54 @@ for (const turn of sim.transcript) {
91
153
  }
92
154
  ```
93
155
 
94
- ### Handle the webhook in Node.js
156
+ ### Verify webhooks (Node, sync)
95
157
 
96
158
  ```ts
97
- import crypto from "node:crypto";
98
159
  import express from "express";
160
+ import { verifyWebhookSignature } from "@osmapi/osmtalk-sdk";
99
161
 
100
162
  const app = express();
101
- app.use(express.raw({ type: "application/json" }));
163
+ // IMPORTANT: use express.raw() NOT express.json(). The signature was
164
+ // computed over the exact bytes; re-serialized JSON will not match.
165
+ app.use("/webhooks/osmtalk", express.raw({ type: "application/json" }));
102
166
 
103
167
  app.post("/webhooks/osmtalk", (req, res) => {
104
- const sig = req.header("X-OsmTalk-Signature") ?? "";
105
- const [algo, hex] = sig.split("=");
106
- const expected = crypto
107
- .createHmac("sha256", process.env.OSMTALK_WEBHOOK_SECRET!)
108
- .update(req.body)
109
- .digest("hex");
110
- if (algo !== "sha256" || !crypto.timingSafeEqual(Buffer.from(hex, "hex"), Buffer.from(expected, "hex"))) {
111
- return res.sendStatus(401);
112
- }
168
+ const ok = verifyWebhookSignature(
169
+ req.body,
170
+ req.header("x-osmtalk-signature"),
171
+ process.env.OSMTALK_WEBHOOK_SECRET!,
172
+ );
173
+ if (!ok) return res.status(401).end();
174
+
113
175
  const event = JSON.parse(req.body.toString());
114
- if (event.event === "campaign.lead_completed" && event.lead.disposition === "qualified") {
115
- console.log("Hot lead:", event.lead.variables.first_name);
176
+ if (event.event === "call.completed") {
177
+ console.log("Call ended:", event.call.id, event.analysis?.disposition);
116
178
  }
117
179
  res.json({ ok: true });
118
180
  });
119
181
  ```
120
182
 
183
+ ### Verify webhooks in Workers / Deno / Bun (async, WebCrypto)
184
+
185
+ ```ts
186
+ import { verifyWebhookSignatureAsync } from "@osmapi/osmtalk-sdk";
187
+
188
+ export default {
189
+ async fetch(req: Request) {
190
+ const raw = await req.text();
191
+ const ok = await verifyWebhookSignatureAsync(
192
+ raw,
193
+ req.headers.get("x-osmtalk-signature"),
194
+ env.OSMTALK_WEBHOOK_SECRET,
195
+ );
196
+ if (!ok) return new Response("invalid signature", { status: 401 });
197
+ const event = JSON.parse(raw);
198
+ // …handle event
199
+ return new Response("ok");
200
+ },
201
+ };
202
+ ```
203
+
121
204
  ## Error handling
122
205
 
123
206
  ```ts
@@ -128,32 +211,61 @@ try {
128
211
  } catch (err) {
129
212
  if (err instanceof OsmtalkError) {
130
213
  console.log("HTTP", err.status, err.body);
214
+ console.log("Retries attempted:", err.retryAttempts);
215
+ if (err.isRetryable) console.log("Server might recover — try again later.");
216
+ if (err.isClientError) console.log("Bad input — check err.body.details.");
131
217
  } else {
132
218
  throw err;
133
219
  }
134
220
  }
135
221
  ```
136
222
 
137
- | Status | Meaning |
138
- |---|---|
139
- | 400 | Validation — `err.body.details` has zod field errors |
140
- | 401 | Bad API key |
141
- | 402 | Insufficient credits |
142
- | 404 | Resource not found |
143
- | 429 | Concurrency or rate limit |
144
- | 5xx | Server error / provider outage |
223
+ | Status | Meaning | `OsmtalkError` flag |
224
+ |---|---|---|
225
+ | 400 | Validation — `err.body.details` has zod field errors | `isClientError` |
226
+ | 401 | Bad API key | `isClientError` |
227
+ | 402 | Insufficient credits | `isClientError` |
228
+ | 404 | Resource not found | `isClientError` |
229
+ | 408 | Request timeout | `isRetryable` |
230
+ | 429 | Concurrency or rate limit | `isRetryable` |
231
+ | 5xx | Server error / provider outage | `isRetryable` |
232
+
233
+ The SDK already auto-retries 408/429/5xx and network errors twice by default. Mutating requests (POST/PUT/DELETE) are only retried when you pass `idempotencyKey` so the SDK never silently double-charges.
145
234
 
146
235
  ## Options
147
236
 
148
237
  ```ts
149
238
  new Osmtalk({
150
- apiKey: "...",
151
- baseUrl: "https://api.osmtalk.com", // optional override
152
- timeoutMs: 30_000, // per-request timeout
153
- fetch: customFetch, // optional (Node 18+ has global fetch)
239
+ apiKey: "osm_live_…",
240
+ baseUrl: "https://api.osmtalk.com", // default
241
+ timeoutMs: 30_000, // per-request, 0 to disable
242
+ maxRetries: 2, // auto-retry count for 5xx/429
243
+ retryInitialDelayMs: 250, // doubles per retry, jittered
244
+ organizationId: "org_xxx", // for multi-org accounts
245
+ defaultHeaders: { "X-Trace-Id": "…" },// added to every request
246
+ fetch: customFetch, // optional, defaults to global fetch
247
+ });
248
+ ```
249
+
250
+ Per-request overrides:
251
+
252
+ ```ts
253
+ await client.calls.outbound(input, {
254
+ idempotencyKey: `dest-${destination}-${date}`,
255
+ signal: controller.signal,
256
+ timeoutMs: 60_000,
257
+ organizationId: "org_yyy",
154
258
  });
155
259
  ```
156
260
 
261
+ ## Runnable examples
262
+
263
+ See [github.com/osm-API/osmtalk-examples](https://github.com/osm-API/osmtalk-examples) for three end-to-end projects:
264
+
265
+ 1. **Personalized outbound call** — dynamic per-user prompts
266
+ 2. **Bulk campaign from CSV** — scale to thousands
267
+ 3. **Verified webhook receiver** — close the loop with `verifyWebhookSignature`
268
+
157
269
  ## License
158
270
 
159
271
  MIT
package/dist/index.d.mts CHANGED
@@ -10,12 +10,49 @@
10
10
  * dynamicVariables: { first_name: "Arjun" },
11
11
  * });
12
12
  */
13
+ /**
14
+ * Current SDK version, sent in the User-Agent header and useful for
15
+ * runtime version checks (e.g. logging which SDK build is talking to the
16
+ * API, or feature-detecting against minimum versions in shared code).
17
+ */
18
+ declare const SDK_VERSION = "0.3.1";
13
19
  interface OsmtalkOptions {
14
20
  apiKey: string;
15
21
  baseUrl?: string;
22
+ /** Per-request timeout. Default 30s. Set `0` to disable. */
16
23
  timeoutMs?: number;
17
24
  /** Custom fetch implementation (e.g. for testing). */
18
25
  fetch?: typeof fetch;
26
+ /**
27
+ * Maximum auto-retries on transient failures (5xx, 429, network
28
+ * errors). Default 2. Set to 0 to disable. Mutating requests (POST,
29
+ * PUT, DELETE) are only retried when an `idempotencyKey` is set on
30
+ * the call — otherwise a retry could double-charge or double-place
31
+ * a call.
32
+ */
33
+ maxRetries?: number;
34
+ /**
35
+ * Initial retry delay in ms. Doubled on each subsequent retry.
36
+ * Default 250ms → 500 → 1000. Server `Retry-After` headers take
37
+ * precedence when present.
38
+ */
39
+ retryInitialDelayMs?: number;
40
+ /** Extra headers added to every request (e.g. observability IDs). */
41
+ defaultHeaders?: Record<string, string>;
42
+ /**
43
+ * Org ID to send as `X-Organization-Id`. For users in multiple orgs;
44
+ * defaults to the API key's primary org if omitted.
45
+ */
46
+ organizationId?: string;
47
+ }
48
+ interface RequestOptions {
49
+ idempotencyKey?: string;
50
+ /** AbortSignal for caller-side cancellation. Combined with the SDK's timeout signal. */
51
+ signal?: AbortSignal;
52
+ /** Per-call override of the global timeout. */
53
+ timeoutMs?: number;
54
+ /** Per-call override of the org ID. */
55
+ organizationId?: string;
19
56
  }
20
57
  interface DynamicVariables {
21
58
  [key: string]: string | number | boolean;
@@ -141,15 +178,36 @@ interface OsmtalkErrorBody {
141
178
  declare class OsmtalkError extends Error {
142
179
  readonly status: number;
143
180
  readonly body: OsmtalkErrorBody;
144
- constructor(status: number, body: OsmtalkErrorBody);
181
+ /** Number of retry attempts the SDK made before giving up. */
182
+ readonly retryAttempts: number;
183
+ constructor(status: number, body: OsmtalkErrorBody, retryAttempts?: number);
184
+ /** True for status codes the server might recover from on retry. */
185
+ get isRetryable(): boolean;
186
+ /** True for client mistakes (bad input, bad auth). */
187
+ get isClientError(): boolean;
145
188
  }
146
189
  declare class HttpClient {
147
190
  private readonly baseUrl;
148
191
  private readonly apiKey;
149
192
  private readonly timeoutMs;
193
+ private readonly maxRetries;
194
+ private readonly retryInitialDelayMs;
150
195
  private readonly fetchImpl;
196
+ private readonly defaultHeaders;
197
+ private readonly organizationId;
198
+ private readonly userAgent;
151
199
  constructor(opts: OsmtalkOptions);
152
- request<T>(method: string, path: string, body?: unknown, extraHeaders?: Record<string, string>, idempotencyKey?: string): Promise<T>;
200
+ /**
201
+ * Combine the caller's AbortSignal with the SDK's per-request timeout
202
+ * signal so EITHER firing cancels the fetch. We can't use
203
+ * `AbortSignal.any` (Node 18 doesn't have it), so we wire it manually.
204
+ */
205
+ private buildAbortSignal;
206
+ /** Parse the body once for both success and error paths. */
207
+ private static parseBody;
208
+ /** How long to wait before the next retry. Honors Retry-After. */
209
+ private retryDelay;
210
+ request<T>(method: string, path: string, body?: unknown, extraHeaders?: Record<string, string>, idempotencyKey?: string, options?: RequestOptions): Promise<T>;
153
211
  }
154
212
  declare class AgentsResource {
155
213
  private readonly http;
@@ -199,27 +257,54 @@ declare class AgentsResource {
199
257
  callId: string;
200
258
  }>;
201
259
  }
260
+ /**
261
+ * Call statuses that mean "no more state changes are coming" — the
262
+ * record is final and safe to consume. `waitUntilEnded()` polls until
263
+ * the call's status matches one of these.
264
+ *
265
+ * Exported so downstream code can mirror the SDK's definition of "done"
266
+ * without copy-pasting strings (e.g. when reacting to webhook events or
267
+ * filtering call lists in a dashboard).
268
+ */
269
+ declare const TERMINAL_CALL_STATUSES: readonly ["completed", "failed", "ended", "cancelled"];
202
270
  declare class CallsResource {
203
271
  private readonly http;
204
272
  constructor(http: HttpClient);
205
- list(): Promise<CallRecord[]>;
206
- get(id: string): Promise<CallRecord>;
273
+ list(opts?: RequestOptions): Promise<CallRecord[]>;
274
+ get(id: string, opts?: RequestOptions): Promise<CallRecord>;
207
275
  /**
208
- * Place an outbound call. Optionally pass `idempotencyKey` so retries
209
- * within 24h return the same response instead of placing a duplicate call.
276
+ * Place an outbound call. Pass `idempotencyKey` so a retry within 24h
277
+ * returns the same response instead of placing a duplicate call
278
+ * required if you want this call to be retried on transient failures.
210
279
  */
211
- outbound(input: CallStartRequest, opts?: {
212
- idempotencyKey?: string;
213
- }): Promise<{
280
+ outbound(input: CallStartRequest, opts?: RequestOptions): Promise<{
214
281
  callId: string;
215
282
  roomName: string;
216
283
  }>;
217
- end(id: string): Promise<{
284
+ end(id: string, opts?: RequestOptions): Promise<{
218
285
  success: true;
219
286
  }>;
220
- transfer(id: string, destination: string, summary?: string): Promise<{
287
+ transfer(id: string, destination: string, summary?: string, opts?: RequestOptions): Promise<{
221
288
  success: true;
222
289
  }>;
290
+ /**
291
+ * Poll a call until it reaches a terminal status (`completed`,
292
+ * `failed`, `ended`, `cancelled`) and return the final record.
293
+ *
294
+ * Saves consumers from writing the same loop in every script. Use
295
+ * webhooks instead in production — this is fine for scripts, demos,
296
+ * and one-off jobs but consumes API quota on every poll.
297
+ *
298
+ * @param opts.pollIntervalMs - default 5s
299
+ * @param opts.timeoutMs - default 30min (rejects with
300
+ * `OsmtalkError` 408 on timeout)
301
+ * @param opts.signal - abort externally
302
+ */
303
+ waitUntilEnded(id: string, opts?: {
304
+ pollIntervalMs?: number;
305
+ timeoutMs?: number;
306
+ signal?: AbortSignal;
307
+ }): Promise<CallRecord>;
223
308
  }
224
309
  declare class PlatformResource {
225
310
  private readonly http;
@@ -517,4 +602,4 @@ declare class Osmtalk {
517
602
  constructor(opts: OsmtalkOptions);
518
603
  }
519
604
 
520
- export { type AgentRecord, type AgentTemplateResult, type AssistantOverride, type CallRecord, type CallStartRequest, type CampaignCreateRequest, type CampaignRecord, type DynamicVariables, type LeadRow, Osmtalk, OsmtalkError, type OsmtalkErrorBody, type OsmtalkOptions, type PresetCostEstimate, type PresetWithCost, type ProviderHealth, Osmtalk as default, verifyWebhookSignature, verifyWebhookSignatureAsync };
605
+ export { type AgentRecord, type AgentTemplateResult, type AssistantOverride, type CallRecord, type CallStartRequest, type CampaignCreateRequest, type CampaignRecord, type DynamicVariables, type LeadRow, Osmtalk, OsmtalkError, type OsmtalkErrorBody, type OsmtalkOptions, type PresetCostEstimate, type PresetWithCost, type ProviderHealth, type RequestOptions, SDK_VERSION, TERMINAL_CALL_STATUSES, Osmtalk as default, verifyWebhookSignature, verifyWebhookSignatureAsync };
package/dist/index.d.ts CHANGED
@@ -10,12 +10,49 @@
10
10
  * dynamicVariables: { first_name: "Arjun" },
11
11
  * });
12
12
  */
13
+ /**
14
+ * Current SDK version, sent in the User-Agent header and useful for
15
+ * runtime version checks (e.g. logging which SDK build is talking to the
16
+ * API, or feature-detecting against minimum versions in shared code).
17
+ */
18
+ declare const SDK_VERSION = "0.3.1";
13
19
  interface OsmtalkOptions {
14
20
  apiKey: string;
15
21
  baseUrl?: string;
22
+ /** Per-request timeout. Default 30s. Set `0` to disable. */
16
23
  timeoutMs?: number;
17
24
  /** Custom fetch implementation (e.g. for testing). */
18
25
  fetch?: typeof fetch;
26
+ /**
27
+ * Maximum auto-retries on transient failures (5xx, 429, network
28
+ * errors). Default 2. Set to 0 to disable. Mutating requests (POST,
29
+ * PUT, DELETE) are only retried when an `idempotencyKey` is set on
30
+ * the call — otherwise a retry could double-charge or double-place
31
+ * a call.
32
+ */
33
+ maxRetries?: number;
34
+ /**
35
+ * Initial retry delay in ms. Doubled on each subsequent retry.
36
+ * Default 250ms → 500 → 1000. Server `Retry-After` headers take
37
+ * precedence when present.
38
+ */
39
+ retryInitialDelayMs?: number;
40
+ /** Extra headers added to every request (e.g. observability IDs). */
41
+ defaultHeaders?: Record<string, string>;
42
+ /**
43
+ * Org ID to send as `X-Organization-Id`. For users in multiple orgs;
44
+ * defaults to the API key's primary org if omitted.
45
+ */
46
+ organizationId?: string;
47
+ }
48
+ interface RequestOptions {
49
+ idempotencyKey?: string;
50
+ /** AbortSignal for caller-side cancellation. Combined with the SDK's timeout signal. */
51
+ signal?: AbortSignal;
52
+ /** Per-call override of the global timeout. */
53
+ timeoutMs?: number;
54
+ /** Per-call override of the org ID. */
55
+ organizationId?: string;
19
56
  }
20
57
  interface DynamicVariables {
21
58
  [key: string]: string | number | boolean;
@@ -141,15 +178,36 @@ interface OsmtalkErrorBody {
141
178
  declare class OsmtalkError extends Error {
142
179
  readonly status: number;
143
180
  readonly body: OsmtalkErrorBody;
144
- constructor(status: number, body: OsmtalkErrorBody);
181
+ /** Number of retry attempts the SDK made before giving up. */
182
+ readonly retryAttempts: number;
183
+ constructor(status: number, body: OsmtalkErrorBody, retryAttempts?: number);
184
+ /** True for status codes the server might recover from on retry. */
185
+ get isRetryable(): boolean;
186
+ /** True for client mistakes (bad input, bad auth). */
187
+ get isClientError(): boolean;
145
188
  }
146
189
  declare class HttpClient {
147
190
  private readonly baseUrl;
148
191
  private readonly apiKey;
149
192
  private readonly timeoutMs;
193
+ private readonly maxRetries;
194
+ private readonly retryInitialDelayMs;
150
195
  private readonly fetchImpl;
196
+ private readonly defaultHeaders;
197
+ private readonly organizationId;
198
+ private readonly userAgent;
151
199
  constructor(opts: OsmtalkOptions);
152
- request<T>(method: string, path: string, body?: unknown, extraHeaders?: Record<string, string>, idempotencyKey?: string): Promise<T>;
200
+ /**
201
+ * Combine the caller's AbortSignal with the SDK's per-request timeout
202
+ * signal so EITHER firing cancels the fetch. We can't use
203
+ * `AbortSignal.any` (Node 18 doesn't have it), so we wire it manually.
204
+ */
205
+ private buildAbortSignal;
206
+ /** Parse the body once for both success and error paths. */
207
+ private static parseBody;
208
+ /** How long to wait before the next retry. Honors Retry-After. */
209
+ private retryDelay;
210
+ request<T>(method: string, path: string, body?: unknown, extraHeaders?: Record<string, string>, idempotencyKey?: string, options?: RequestOptions): Promise<T>;
153
211
  }
154
212
  declare class AgentsResource {
155
213
  private readonly http;
@@ -199,27 +257,54 @@ declare class AgentsResource {
199
257
  callId: string;
200
258
  }>;
201
259
  }
260
+ /**
261
+ * Call statuses that mean "no more state changes are coming" — the
262
+ * record is final and safe to consume. `waitUntilEnded()` polls until
263
+ * the call's status matches one of these.
264
+ *
265
+ * Exported so downstream code can mirror the SDK's definition of "done"
266
+ * without copy-pasting strings (e.g. when reacting to webhook events or
267
+ * filtering call lists in a dashboard).
268
+ */
269
+ declare const TERMINAL_CALL_STATUSES: readonly ["completed", "failed", "ended", "cancelled"];
202
270
  declare class CallsResource {
203
271
  private readonly http;
204
272
  constructor(http: HttpClient);
205
- list(): Promise<CallRecord[]>;
206
- get(id: string): Promise<CallRecord>;
273
+ list(opts?: RequestOptions): Promise<CallRecord[]>;
274
+ get(id: string, opts?: RequestOptions): Promise<CallRecord>;
207
275
  /**
208
- * Place an outbound call. Optionally pass `idempotencyKey` so retries
209
- * within 24h return the same response instead of placing a duplicate call.
276
+ * Place an outbound call. Pass `idempotencyKey` so a retry within 24h
277
+ * returns the same response instead of placing a duplicate call
278
+ * required if you want this call to be retried on transient failures.
210
279
  */
211
- outbound(input: CallStartRequest, opts?: {
212
- idempotencyKey?: string;
213
- }): Promise<{
280
+ outbound(input: CallStartRequest, opts?: RequestOptions): Promise<{
214
281
  callId: string;
215
282
  roomName: string;
216
283
  }>;
217
- end(id: string): Promise<{
284
+ end(id: string, opts?: RequestOptions): Promise<{
218
285
  success: true;
219
286
  }>;
220
- transfer(id: string, destination: string, summary?: string): Promise<{
287
+ transfer(id: string, destination: string, summary?: string, opts?: RequestOptions): Promise<{
221
288
  success: true;
222
289
  }>;
290
+ /**
291
+ * Poll a call until it reaches a terminal status (`completed`,
292
+ * `failed`, `ended`, `cancelled`) and return the final record.
293
+ *
294
+ * Saves consumers from writing the same loop in every script. Use
295
+ * webhooks instead in production — this is fine for scripts, demos,
296
+ * and one-off jobs but consumes API quota on every poll.
297
+ *
298
+ * @param opts.pollIntervalMs - default 5s
299
+ * @param opts.timeoutMs - default 30min (rejects with
300
+ * `OsmtalkError` 408 on timeout)
301
+ * @param opts.signal - abort externally
302
+ */
303
+ waitUntilEnded(id: string, opts?: {
304
+ pollIntervalMs?: number;
305
+ timeoutMs?: number;
306
+ signal?: AbortSignal;
307
+ }): Promise<CallRecord>;
223
308
  }
224
309
  declare class PlatformResource {
225
310
  private readonly http;
@@ -517,4 +602,4 @@ declare class Osmtalk {
517
602
  constructor(opts: OsmtalkOptions);
518
603
  }
519
604
 
520
- export { type AgentRecord, type AgentTemplateResult, type AssistantOverride, type CallRecord, type CallStartRequest, type CampaignCreateRequest, type CampaignRecord, type DynamicVariables, type LeadRow, Osmtalk, OsmtalkError, type OsmtalkErrorBody, type OsmtalkOptions, type PresetCostEstimate, type PresetWithCost, type ProviderHealth, Osmtalk as default, verifyWebhookSignature, verifyWebhookSignatureAsync };
605
+ export { type AgentRecord, type AgentTemplateResult, type AssistantOverride, type CallRecord, type CallStartRequest, type CampaignCreateRequest, type CampaignRecord, type DynamicVariables, type LeadRow, Osmtalk, OsmtalkError, type OsmtalkErrorBody, type OsmtalkOptions, type PresetCostEstimate, type PresetWithCost, type ProviderHealth, type RequestOptions, SDK_VERSION, TERMINAL_CALL_STATUSES, Osmtalk as default, verifyWebhookSignature, verifyWebhookSignatureAsync };
package/dist/index.js CHANGED
@@ -22,68 +22,196 @@ var index_exports = {};
22
22
  __export(index_exports, {
23
23
  Osmtalk: () => Osmtalk,
24
24
  OsmtalkError: () => OsmtalkError,
25
+ SDK_VERSION: () => SDK_VERSION,
26
+ TERMINAL_CALL_STATUSES: () => TERMINAL_CALL_STATUSES,
25
27
  default: () => index_default,
26
28
  verifyWebhookSignature: () => verifyWebhookSignature,
27
29
  verifyWebhookSignatureAsync: () => verifyWebhookSignatureAsync
28
30
  });
29
31
  module.exports = __toCommonJS(index_exports);
32
+ var SDK_VERSION = "0.3.1";
30
33
  var OsmtalkError = class extends Error {
31
34
  status;
32
35
  body;
33
- constructor(status, body) {
36
+ /** Number of retry attempts the SDK made before giving up. */
37
+ retryAttempts;
38
+ constructor(status, body, retryAttempts = 0) {
34
39
  super(body?.error || body?.message || `osmTalk API error: ${status}`);
35
40
  this.name = "OsmtalkError";
36
41
  this.status = status;
37
42
  this.body = body;
43
+ this.retryAttempts = retryAttempts;
38
44
  }
45
+ /** True for status codes the server might recover from on retry. */
46
+ get isRetryable() {
47
+ return this.status === 408 || this.status === 429 || this.status >= 500;
48
+ }
49
+ /** True for client mistakes (bad input, bad auth). */
50
+ get isClientError() {
51
+ return this.status >= 400 && this.status < 500;
52
+ }
53
+ };
54
+ function runtimeTag() {
55
+ const g = globalThis;
56
+ if (g.Deno?.version?.deno) return `deno/${g.Deno.version.deno}`;
57
+ if (g.Bun?.version) return `bun/${g.Bun.version}`;
58
+ if (g.process?.versions?.node) return `node/${g.process.versions.node}`;
59
+ if (typeof navigator !== "undefined" && navigator.userAgent) return "browser";
60
+ return "unknown";
61
+ }
62
+ var STATUS_TEXT = {
63
+ 408: "Request Timeout",
64
+ 429: "Too Many Requests",
65
+ 500: "Internal Server Error",
66
+ 502: "Bad Gateway",
67
+ 503: "Service Unavailable",
68
+ 504: "Gateway Timeout"
39
69
  };
40
- var HttpClient = class {
70
+ var SAFE_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS"]);
71
+ var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([408, 429, 500, 502, 503, 504]);
72
+ var HttpClient = class _HttpClient {
41
73
  baseUrl;
42
74
  apiKey;
43
75
  timeoutMs;
76
+ maxRetries;
77
+ retryInitialDelayMs;
44
78
  fetchImpl;
79
+ defaultHeaders;
80
+ organizationId;
81
+ userAgent;
45
82
  constructor(opts) {
46
83
  this.apiKey = opts.apiKey;
47
84
  this.baseUrl = (opts.baseUrl ?? "https://api.osmtalk.com").replace(/\/$/, "");
48
85
  this.timeoutMs = opts.timeoutMs ?? 3e4;
86
+ this.maxRetries = Math.max(0, opts.maxRetries ?? 2);
87
+ this.retryInitialDelayMs = Math.max(0, opts.retryInitialDelayMs ?? 250);
88
+ this.defaultHeaders = opts.defaultHeaders ?? {};
89
+ this.organizationId = opts.organizationId;
90
+ this.userAgent = `@osmapi/osmtalk-sdk/${SDK_VERSION} ${runtimeTag()}`;
49
91
  this.fetchImpl = opts.fetch ?? (typeof fetch !== "undefined" ? fetch.bind(globalThis) : void 0);
50
- if (!this.fetchImpl) throw new Error("No fetch implementation available \u2014 provide one in OsmtalkOptions.fetch");
92
+ if (!this.fetchImpl) {
93
+ throw new Error("No fetch implementation available \u2014 provide one in OsmtalkOptions.fetch");
94
+ }
51
95
  if (!opts.apiKey) throw new Error("apiKey is required");
52
96
  }
53
- async request(method, path, body, extraHeaders, idempotencyKey) {
97
+ /**
98
+ * Combine the caller's AbortSignal with the SDK's per-request timeout
99
+ * signal so EITHER firing cancels the fetch. We can't use
100
+ * `AbortSignal.any` (Node 18 doesn't have it), so we wire it manually.
101
+ */
102
+ buildAbortSignal(externalSignal, timeoutMs) {
54
103
  const ctrl = new AbortController();
55
- const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
104
+ const onAbort = () => ctrl.abort();
105
+ if (externalSignal) {
106
+ if (externalSignal.aborted) ctrl.abort();
107
+ else externalSignal.addEventListener("abort", onAbort, { once: true });
108
+ }
109
+ const timer = timeoutMs > 0 ? setTimeout(() => ctrl.abort(new Error("Request timed out")), timeoutMs) : null;
110
+ return {
111
+ signal: ctrl.signal,
112
+ cancel: () => {
113
+ if (timer) clearTimeout(timer);
114
+ if (externalSignal) externalSignal.removeEventListener("abort", onAbort);
115
+ }
116
+ };
117
+ }
118
+ /** Parse the body once for both success and error paths. */
119
+ static async parseBody(res) {
120
+ const text = await res.text();
121
+ if (!text) return null;
122
+ try {
123
+ return JSON.parse(text);
124
+ } catch {
125
+ return text;
126
+ }
127
+ }
128
+ /** How long to wait before the next retry. Honors Retry-After. */
129
+ retryDelay(attempt, res) {
130
+ if (res) {
131
+ const retryAfter = res.headers.get("retry-after");
132
+ if (retryAfter) {
133
+ const secs = Number(retryAfter);
134
+ if (Number.isFinite(secs) && secs >= 0) return Math.min(secs * 1e3, 3e4);
135
+ const when = Date.parse(retryAfter);
136
+ if (!Number.isNaN(when)) return Math.max(0, Math.min(when - Date.now(), 3e4));
137
+ }
138
+ }
139
+ const base = this.retryInitialDelayMs * 2 ** attempt;
140
+ const jitter = Math.floor(Math.random() * (base / 4));
141
+ return Math.min(base + jitter, 3e4);
142
+ }
143
+ async request(method, path, body, extraHeaders, idempotencyKey, options) {
144
+ const reqOrgId = options?.organizationId ?? this.organizationId;
145
+ const reqTimeout = options?.timeoutMs ?? this.timeoutMs;
56
146
  const headers = {
57
147
  Authorization: `Bearer ${this.apiKey}`,
148
+ "User-Agent": this.userAgent,
149
+ Accept: "application/json",
58
150
  ...body !== void 0 ? { "Content-Type": "application/json" } : {},
59
151
  ...idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {},
152
+ ...reqOrgId ? { "X-Organization-Id": reqOrgId } : {},
153
+ ...this.defaultHeaders,
60
154
  ...extraHeaders ?? {}
61
155
  };
62
- let res;
63
- try {
64
- res = await this.fetchImpl(`${this.baseUrl}${path}`, {
65
- method,
66
- headers,
67
- body: body === void 0 ? void 0 : typeof body === "string" ? body : JSON.stringify(body),
68
- signal: ctrl.signal
69
- });
70
- } finally {
71
- clearTimeout(timer);
72
- }
73
- const text = await res.text();
74
- const parsed = text ? (() => {
156
+ const serializedBody = body === void 0 ? void 0 : typeof body === "string" ? body : JSON.stringify(body);
157
+ const canRetryMutation = SAFE_METHODS.has(method.toUpperCase()) || Boolean(idempotencyKey);
158
+ const url = `${this.baseUrl}${path}`;
159
+ let lastErr;
160
+ let lastRes = null;
161
+ const maxAttempts = this.maxRetries + 1;
162
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
163
+ const { signal, cancel } = this.buildAbortSignal(options?.signal, reqTimeout);
164
+ let res = null;
75
165
  try {
76
- return JSON.parse(text);
77
- } catch {
78
- return text;
166
+ res = await this.fetchImpl(url, {
167
+ method,
168
+ headers,
169
+ body: serializedBody,
170
+ signal
171
+ });
172
+ } catch (err) {
173
+ lastErr = err;
174
+ cancel();
175
+ if (options?.signal?.aborted) throw err;
176
+ if (attempt < maxAttempts - 1 && canRetryMutation) {
177
+ await sleep(this.retryDelay(attempt, null));
178
+ continue;
179
+ }
180
+ throw err;
181
+ }
182
+ cancel();
183
+ if (res.ok) {
184
+ return await _HttpClient.parseBody(res);
79
185
  }
80
- })() : null;
81
- if (!res.ok) {
82
- throw new OsmtalkError(res.status, parsed ?? { error: text });
186
+ const retryable = RETRYABLE_STATUSES.has(res.status);
187
+ if (retryable && canRetryMutation && attempt < maxAttempts - 1) {
188
+ try {
189
+ await res.text();
190
+ } catch {
191
+ }
192
+ await sleep(this.retryDelay(attempt, res));
193
+ lastRes = res;
194
+ continue;
195
+ }
196
+ const parsed = await _HttpClient.parseBody(res);
197
+ throw new OsmtalkError(
198
+ res.status,
199
+ parsed ?? { error: STATUS_TEXT[res.status] ?? `HTTP ${res.status}` },
200
+ attempt
201
+ );
202
+ }
203
+ if (lastRes) {
204
+ const parsed = await _HttpClient.parseBody(lastRes);
205
+ throw new OsmtalkError(
206
+ lastRes.status,
207
+ parsed ?? { error: STATUS_TEXT[lastRes.status] ?? "Server error" },
208
+ maxAttempts - 1
209
+ );
83
210
  }
84
- return parsed;
211
+ throw lastErr ?? new Error("osmTalk SDK: request failed without details");
85
212
  }
86
213
  };
214
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
87
215
  var AgentsResource = class {
88
216
  constructor(http) {
89
217
  this.http = http;
@@ -139,20 +267,22 @@ var AgentsResource = class {
139
267
  );
140
268
  }
141
269
  };
270
+ var TERMINAL_CALL_STATUSES = ["completed", "failed", "ended", "cancelled"];
142
271
  var CallsResource = class {
143
272
  constructor(http) {
144
273
  this.http = http;
145
274
  }
146
275
  http;
147
- list() {
148
- return this.http.request("GET", "/api/calls");
276
+ list(opts) {
277
+ return this.http.request("GET", "/api/calls", void 0, void 0, void 0, opts);
149
278
  }
150
- get(id) {
151
- return this.http.request("GET", `/api/calls/${id}`);
279
+ get(id, opts) {
280
+ return this.http.request("GET", `/api/calls/${id}`, void 0, void 0, void 0, opts);
152
281
  }
153
282
  /**
154
- * Place an outbound call. Optionally pass `idempotencyKey` so retries
155
- * within 24h return the same response instead of placing a duplicate call.
283
+ * Place an outbound call. Pass `idempotencyKey` so a retry within 24h
284
+ * returns the same response instead of placing a duplicate call
285
+ * required if you want this call to be retried on transient failures.
156
286
  */
157
287
  outbound(input, opts) {
158
288
  return this.http.request(
@@ -160,14 +290,61 @@ var CallsResource = class {
160
290
  "/api/calls/outbound",
161
291
  input,
162
292
  void 0,
163
- opts?.idempotencyKey
293
+ opts?.idempotencyKey,
294
+ opts
295
+ );
296
+ }
297
+ end(id, opts) {
298
+ return this.http.request(
299
+ "POST",
300
+ `/api/calls/${id}/end`,
301
+ void 0,
302
+ void 0,
303
+ opts?.idempotencyKey,
304
+ opts
164
305
  );
165
306
  }
166
- end(id) {
167
- return this.http.request("POST", `/api/calls/${id}/end`);
307
+ transfer(id, destination, summary, opts) {
308
+ return this.http.request(
309
+ "POST",
310
+ `/api/calls/${id}/transfer`,
311
+ { destination, summary },
312
+ void 0,
313
+ opts?.idempotencyKey,
314
+ opts
315
+ );
168
316
  }
169
- transfer(id, destination, summary) {
170
- return this.http.request("POST", `/api/calls/${id}/transfer`, { destination, summary });
317
+ /**
318
+ * Poll a call until it reaches a terminal status (`completed`,
319
+ * `failed`, `ended`, `cancelled`) and return the final record.
320
+ *
321
+ * Saves consumers from writing the same loop in every script. Use
322
+ * webhooks instead in production — this is fine for scripts, demos,
323
+ * and one-off jobs but consumes API quota on every poll.
324
+ *
325
+ * @param opts.pollIntervalMs - default 5s
326
+ * @param opts.timeoutMs - default 30min (rejects with
327
+ * `OsmtalkError` 408 on timeout)
328
+ * @param opts.signal - abort externally
329
+ */
330
+ async waitUntilEnded(id, opts) {
331
+ const pollInterval = Math.max(1e3, opts?.pollIntervalMs ?? 5e3);
332
+ const totalTimeout = opts?.timeoutMs ?? 30 * 60 * 1e3;
333
+ const deadline = Date.now() + totalTimeout;
334
+ const terminal = new Set(TERMINAL_CALL_STATUSES);
335
+ while (true) {
336
+ if (opts?.signal?.aborted) {
337
+ throw new OsmtalkError(0, { error: "Aborted by caller" });
338
+ }
339
+ const call = await this.get(id, { signal: opts?.signal });
340
+ if (terminal.has(call.status)) return call;
341
+ if (Date.now() >= deadline) {
342
+ throw new OsmtalkError(408, {
343
+ error: `waitUntilEnded: call ${id} did not reach a terminal state within ${totalTimeout}ms (last status: ${call.status})`
344
+ });
345
+ }
346
+ await sleep(Math.min(pollInterval, Math.max(0, deadline - Date.now())));
347
+ }
171
348
  }
172
349
  };
173
350
  var PlatformResource = class {
@@ -445,6 +622,8 @@ var index_default = Osmtalk;
445
622
  0 && (module.exports = {
446
623
  Osmtalk,
447
624
  OsmtalkError,
625
+ SDK_VERSION,
626
+ TERMINAL_CALL_STATUSES,
448
627
  verifyWebhookSignature,
449
628
  verifyWebhookSignatureAsync
450
629
  });
package/dist/index.mjs CHANGED
@@ -1,61 +1,187 @@
1
1
  // src/index.ts
2
+ var SDK_VERSION = "0.3.1";
2
3
  var OsmtalkError = class extends Error {
3
4
  status;
4
5
  body;
5
- constructor(status, body) {
6
+ /** Number of retry attempts the SDK made before giving up. */
7
+ retryAttempts;
8
+ constructor(status, body, retryAttempts = 0) {
6
9
  super(body?.error || body?.message || `osmTalk API error: ${status}`);
7
10
  this.name = "OsmtalkError";
8
11
  this.status = status;
9
12
  this.body = body;
13
+ this.retryAttempts = retryAttempts;
10
14
  }
15
+ /** True for status codes the server might recover from on retry. */
16
+ get isRetryable() {
17
+ return this.status === 408 || this.status === 429 || this.status >= 500;
18
+ }
19
+ /** True for client mistakes (bad input, bad auth). */
20
+ get isClientError() {
21
+ return this.status >= 400 && this.status < 500;
22
+ }
23
+ };
24
+ function runtimeTag() {
25
+ const g = globalThis;
26
+ if (g.Deno?.version?.deno) return `deno/${g.Deno.version.deno}`;
27
+ if (g.Bun?.version) return `bun/${g.Bun.version}`;
28
+ if (g.process?.versions?.node) return `node/${g.process.versions.node}`;
29
+ if (typeof navigator !== "undefined" && navigator.userAgent) return "browser";
30
+ return "unknown";
31
+ }
32
+ var STATUS_TEXT = {
33
+ 408: "Request Timeout",
34
+ 429: "Too Many Requests",
35
+ 500: "Internal Server Error",
36
+ 502: "Bad Gateway",
37
+ 503: "Service Unavailable",
38
+ 504: "Gateway Timeout"
11
39
  };
12
- var HttpClient = class {
40
+ var SAFE_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS"]);
41
+ var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([408, 429, 500, 502, 503, 504]);
42
+ var HttpClient = class _HttpClient {
13
43
  baseUrl;
14
44
  apiKey;
15
45
  timeoutMs;
46
+ maxRetries;
47
+ retryInitialDelayMs;
16
48
  fetchImpl;
49
+ defaultHeaders;
50
+ organizationId;
51
+ userAgent;
17
52
  constructor(opts) {
18
53
  this.apiKey = opts.apiKey;
19
54
  this.baseUrl = (opts.baseUrl ?? "https://api.osmtalk.com").replace(/\/$/, "");
20
55
  this.timeoutMs = opts.timeoutMs ?? 3e4;
56
+ this.maxRetries = Math.max(0, opts.maxRetries ?? 2);
57
+ this.retryInitialDelayMs = Math.max(0, opts.retryInitialDelayMs ?? 250);
58
+ this.defaultHeaders = opts.defaultHeaders ?? {};
59
+ this.organizationId = opts.organizationId;
60
+ this.userAgent = `@osmapi/osmtalk-sdk/${SDK_VERSION} ${runtimeTag()}`;
21
61
  this.fetchImpl = opts.fetch ?? (typeof fetch !== "undefined" ? fetch.bind(globalThis) : void 0);
22
- if (!this.fetchImpl) throw new Error("No fetch implementation available \u2014 provide one in OsmtalkOptions.fetch");
62
+ if (!this.fetchImpl) {
63
+ throw new Error("No fetch implementation available \u2014 provide one in OsmtalkOptions.fetch");
64
+ }
23
65
  if (!opts.apiKey) throw new Error("apiKey is required");
24
66
  }
25
- async request(method, path, body, extraHeaders, idempotencyKey) {
67
+ /**
68
+ * Combine the caller's AbortSignal with the SDK's per-request timeout
69
+ * signal so EITHER firing cancels the fetch. We can't use
70
+ * `AbortSignal.any` (Node 18 doesn't have it), so we wire it manually.
71
+ */
72
+ buildAbortSignal(externalSignal, timeoutMs) {
26
73
  const ctrl = new AbortController();
27
- const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
74
+ const onAbort = () => ctrl.abort();
75
+ if (externalSignal) {
76
+ if (externalSignal.aborted) ctrl.abort();
77
+ else externalSignal.addEventListener("abort", onAbort, { once: true });
78
+ }
79
+ const timer = timeoutMs > 0 ? setTimeout(() => ctrl.abort(new Error("Request timed out")), timeoutMs) : null;
80
+ return {
81
+ signal: ctrl.signal,
82
+ cancel: () => {
83
+ if (timer) clearTimeout(timer);
84
+ if (externalSignal) externalSignal.removeEventListener("abort", onAbort);
85
+ }
86
+ };
87
+ }
88
+ /** Parse the body once for both success and error paths. */
89
+ static async parseBody(res) {
90
+ const text = await res.text();
91
+ if (!text) return null;
92
+ try {
93
+ return JSON.parse(text);
94
+ } catch {
95
+ return text;
96
+ }
97
+ }
98
+ /** How long to wait before the next retry. Honors Retry-After. */
99
+ retryDelay(attempt, res) {
100
+ if (res) {
101
+ const retryAfter = res.headers.get("retry-after");
102
+ if (retryAfter) {
103
+ const secs = Number(retryAfter);
104
+ if (Number.isFinite(secs) && secs >= 0) return Math.min(secs * 1e3, 3e4);
105
+ const when = Date.parse(retryAfter);
106
+ if (!Number.isNaN(when)) return Math.max(0, Math.min(when - Date.now(), 3e4));
107
+ }
108
+ }
109
+ const base = this.retryInitialDelayMs * 2 ** attempt;
110
+ const jitter = Math.floor(Math.random() * (base / 4));
111
+ return Math.min(base + jitter, 3e4);
112
+ }
113
+ async request(method, path, body, extraHeaders, idempotencyKey, options) {
114
+ const reqOrgId = options?.organizationId ?? this.organizationId;
115
+ const reqTimeout = options?.timeoutMs ?? this.timeoutMs;
28
116
  const headers = {
29
117
  Authorization: `Bearer ${this.apiKey}`,
118
+ "User-Agent": this.userAgent,
119
+ Accept: "application/json",
30
120
  ...body !== void 0 ? { "Content-Type": "application/json" } : {},
31
121
  ...idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {},
122
+ ...reqOrgId ? { "X-Organization-Id": reqOrgId } : {},
123
+ ...this.defaultHeaders,
32
124
  ...extraHeaders ?? {}
33
125
  };
34
- let res;
35
- try {
36
- res = await this.fetchImpl(`${this.baseUrl}${path}`, {
37
- method,
38
- headers,
39
- body: body === void 0 ? void 0 : typeof body === "string" ? body : JSON.stringify(body),
40
- signal: ctrl.signal
41
- });
42
- } finally {
43
- clearTimeout(timer);
44
- }
45
- const text = await res.text();
46
- const parsed = text ? (() => {
126
+ const serializedBody = body === void 0 ? void 0 : typeof body === "string" ? body : JSON.stringify(body);
127
+ const canRetryMutation = SAFE_METHODS.has(method.toUpperCase()) || Boolean(idempotencyKey);
128
+ const url = `${this.baseUrl}${path}`;
129
+ let lastErr;
130
+ let lastRes = null;
131
+ const maxAttempts = this.maxRetries + 1;
132
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
133
+ const { signal, cancel } = this.buildAbortSignal(options?.signal, reqTimeout);
134
+ let res = null;
47
135
  try {
48
- return JSON.parse(text);
49
- } catch {
50
- return text;
136
+ res = await this.fetchImpl(url, {
137
+ method,
138
+ headers,
139
+ body: serializedBody,
140
+ signal
141
+ });
142
+ } catch (err) {
143
+ lastErr = err;
144
+ cancel();
145
+ if (options?.signal?.aborted) throw err;
146
+ if (attempt < maxAttempts - 1 && canRetryMutation) {
147
+ await sleep(this.retryDelay(attempt, null));
148
+ continue;
149
+ }
150
+ throw err;
151
+ }
152
+ cancel();
153
+ if (res.ok) {
154
+ return await _HttpClient.parseBody(res);
51
155
  }
52
- })() : null;
53
- if (!res.ok) {
54
- throw new OsmtalkError(res.status, parsed ?? { error: text });
156
+ const retryable = RETRYABLE_STATUSES.has(res.status);
157
+ if (retryable && canRetryMutation && attempt < maxAttempts - 1) {
158
+ try {
159
+ await res.text();
160
+ } catch {
161
+ }
162
+ await sleep(this.retryDelay(attempt, res));
163
+ lastRes = res;
164
+ continue;
165
+ }
166
+ const parsed = await _HttpClient.parseBody(res);
167
+ throw new OsmtalkError(
168
+ res.status,
169
+ parsed ?? { error: STATUS_TEXT[res.status] ?? `HTTP ${res.status}` },
170
+ attempt
171
+ );
172
+ }
173
+ if (lastRes) {
174
+ const parsed = await _HttpClient.parseBody(lastRes);
175
+ throw new OsmtalkError(
176
+ lastRes.status,
177
+ parsed ?? { error: STATUS_TEXT[lastRes.status] ?? "Server error" },
178
+ maxAttempts - 1
179
+ );
55
180
  }
56
- return parsed;
181
+ throw lastErr ?? new Error("osmTalk SDK: request failed without details");
57
182
  }
58
183
  };
184
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
59
185
  var AgentsResource = class {
60
186
  constructor(http) {
61
187
  this.http = http;
@@ -111,20 +237,22 @@ var AgentsResource = class {
111
237
  );
112
238
  }
113
239
  };
240
+ var TERMINAL_CALL_STATUSES = ["completed", "failed", "ended", "cancelled"];
114
241
  var CallsResource = class {
115
242
  constructor(http) {
116
243
  this.http = http;
117
244
  }
118
245
  http;
119
- list() {
120
- return this.http.request("GET", "/api/calls");
246
+ list(opts) {
247
+ return this.http.request("GET", "/api/calls", void 0, void 0, void 0, opts);
121
248
  }
122
- get(id) {
123
- return this.http.request("GET", `/api/calls/${id}`);
249
+ get(id, opts) {
250
+ return this.http.request("GET", `/api/calls/${id}`, void 0, void 0, void 0, opts);
124
251
  }
125
252
  /**
126
- * Place an outbound call. Optionally pass `idempotencyKey` so retries
127
- * within 24h return the same response instead of placing a duplicate call.
253
+ * Place an outbound call. Pass `idempotencyKey` so a retry within 24h
254
+ * returns the same response instead of placing a duplicate call
255
+ * required if you want this call to be retried on transient failures.
128
256
  */
129
257
  outbound(input, opts) {
130
258
  return this.http.request(
@@ -132,14 +260,61 @@ var CallsResource = class {
132
260
  "/api/calls/outbound",
133
261
  input,
134
262
  void 0,
135
- opts?.idempotencyKey
263
+ opts?.idempotencyKey,
264
+ opts
265
+ );
266
+ }
267
+ end(id, opts) {
268
+ return this.http.request(
269
+ "POST",
270
+ `/api/calls/${id}/end`,
271
+ void 0,
272
+ void 0,
273
+ opts?.idempotencyKey,
274
+ opts
136
275
  );
137
276
  }
138
- end(id) {
139
- return this.http.request("POST", `/api/calls/${id}/end`);
277
+ transfer(id, destination, summary, opts) {
278
+ return this.http.request(
279
+ "POST",
280
+ `/api/calls/${id}/transfer`,
281
+ { destination, summary },
282
+ void 0,
283
+ opts?.idempotencyKey,
284
+ opts
285
+ );
140
286
  }
141
- transfer(id, destination, summary) {
142
- return this.http.request("POST", `/api/calls/${id}/transfer`, { destination, summary });
287
+ /**
288
+ * Poll a call until it reaches a terminal status (`completed`,
289
+ * `failed`, `ended`, `cancelled`) and return the final record.
290
+ *
291
+ * Saves consumers from writing the same loop in every script. Use
292
+ * webhooks instead in production — this is fine for scripts, demos,
293
+ * and one-off jobs but consumes API quota on every poll.
294
+ *
295
+ * @param opts.pollIntervalMs - default 5s
296
+ * @param opts.timeoutMs - default 30min (rejects with
297
+ * `OsmtalkError` 408 on timeout)
298
+ * @param opts.signal - abort externally
299
+ */
300
+ async waitUntilEnded(id, opts) {
301
+ const pollInterval = Math.max(1e3, opts?.pollIntervalMs ?? 5e3);
302
+ const totalTimeout = opts?.timeoutMs ?? 30 * 60 * 1e3;
303
+ const deadline = Date.now() + totalTimeout;
304
+ const terminal = new Set(TERMINAL_CALL_STATUSES);
305
+ while (true) {
306
+ if (opts?.signal?.aborted) {
307
+ throw new OsmtalkError(0, { error: "Aborted by caller" });
308
+ }
309
+ const call = await this.get(id, { signal: opts?.signal });
310
+ if (terminal.has(call.status)) return call;
311
+ if (Date.now() >= deadline) {
312
+ throw new OsmtalkError(408, {
313
+ error: `waitUntilEnded: call ${id} did not reach a terminal state within ${totalTimeout}ms (last status: ${call.status})`
314
+ });
315
+ }
316
+ await sleep(Math.min(pollInterval, Math.max(0, deadline - Date.now())));
317
+ }
143
318
  }
144
319
  };
145
320
  var PlatformResource = class {
@@ -416,6 +591,8 @@ var index_default = Osmtalk;
416
591
  export {
417
592
  Osmtalk,
418
593
  OsmtalkError,
594
+ SDK_VERSION,
595
+ TERMINAL_CALL_STATUSES,
419
596
  index_default as default,
420
597
  verifyWebhookSignature,
421
598
  verifyWebhookSignatureAsync
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@osmapi/osmtalk-sdk",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Official TypeScript SDK for the osmTalk voice AI platform",
5
5
  "homepage": "https://docs.osmtalk.com",
6
6
  "repository": {