@medalsocial/sdk 1.6.0 → 1.7.0

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/dist/src/index.js CHANGED
@@ -21,6 +21,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var src_exports = {};
22
22
  __export(src_exports, {
23
23
  BaseClient: () => BaseClient,
24
+ Bookings: () => Bookings,
24
25
  CAPABILITY_IDS: () => CAPABILITY_IDS,
25
26
  CAPABILITY_ROUTES: () => CAPABILITY_ROUTES,
26
27
  CapabilityConfirmations: () => CapabilityConfirmations,
@@ -45,116 +46,6 @@ __export(src_exports, {
45
46
  });
46
47
  module.exports = __toCommonJS(src_exports);
47
48
 
48
- // src/types/capabilities.ts
49
- var CAPABILITY_IDS = [
50
- "channel.connect_link.create.execute",
51
- "channel.connect_link.revoke.execute",
52
- "channel.connection.disconnect.execute",
53
- "helpdesk.conversation.reply.execute",
54
- "helpdesk.conversation.update.execute",
55
- "helpdesk.webhook.create.execute",
56
- "helpdesk.webhook.update.execute",
57
- "helpdesk.webhook.delete.execute"
58
- ];
59
- var CAPABILITY_ROUTES = {
60
- "channel.connect_link.create.execute": {
61
- method: "POST",
62
- path_template: "/api/v1/channels/connect-links"
63
- },
64
- "channel.connect_link.revoke.execute": {
65
- method: "DELETE",
66
- path_template: "/api/v1/channels/connect-links/{id}"
67
- },
68
- "channel.connection.disconnect.execute": {
69
- method: "DELETE",
70
- path_template: "/api/v1/channels/connections/{id}"
71
- },
72
- "helpdesk.conversation.reply.execute": {
73
- method: "POST",
74
- path_template: "/api/v1/helpdesk/replies"
75
- },
76
- "helpdesk.conversation.update.execute": {
77
- method: "PATCH",
78
- path_template: "/api/v1/helpdesk/conversations/{id}"
79
- },
80
- "helpdesk.webhook.create.execute": {
81
- method: "POST",
82
- path_template: "/api/v1/webhooks"
83
- },
84
- "helpdesk.webhook.update.execute": {
85
- method: "PATCH",
86
- path_template: "/api/v1/webhooks/{id}"
87
- },
88
- "helpdesk.webhook.delete.execute": {
89
- method: "DELETE",
90
- path_template: "/api/v1/webhooks/{id}"
91
- }
92
- };
93
-
94
- // src/capability-confirmer.ts
95
- function newIdempotencyKey() {
96
- const cryptoRef = globalThis.crypto;
97
- if (typeof cryptoRef?.randomUUID === "function") {
98
- return cryptoRef.randomUUID();
99
- }
100
- return `idem_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 12)}`;
101
- }
102
- function resolvePath(template, pathParams) {
103
- return template.replace(/\{([^}/]+)\}/g, (_match, name) => {
104
- const value = pathParams?.[name];
105
- return value === void 0 ? `{${name}}` : encodeURIComponent(String(value));
106
- });
107
- }
108
- var CapabilityConfirmer = class {
109
- constructor(confirmations, defaults) {
110
- this.confirmations = confirmations;
111
- this.defaults = defaults;
112
- }
113
- confirmations;
114
- defaults;
115
- /**
116
- * Return the request options to use for a confirmable write, minting the
117
- * idempotency key and confirmation token first when auto-confirm is active.
118
- *
119
- * `body` is the pending request payload (`undefined` for `DELETE` routes).
120
- * It is handed to the `previewSummary` callback by reference so the summary
121
- * can describe the specific action, not just the route — it is the caller's
122
- * own payload, so it is passed through unmodified and unredacted.
123
- */
124
- async prepare(request, pathParams, options) {
125
- const auto = options?.autoConfirm === false ? void 0 : options?.autoConfirm ?? this.defaults;
126
- if (!auto) return options;
127
- if (options?.idempotencyKey && options?.capabilityConfirmation) return options;
128
- const route = CAPABILITY_ROUTES[request.capabilityId];
129
- const idempotencyKey = options?.idempotencyKey ?? newIdempotencyKey();
130
- const path = resolvePath(route.path_template, pathParams);
131
- const previewSummary = auto.previewSummary({
132
- ...request,
133
- method: route.method,
134
- path,
135
- ...pathParams ? { pathParams } : {},
136
- idempotencyKey
137
- });
138
- if (typeof previewSummary !== "string" || previewSummary.trim() === "") {
139
- throw new Error(
140
- `autoConfirm.previewSummary must return a non-empty summary for ${request.capabilityId}. The summary is the audit record of what your user approved \u2014 refusing to assert user_approved: true without one.`
141
- );
142
- }
143
- const { data } = await this.confirmations.create({
144
- capability_id: request.capabilityId,
145
- ...pathParams ? { path_params: pathParams } : {},
146
- idempotency_key: idempotencyKey,
147
- preview_summary: previewSummary,
148
- user_approved: true
149
- });
150
- return {
151
- ...options,
152
- idempotencyKey,
153
- capabilityConfirmation: data.confirmation_token
154
- };
155
- }
156
- };
157
-
158
49
  // src/types/common.ts
159
50
  var MedalApiError = class extends Error {
160
51
  status;
@@ -170,6 +61,21 @@ var MedalApiError = class extends Error {
170
61
  };
171
62
 
172
63
  // src/client.ts
64
+ function randomIdempotencyKey() {
65
+ const webCrypto = globalThis.crypto;
66
+ if (typeof webCrypto.randomUUID === "function") {
67
+ return webCrypto.randomUUID();
68
+ }
69
+ const bytes = new Uint8Array(16);
70
+ webCrypto.getRandomValues(bytes);
71
+ bytes[6] = bytes[6] & 15 | 64;
72
+ bytes[8] = bytes[8] & 63 | 128;
73
+ const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
74
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
75
+ }
76
+ function resolveIdempotencyKey(supplied) {
77
+ return (supplied ?? "").trim() || randomIdempotencyKey();
78
+ }
173
79
  var BaseClient = class {
174
80
  /** Resolved client configuration. */
175
81
  config;
@@ -189,6 +95,29 @@ var BaseClient = class {
189
95
  body: body !== void 0 ? JSON.stringify(body) : void 0
190
96
  });
191
97
  }
98
+ /**
99
+ * Execute a POST that must never execute twice, guaranteeing an
100
+ * `Idempotency-Key`.
101
+ *
102
+ * {@link BaseClient.post} retries 429 and 5xx automatically, so a write
103
+ * whose transaction committed before the gateway failed would otherwise be
104
+ * submitted a second time — booking the same slot twice. A key turns that
105
+ * retry into a replay: the server keys on the key, the workspace, and the
106
+ * method+path, and answers a repeat with the stored response, or 409 while
107
+ * the first attempt is still in flight. Either way the write happens once.
108
+ *
109
+ * The key is minted ONCE here, outside the retry loop in `request`, so every
110
+ * attempt of the same logical call carries the same value — a key minted per
111
+ * attempt would deduplicate nothing. A caller-supplied key always wins, so
112
+ * callers keeping their own records stay in control. See
113
+ * {@link resolveIdempotencyKey} for what counts as supplied.
114
+ */
115
+ async postOnce(path, body, options) {
116
+ return this.post(path, body, {
117
+ ...options,
118
+ idempotencyKey: resolveIdempotencyKey(options?.idempotencyKey)
119
+ });
120
+ }
192
121
  /** Execute an authenticated PATCH request with a JSON body. */
193
122
  async patch(path, body, options) {
194
123
  return this.request(this.buildUrl(path), {
@@ -240,12 +169,22 @@ var BaseClient = class {
240
169
  const controller = new AbortController();
241
170
  const timeout = setTimeout(() => controller.abort(), this.config.timeout);
242
171
  let res;
172
+ let text = "";
173
+ let retrying = false;
243
174
  try {
244
175
  res = await fetch(url, { ...init, headers, signal: controller.signal });
176
+ retrying = (res.status === 429 || res.status >= 500 && res.status <= 599) && attempt < maxAttempts;
177
+ if (retrying) {
178
+ const drained = res.body ? res.body.pipeTo(new WritableStream()) : res.text();
179
+ await drained.catch(() => {
180
+ });
181
+ } else {
182
+ text = await res.text();
183
+ }
245
184
  } finally {
246
185
  clearTimeout(timeout);
247
186
  }
248
- if ((res.status === 429 || res.status >= 500 && res.status <= 599) && attempt < maxAttempts) {
187
+ if (retrying) {
249
188
  const retryAfter = res.headers.get("retry-after");
250
189
  let delayMs = 0;
251
190
  if (retryAfter) {
@@ -258,7 +197,6 @@ var BaseClient = class {
258
197
  await new Promise((r) => setTimeout(r, delayMs));
259
198
  continue;
260
199
  }
261
- const text = await res.text();
262
200
  let parsed;
263
201
  try {
264
202
  parsed = text ? JSON.parse(text) : void 0;
@@ -280,6 +218,266 @@ var BaseClient = class {
280
218
  }
281
219
  };
282
220
 
221
+ // src/types/capabilities.ts
222
+ var CAPABILITY_IDS = [
223
+ "channel.connect_link.create.execute",
224
+ "channel.connect_link.revoke.execute",
225
+ "channel.connection.disconnect.execute",
226
+ "helpdesk.conversation.reply.execute",
227
+ "helpdesk.conversation.update.execute",
228
+ "helpdesk.webhook.create.execute",
229
+ "helpdesk.webhook.update.execute",
230
+ "helpdesk.webhook.delete.execute"
231
+ ];
232
+ var CAPABILITY_ROUTES = {
233
+ "channel.connect_link.create.execute": {
234
+ method: "POST",
235
+ path_template: "/api/v1/channels/connect-links"
236
+ },
237
+ "channel.connect_link.revoke.execute": {
238
+ method: "DELETE",
239
+ path_template: "/api/v1/channels/connect-links/{id}"
240
+ },
241
+ "channel.connection.disconnect.execute": {
242
+ method: "DELETE",
243
+ path_template: "/api/v1/channels/connections/{id}"
244
+ },
245
+ "helpdesk.conversation.reply.execute": {
246
+ method: "POST",
247
+ path_template: "/api/v1/helpdesk/replies"
248
+ },
249
+ "helpdesk.conversation.update.execute": {
250
+ method: "PATCH",
251
+ path_template: "/api/v1/helpdesk/conversations/{id}"
252
+ },
253
+ "helpdesk.webhook.create.execute": {
254
+ method: "POST",
255
+ path_template: "/api/v1/webhooks"
256
+ },
257
+ "helpdesk.webhook.update.execute": {
258
+ method: "PATCH",
259
+ path_template: "/api/v1/webhooks/{id}"
260
+ },
261
+ "helpdesk.webhook.delete.execute": {
262
+ method: "DELETE",
263
+ path_template: "/api/v1/webhooks/{id}"
264
+ }
265
+ };
266
+
267
+ // src/capability-confirmer.ts
268
+ function resolvePath(template, pathParams) {
269
+ return template.replace(/\{([^}/]+)\}/g, (_match, name) => {
270
+ const value = pathParams?.[name];
271
+ return value === void 0 ? `{${name}}` : encodeURIComponent(String(value));
272
+ });
273
+ }
274
+ var CapabilityConfirmer = class {
275
+ constructor(confirmations, defaults) {
276
+ this.confirmations = confirmations;
277
+ this.defaults = defaults;
278
+ }
279
+ confirmations;
280
+ defaults;
281
+ /**
282
+ * Return the request options to use for a confirmable write, minting the
283
+ * idempotency key and confirmation token first when auto-confirm is active.
284
+ *
285
+ * `body` is the pending request payload (`undefined` for `DELETE` routes).
286
+ * It is handed to the `previewSummary` callback by reference so the summary
287
+ * can describe the specific action, not just the route — it is the caller's
288
+ * own payload, so it is passed through unmodified and unredacted.
289
+ */
290
+ async prepare(request, pathParams, options) {
291
+ const auto = options?.autoConfirm === false ? void 0 : options?.autoConfirm ?? this.defaults;
292
+ if (!auto) return options;
293
+ const idempotencyKey = resolveIdempotencyKey(options?.idempotencyKey);
294
+ const callerKeyIsUsable = idempotencyKey === options?.idempotencyKey;
295
+ if (callerKeyIsUsable && options?.capabilityConfirmation) return options;
296
+ const route = CAPABILITY_ROUTES[request.capabilityId];
297
+ const path = resolvePath(route.path_template, pathParams);
298
+ const previewSummary = auto.previewSummary({
299
+ ...request,
300
+ method: route.method,
301
+ path,
302
+ ...pathParams ? { pathParams } : {},
303
+ idempotencyKey
304
+ });
305
+ if (typeof previewSummary !== "string" || previewSummary.trim() === "") {
306
+ throw new Error(
307
+ `autoConfirm.previewSummary must return a non-empty summary for ${request.capabilityId}. The summary is the audit record of what your user approved \u2014 refusing to assert user_approved: true without one.`
308
+ );
309
+ }
310
+ const { data } = await this.confirmations.create({
311
+ capability_id: request.capabilityId,
312
+ ...pathParams ? { path_params: pathParams } : {},
313
+ idempotency_key: idempotencyKey,
314
+ preview_summary: previewSummary,
315
+ user_approved: true
316
+ });
317
+ return {
318
+ ...options,
319
+ idempotencyKey,
320
+ capabilityConfirmation: data.confirmation_token
321
+ };
322
+ }
323
+ };
324
+
325
+ // src/resources/bookings.ts
326
+ var BookingsManage = class {
327
+ constructor(client) {
328
+ this.client = client;
329
+ }
330
+ client;
331
+ /**
332
+ * Read what the holder of a manage token may see and do. Honour
333
+ * `can_cancel` / `can_reschedule` — they already apply the policy windows.
334
+ */
335
+ async get(token) {
336
+ return this.client.get(`/api/v1/bookings/manage/${encodeURIComponent(token)}`);
337
+ }
338
+ /** Cancel on the customer's behalf. Rejected outside the cancel window. */
339
+ async cancel(token, input, options) {
340
+ return this.client.postOnce(
341
+ `/api/v1/bookings/manage/${encodeURIComponent(token)}/cancel`,
342
+ input ?? {},
343
+ options
344
+ );
345
+ }
346
+ /**
347
+ * Move the booking on the customer's behalf. Rejected outside the reschedule
348
+ * window. Returns a NEW booking id and a new manage token — the old token
349
+ * stops working, so relay the new one into whatever link you send next.
350
+ */
351
+ async reschedule(token, input, options) {
352
+ return this.client.postOnce(
353
+ `/api/v1/bookings/manage/${encodeURIComponent(token)}/reschedule`,
354
+ input,
355
+ options
356
+ );
357
+ }
358
+ };
359
+ var Bookings = class {
360
+ constructor(client) {
361
+ this.client = client;
362
+ this.manage = new BookingsManage(client);
363
+ }
364
+ client;
365
+ /** Customer-side actions addressed by manage token. */
366
+ manage;
367
+ /** List the bookable service catalogue. Active-only unless asked otherwise. */
368
+ async listServices(options) {
369
+ const params = {};
370
+ if (options?.include_inactive !== void 0) {
371
+ params.include_inactive = String(options.include_inactive);
372
+ }
373
+ return this.client.get("/api/v1/bookings/services", params);
374
+ }
375
+ /** List the bookable resources — staff, rooms, and equipment. */
376
+ async listResources() {
377
+ return this.client.get("/api/v1/bookings/resources");
378
+ }
379
+ /**
380
+ * List free slots for a service over a window. Slots reflect opening hours,
381
+ * time off, buffers, and existing bookings at the moment of the call — they
382
+ * are not held, so a slot can be taken before you book it.
383
+ */
384
+ async availability(options) {
385
+ const params = {
386
+ service_id: options.service_id,
387
+ from_ts: String(options.from_ts),
388
+ to_ts: String(options.to_ts)
389
+ };
390
+ if (options.resource_id) params.resource_id = options.resource_id;
391
+ return this.client.get("/api/v1/bookings/availability", params);
392
+ }
393
+ /**
394
+ * The dates a service can be booked on — the half `availability` cannot
395
+ * answer. Availability returns free slots and nothing else, so a closed day,
396
+ * an evening past closing and a fully booked day are all the same empty
397
+ * array. A date absent from this list is closed; on a listed date, compare
398
+ * `last_start_ts` against the clock to tell "too late today" from "full".
399
+ */
400
+ async schedule(options) {
401
+ const params = {
402
+ service_id: options.service_id,
403
+ from_ts: String(options.from_ts),
404
+ to_ts: String(options.to_ts)
405
+ };
406
+ if (options.resource_id) params.resource_id = options.resource_id;
407
+ return this.client.get("/api/v1/bookings/schedule", params);
408
+ }
409
+ /**
410
+ * List bookings with cursor-based pagination and optional filters.
411
+ *
412
+ * Check `pagination.truncated`: when true the read window was clipped and
413
+ * matching bookings exist that no cursor reaches — narrow `from_ts`/`to_ts`.
414
+ */
415
+ async list(options) {
416
+ const params = {};
417
+ if (options?.limit !== void 0) params.limit = String(options.limit);
418
+ if (options?.cursor) params.cursor = options.cursor;
419
+ if (options?.status) params.status = options.status;
420
+ if (options?.resource_id) params.resource_id = options.resource_id;
421
+ if (options?.from_ts !== void 0) params.from_ts = String(options.from_ts);
422
+ if (options?.to_ts !== void 0) params.to_ts = String(options.to_ts);
423
+ return this.client.get("/api/v1/bookings", params);
424
+ }
425
+ /**
426
+ * Book a party — every item succeeds or none do (max 50).
427
+ *
428
+ * Each created booking comes back with a `manage_token` exactly once; only
429
+ * its hash is stored, so persist it if you need the customer's manage link.
430
+ *
431
+ * Automatically idempotent: the SDK mints an `Idempotency-Key` so its own
432
+ * 5xx retries replay rather than book the slot twice. Supply
433
+ * `options.idempotencyKey` to deduplicate across your OWN retries too — the
434
+ * server keys on it for 24 hours, so re-sending the same key after a network
435
+ * timeout returns the original bookings instead of a second set.
436
+ */
437
+ async create(input, options) {
438
+ return this.client.postOnce("/api/v1/bookings", input, options);
439
+ }
440
+ /** Get a booking by ID. */
441
+ async get(id) {
442
+ return this.client.get(`/api/v1/bookings/${encodeURIComponent(id)}`);
443
+ }
444
+ /**
445
+ * Annotate a booking. At least one of `notes` (customer-visible) or
446
+ * `internal_notes` (staff-only) is required; `""` clears a field.
447
+ */
448
+ async update(id, input, options) {
449
+ return this.client.patch(`/api/v1/bookings/${encodeURIComponent(id)}`, input, options);
450
+ }
451
+ /** Cancel as the business — the cancel window is bypassed. */
452
+ async cancel(id, input, options) {
453
+ return this.client.postOnce(
454
+ `/api/v1/bookings/${encodeURIComponent(id)}/cancel`,
455
+ input ?? {},
456
+ options
457
+ );
458
+ }
459
+ /**
460
+ * Move a booking as the business — the reschedule window is bypassed.
461
+ * Returns a NEW booking id and a new manage token; the old booking is
462
+ * cancelled and its token stops working.
463
+ */
464
+ async reschedule(id, input, options) {
465
+ return this.client.postOnce(
466
+ `/api/v1/bookings/${encodeURIComponent(id)}/reschedule`,
467
+ input,
468
+ options
469
+ );
470
+ }
471
+ /** Mark a booking as a no-show. */
472
+ async markNoShow(id, options) {
473
+ return this.client.postOnce(
474
+ `/api/v1/bookings/${encodeURIComponent(id)}/no-show`,
475
+ void 0,
476
+ options
477
+ );
478
+ }
479
+ };
480
+
283
481
  // src/resources/capability-confirmations.ts
284
482
  var CapabilityConfirmations = class {
285
483
  constructor(client) {
@@ -297,6 +495,13 @@ var CapabilityConfirmations = class {
297
495
  * Setting `user_approved: true` asserts that a human on your side approved
298
496
  * this specific action. `preview_summary` is what they approved, and is
299
497
  * retained for audit — write it for a human reader, not a log parser.
498
+ *
499
+ * Deliberately unkeyed, unlike the writes it authorizes. Minting is not the
500
+ * state change the guarantee exists to protect: the write itself is already
501
+ * bound to `idempotency_key`, so a retry that mints a second token cannot
502
+ * produce a second write. Keying this call would instead park a credential
503
+ * designed to expire in 15 minutes inside a replay cache that answers for 24
504
+ * hours — a worse trade than the duplicate token it would avoid.
300
505
  */
301
506
  async create(input) {
302
507
  return this.client.post("/api/v1/capability-confirmations", input);
@@ -321,6 +526,13 @@ var ChannelConnectLinks = class {
321
526
  *
322
527
  * Requires the `channel.connect.manage` scope; OAuth callers additionally
323
528
  * need the workspace `admin` role.
529
+ *
530
+ * Automatically idempotent: an unkeyed retry mints a SECOND live single-use
531
+ * link for the same person, and only one of the two can ever be consumed —
532
+ * the other stays outstanding until it is revoked or expires. The key the
533
+ * confirmer chose is the key that goes out — a capability confirmation is
534
+ * bound to its idempotency key, so minting a fresh one here would invalidate
535
+ * the confirmation.
324
536
  */
325
537
  async create(input, options) {
326
538
  const resolved = await this.confirmer.prepare(
@@ -328,7 +540,7 @@ var ChannelConnectLinks = class {
328
540
  void 0,
329
541
  options
330
542
  );
331
- return this.client.post("/api/v1/channels/connect-links", input, resolved);
543
+ return this.client.postOnce("/api/v1/channels/connect-links", input, resolved);
332
544
  }
333
545
  /**
334
546
  * List the workspace's connect links (tokens are never returned), newest
@@ -423,9 +635,17 @@ var Contacts = class {
423
635
  if (options?.search) params.search = options.search;
424
636
  return this.client.get("/api/v1/contacts", params);
425
637
  }
426
- /** Create a new contact. Email must be unique in the workspace. */
427
- async create(input) {
428
- return this.client.post("/api/v1/contacts", input);
638
+ /**
639
+ * Create a new contact. Email must be unique in the workspace.
640
+ *
641
+ * Automatically idempotent: the SDK mints an `Idempotency-Key` so its own
642
+ * 5xx retries replay rather than run the create a second time. Uniqueness
643
+ * alone would not save you here — it turns the retry of a committed create
644
+ * into a spurious conflict, which reads as "the contact was not created".
645
+ * Supply `options.idempotencyKey` to deduplicate across your OWN retries too.
646
+ */
647
+ async create(input, options) {
648
+ return this.client.postOnce("/api/v1/contacts", input, options);
429
649
  }
430
650
  /** Get a contact by ID. */
431
651
  async get(id) {
@@ -446,13 +666,26 @@ var Contacts = class {
446
666
  if (options?.cursor) params.cursor = options.cursor;
447
667
  return this.client.get(`/api/v1/contacts/${encodeURIComponent(id)}/activities`, params);
448
668
  }
449
- /** Add a note to a contact's timeline. */
450
- async addNote(id, input) {
451
- return this.client.post(`/api/v1/contacts/${encodeURIComponent(id)}/notes`, input);
669
+ /**
670
+ * Add a note to a contact's timeline.
671
+ *
672
+ * Automatically idempotent: nothing about a note is unique, so an unkeyed
673
+ * retry appends the same text to the timeline twice. Supply
674
+ * `options.idempotencyKey` to deduplicate across your OWN retries too.
675
+ */
676
+ async addNote(id, input, options) {
677
+ return this.client.postOnce(`/api/v1/contacts/${encodeURIComponent(id)}/notes`, input, options);
452
678
  }
453
- /** Bulk import contacts (max 500). Duplicates are skipped. */
454
- async import(contacts) {
455
- return this.client.post("/api/v1/contacts/import", { contacts });
679
+ /**
680
+ * Bulk import contacts (max 500). Duplicates are skipped.
681
+ *
682
+ * Automatically idempotent: the import is processed in chunks, so a retry
683
+ * after a partial failure re-walks the whole batch and reports `added` /
684
+ * `skipped` counts for a run that was not the first. Supply
685
+ * `options.idempotencyKey` to deduplicate across your OWN retries too.
686
+ */
687
+ async import(contacts, options) {
688
+ return this.client.postOnce("/api/v1/contacts/import", { contacts }, options);
456
689
  }
457
690
  };
458
691
 
@@ -471,9 +704,15 @@ var Deals = class {
471
704
  if (options?.search) params.search = options.search;
472
705
  return this.client.get("/api/v1/deals", params);
473
706
  }
474
- /** Create a new deal. */
475
- async create(input) {
476
- return this.client.post("/api/v1/deals", input);
707
+ /**
708
+ * Create a new deal.
709
+ *
710
+ * Automatically idempotent: nothing about a deal is unique, so an unkeyed
711
+ * retry puts a second identical deal in the pipeline. Supply
712
+ * `options.idempotencyKey` to deduplicate across your OWN retries too.
713
+ */
714
+ async create(input, options) {
715
+ return this.client.postOnce("/api/v1/deals", input, options);
477
716
  }
478
717
  /** Get a deal by ID. */
479
718
  async get(id) {
@@ -517,9 +756,18 @@ var Emails = class {
517
756
  /**
518
757
  * Send a transactional email using a template (HTTP 202). The returned `id`
519
758
  * is an email send id — poll `emails.get(id)` with it to track delivery.
759
+ *
760
+ * Automatically idempotent: the SDK mints an `Idempotency-Key` so its own
761
+ * 5xx retries replay rather than queue a second copy into someone's inbox —
762
+ * a send that already committed cannot be un-sent. Supply
763
+ * `options.idempotencyKey` to deduplicate across your OWN retries too.
764
+ *
765
+ * `input.idempotency_key` is the older, body-level form of the same control
766
+ * and still takes precedence server-side, so setting it keeps working
767
+ * unchanged.
520
768
  */
521
- async send(input) {
522
- return this.client.post("/api/v1/emails", input);
769
+ async send(input, options) {
770
+ return this.client.postOnce("/api/v1/emails", input, options);
523
771
  }
524
772
  /** Get the delivery status of a sent email. */
525
773
  async get(id) {
@@ -528,9 +776,14 @@ var Emails = class {
528
776
  /**
529
777
  * Send the same template to multiple recipients (max 100, HTTP 202). Each
530
778
  * queued recipient gets its own send id in `results` for `emails.get(id)`.
779
+ *
780
+ * Automatically idempotent — and this is the call where it matters most: an
781
+ * unkeyed retry of a batch that already committed sends up to 100 duplicate
782
+ * emails. Supply `options.idempotencyKey` to deduplicate across your OWN
783
+ * retries too.
531
784
  */
532
- async batch(input) {
533
- return this.client.post("/api/v1/emails/batch", input);
785
+ async batch(input, options) {
786
+ return this.client.postOnce("/api/v1/emails/batch", input, options);
534
787
  }
535
788
  };
536
789
 
@@ -540,9 +793,17 @@ var Gdpr = class {
540
793
  this.client = client;
541
794
  }
542
795
  client;
543
- /** Request a workspace data export. Runs asynchronously. */
544
- async requestExport() {
545
- return this.client.post("/api/v1/gdpr/export");
796
+ /**
797
+ * Request a workspace data export. Runs asynchronously.
798
+ *
799
+ * Automatically idempotent: the request is recorded and the export is
800
+ * scheduled in one step with no de-duplication of its own, so an unkeyed
801
+ * retry files a second subject-access request and runs a second full export
802
+ * of the workspace. Supply `options.idempotencyKey` to deduplicate across
803
+ * your OWN retries too.
804
+ */
805
+ async requestExport(options) {
806
+ return this.client.postOnce("/api/v1/gdpr/export", void 0, options);
546
807
  }
547
808
  /** List all workspace export requests. */
548
809
  async listExports() {
@@ -552,7 +813,13 @@ var Gdpr = class {
552
813
  async getExport(id) {
553
814
  return this.client.get(`/api/v1/gdpr/exports/${encodeURIComponent(id)}`);
554
815
  }
555
- /** Record a GDPR consent decision for a contact by email. */
816
+ /**
817
+ * Record a GDPR consent decision for a contact by email.
818
+ *
819
+ * Deliberately unkeyed: a decision is stored once per
820
+ * (workspace, email, consent type) and overwritten in place, so re-sending
821
+ * the same body reaches the same state and returns the same record id.
822
+ */
556
823
  async recordConsent(input) {
557
824
  return this.client.post("/api/v1/gdpr/consent", input);
558
825
  }
@@ -560,7 +827,15 @@ var Gdpr = class {
560
827
  async getConsent(email) {
561
828
  return this.client.get(`/api/v1/gdpr/consent/${encodeURIComponent(email)}`);
562
829
  }
563
- /** Record cookie consent from an external site (legacy endpoint). */
830
+ /**
831
+ * Record cookie consent from an external site (legacy endpoint).
832
+ *
833
+ * Deliberately unkeyed: this legacy route predates the versioned API and
834
+ * does not run the `Idempotency-Key` machinery, so a key here would be a
835
+ * header that changes nothing while implying a guarantee the endpoint cannot
836
+ * make. Treat a failed call as "unknown" and re-send only if a missing
837
+ * consent log matters more to you than a duplicate one.
838
+ */
564
839
  async cookieConsent(input) {
565
840
  return this.client.post("/api/cookie-consent", input);
566
841
  }
@@ -624,8 +899,16 @@ var HelpdeskReplies = class {
624
899
  /**
625
900
  * Send an operator reply or internal note. Returns HTTP 201.
626
901
  *
627
- * Pass an `idempotencyKey` so retried requests do not create duplicate
628
- * messages it is REQUIRED for capability-scoped tokens.
902
+ * Automatically idempotent: a reply is a message to a real person, and an
903
+ * unkeyed retry sends it to them twice. The key the confirmer chose is the
904
+ * key that goes out — a capability confirmation is bound to its idempotency
905
+ * key, so minting a fresh one here would invalidate the confirmation.
906
+ *
907
+ * Pass `options.idempotencyKey` to deduplicate across your OWN retries too.
908
+ * It is REQUIRED for capability-scoped tokens, which need it paired with a
909
+ * `capabilityConfirmation` — a generated key satisfies the pairing's key
910
+ * half only; the confirmation is still yours to supply (or to let
911
+ * `autoConfirm` mint).
629
912
  */
630
913
  async create(input, options) {
631
914
  const resolved = await this.confirmer.prepare(
@@ -633,7 +916,7 @@ var HelpdeskReplies = class {
633
916
  void 0,
634
917
  options
635
918
  );
636
- return this.client.post("/api/v1/helpdesk/replies", input, resolved);
919
+ return this.client.postOnce("/api/v1/helpdesk/replies", input, resolved);
637
920
  }
638
921
  };
639
922
  var Helpdesk = class {
@@ -661,9 +944,15 @@ var Posts = class {
661
944
  if (options?.type) params.type = options.type;
662
945
  return this.client.get("/api/v1/posts", params);
663
946
  }
664
- /** Create a new post with content and target channels. */
665
- async create(input) {
666
- return this.client.post("/api/v1/posts", input);
947
+ /**
948
+ * Create a new post with content and target channels.
949
+ *
950
+ * Automatically idempotent: the SDK mints an `Idempotency-Key` so its own
951
+ * 5xx retries replay rather than draft the post twice. Supply
952
+ * `options.idempotencyKey` to deduplicate across your OWN retries too.
953
+ */
954
+ async create(input, options) {
955
+ return this.client.postOnce("/api/v1/posts", input, options);
667
956
  }
668
957
  /** Get a post by ID, including its per-channel variants. */
669
958
  async get(id) {
@@ -677,11 +966,26 @@ var Posts = class {
677
966
  async remove(id) {
678
967
  return this.client.delete(`/api/v1/posts/${encodeURIComponent(id)}`);
679
968
  }
680
- /** Schedule a post for future publication. */
969
+ /**
970
+ * Schedule a post for future publication.
971
+ *
972
+ * Deliberately unkeyed: re-sending the same `scheduled_at` for an
973
+ * already-scheduled post returns the original `workflow_id` rather than
974
+ * starting a second one, so a retried schedule cannot double-publish. A
975
+ * *different* time is rejected — unschedule first.
976
+ */
681
977
  async schedule(id, input) {
682
978
  return this.client.post(`/api/v1/posts/${encodeURIComponent(id)}/schedule`, input);
683
979
  }
684
- /** Publish a post immediately to all target channels. */
980
+ /**
981
+ * Publish a post immediately to all target channels.
982
+ *
983
+ * Deliberately unkeyed: publishing moves the post out of the set of statuses
984
+ * that may be published, so the retry of a publish that already committed is
985
+ * refused rather than posting a second time. It is refused with a 400 though
986
+ * — treat an error here as "check the post's status", not as "nothing
987
+ * happened".
988
+ */
685
989
  async publish(id) {
686
990
  return this.client.post(`/api/v1/posts/${encodeURIComponent(id)}/publish`);
687
991
  }
@@ -702,10 +1006,15 @@ var Scan = class {
702
1006
  * Queue a scan. Provide exactly one of `url`, `orgnr`, or `name`.
703
1007
  * Runs asynchronously — poll with `get()` or use `waitForResult()`.
704
1008
  *
1009
+ * Automatically idempotent: a scan job is queued the moment it is created,
1010
+ * so an unkeyed retry starts a second crawl of the same site and returns an
1011
+ * id for a job that duplicates one already running. Supply
1012
+ * `options.idempotencyKey` to deduplicate across your OWN retries too.
1013
+ *
705
1014
  * @throws Error before any request when zero or several selectors are set —
706
1015
  * the server would reject the body anyway; failing locally is clearer.
707
1016
  */
708
- async create(input) {
1017
+ async create(input, options) {
709
1018
  const entries = ["url", "orgnr", "name"].filter(
710
1019
  (key2) => input[key2] !== void 0 && input[key2] !== ""
711
1020
  );
@@ -713,7 +1022,7 @@ var Scan = class {
713
1022
  throw new Error("scan.create requires exactly one of url, orgnr, or name");
714
1023
  }
715
1024
  const key = entries[0];
716
- return this.client.post("/api/v1/scan", { [key]: input[key] });
1025
+ return this.client.postOnce("/api/v1/scan", { [key]: input[key] }, options);
717
1026
  }
718
1027
  /** Get a scan job's status and, once done, its findings payload. */
719
1028
  async get(id) {
@@ -771,6 +1080,13 @@ var Webhooks = class {
771
1080
  * `secret` is typed optional because an idempotent replay (retrying with the
772
1081
  * same `Idempotency-Key`, `X-Idempotent-Replayed: true`) returns the existing
773
1082
  * endpoint WITHOUT the secret — handle that case (rotate if you lost it).
1083
+ *
1084
+ * Automatically idempotent: a duplicate endpoint is not a stray row, it is a
1085
+ * second copy of every future delivery to the same URL, forever. The key the
1086
+ * confirmer chose is the key that goes out — a capability confirmation is
1087
+ * bound to its idempotency key, so minting a fresh one here would invalidate
1088
+ * the confirmation. That the SDK now always sends a key is also what makes
1089
+ * the replay-without-secret case above reachable on a plain 5xx retry.
774
1090
  */
775
1091
  async create(input, options) {
776
1092
  const resolved = await this.confirmer.prepare(
@@ -778,7 +1094,7 @@ var Webhooks = class {
778
1094
  void 0,
779
1095
  options
780
1096
  );
781
- return this.client.post("/api/v1/webhooks", input, resolved);
1097
+ return this.client.postOnce("/api/v1/webhooks", input, resolved);
782
1098
  }
783
1099
  /** Get a webhook endpoint by ID. */
784
1100
  async get(id) {
@@ -813,7 +1129,14 @@ var Webhooks = class {
813
1129
  if (options?.limit !== void 0) params.limit = String(options.limit);
814
1130
  return this.client.get(`/api/v1/webhooks/${encodeURIComponent(id)}/deliveries`, params);
815
1131
  }
816
- /** Queue a signed `test.ping` delivery to the endpoint. Returns HTTP 202. */
1132
+ /**
1133
+ * Queue a signed `test.ping` delivery to the endpoint. Returns HTTP 202.
1134
+ *
1135
+ * Deliberately unkeyed: a duplicate ping is the one duplicate that costs
1136
+ * nothing. Real deliveries are retried too, so any endpoint worth pointing at
1137
+ * already tolerates receiving the same event twice — that is what this call
1138
+ * exists to prove.
1139
+ */
817
1140
  async test(id) {
818
1141
  return this.client.post(`/api/v1/webhooks/${encodeURIComponent(id)}/test`);
819
1142
  }
@@ -903,6 +1226,7 @@ async function verifyWebhookSignature(input) {
903
1226
 
904
1227
  // src/index.ts
905
1228
  var Medal = class {
1229
+ bookings;
906
1230
  capabilityConfirmations;
907
1231
  channels;
908
1232
  emails;
@@ -932,6 +1256,7 @@ var Medal = class {
932
1256
  this.capabilityConfirmations,
933
1257
  options?.autoConfirmCapabilities
934
1258
  );
1259
+ this.bookings = new Bookings(client);
935
1260
  this.channels = new Channels(client, confirmer);
936
1261
  this.emails = new Emails(client);
937
1262
  this.contacts = new Contacts(client);
@@ -951,6 +1276,7 @@ var src_default = Medal;
951
1276
  // Annotate the CommonJS export names for ESM import in node:
952
1277
  0 && (module.exports = {
953
1278
  BaseClient,
1279
+ Bookings,
954
1280
  CAPABILITY_IDS,
955
1281
  CAPABILITY_ROUTES,
956
1282
  CapabilityConfirmations,