@medalsocial/sdk 1.6.0 → 1.8.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.
@@ -1,3 +1,191 @@
1
+ // src/types/common.ts
2
+ var MedalApiError = class extends Error {
3
+ status;
4
+ code;
5
+ details;
6
+ constructor(status, code, message, details) {
7
+ super(message);
8
+ this.name = "MedalApiError";
9
+ this.status = status;
10
+ this.code = code;
11
+ this.details = details;
12
+ }
13
+ };
14
+
15
+ // src/client.ts
16
+ function randomIdempotencyKey() {
17
+ const webCrypto = globalThis.crypto;
18
+ if (typeof webCrypto.randomUUID === "function") {
19
+ return webCrypto.randomUUID();
20
+ }
21
+ const bytes = new Uint8Array(16);
22
+ webCrypto.getRandomValues(bytes);
23
+ bytes[6] = bytes[6] & 15 | 64;
24
+ bytes[8] = bytes[8] & 63 | 128;
25
+ const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
26
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
27
+ }
28
+ function resolveIdempotencyKey(supplied) {
29
+ return (supplied ?? "").trim() || randomIdempotencyKey();
30
+ }
31
+ var BaseClient = class {
32
+ /** Resolved client configuration. */
33
+ config;
34
+ constructor(config) {
35
+ this.config = config;
36
+ }
37
+ /** Execute an authenticated GET request and return the parsed JSON body. */
38
+ async get(path, params, options) {
39
+ const url = this.buildUrl(path, params);
40
+ return this.request(url, { method: "GET", headers: options?.headers });
41
+ }
42
+ /** Execute an authenticated POST request with a JSON body. */
43
+ async post(path, body, options) {
44
+ return this.request(
45
+ this.buildUrl(path),
46
+ {
47
+ method: "POST",
48
+ headers: this.writeHeaders(options),
49
+ body: body !== void 0 ? JSON.stringify(body) : void 0
50
+ },
51
+ options?.retry
52
+ );
53
+ }
54
+ /**
55
+ * Execute a POST that must never execute twice, guaranteeing an
56
+ * `Idempotency-Key`.
57
+ *
58
+ * {@link BaseClient.post} retries 429 and 5xx automatically, so a write
59
+ * whose transaction committed before the gateway failed would otherwise be
60
+ * submitted a second time — booking the same slot twice. A key turns that
61
+ * retry into a replay: the server keys on the key, the workspace, and the
62
+ * method+path, and answers a repeat with the stored response, or 409 while
63
+ * the first attempt is still in flight. Either way the write happens once.
64
+ *
65
+ * The key is minted ONCE here, outside the retry loop in `request`, so every
66
+ * attempt of the same logical call carries the same value — a key minted per
67
+ * attempt would deduplicate nothing. A caller-supplied key always wins, so
68
+ * callers keeping their own records stay in control. See
69
+ * {@link resolveIdempotencyKey} for what counts as supplied.
70
+ */
71
+ async postOnce(path, body, options) {
72
+ return this.post(path, body, {
73
+ ...options,
74
+ idempotencyKey: resolveIdempotencyKey(options?.idempotencyKey)
75
+ });
76
+ }
77
+ /** Execute an authenticated PATCH request with a JSON body. */
78
+ async patch(path, body, options) {
79
+ return this.request(
80
+ this.buildUrl(path),
81
+ {
82
+ method: "PATCH",
83
+ headers: this.writeHeaders(options),
84
+ body: JSON.stringify(body)
85
+ },
86
+ options?.retry
87
+ );
88
+ }
89
+ /** Execute an authenticated DELETE request. */
90
+ async delete(path, options) {
91
+ return this.request(
92
+ this.buildUrl(path),
93
+ {
94
+ method: "DELETE",
95
+ headers: this.writeHeaders(options)
96
+ },
97
+ options?.retry
98
+ );
99
+ }
100
+ writeHeaders(options) {
101
+ const headers = {};
102
+ for (const [key, value] of Object.entries(options?.headers ?? {})) {
103
+ headers[key.toLowerCase()] = value;
104
+ }
105
+ headers["content-type"] = "application/json";
106
+ if (options?.idempotencyKey) {
107
+ headers["idempotency-key"] = options.idempotencyKey;
108
+ }
109
+ if (options?.capabilityConfirmation) {
110
+ headers["x-capability-confirmation"] = options.capabilityConfirmation;
111
+ }
112
+ return headers;
113
+ }
114
+ buildUrl(path, params) {
115
+ const url = new URL(`${this.config.baseUrl}${path}`);
116
+ if (params) {
117
+ for (const [key, value] of Object.entries(params)) {
118
+ if (value !== void 0) {
119
+ url.searchParams.set(key, value);
120
+ }
121
+ }
122
+ }
123
+ return url.toString();
124
+ }
125
+ async request(url, init, retry = true) {
126
+ const maxAttempts = retry ? 3 : 1;
127
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
128
+ const headers = new Headers(init.headers);
129
+ headers.set("authorization", `Bearer ${this.config.token}`);
130
+ if (this.config.workspaceId) {
131
+ headers.set("x-workspace-id", this.config.workspaceId);
132
+ }
133
+ try {
134
+ headers.set("user-agent", this.config.userAgent);
135
+ } catch {
136
+ }
137
+ const controller = new AbortController();
138
+ const timeout = setTimeout(() => controller.abort(), this.config.timeout);
139
+ let res;
140
+ let text = "";
141
+ let retrying = false;
142
+ try {
143
+ res = await fetch(url, { ...init, headers, signal: controller.signal });
144
+ retrying = (res.status === 429 || res.status >= 500 && res.status <= 599) && attempt < maxAttempts;
145
+ if (retrying) {
146
+ const drained = res.body ? res.body.pipeTo(new WritableStream()) : res.text();
147
+ await drained.catch(() => {
148
+ });
149
+ } else {
150
+ text = await res.text();
151
+ }
152
+ } finally {
153
+ clearTimeout(timeout);
154
+ }
155
+ if (retrying) {
156
+ const retryAfter = res.headers.get("retry-after");
157
+ let delayMs = 0;
158
+ if (retryAfter) {
159
+ const seconds = Number(retryAfter);
160
+ delayMs = Number.isFinite(seconds) ? seconds * 1e3 : 0;
161
+ }
162
+ if (delayMs <= 0) {
163
+ delayMs = 250 * attempt;
164
+ }
165
+ await new Promise((r) => setTimeout(r, delayMs));
166
+ continue;
167
+ }
168
+ let parsed;
169
+ try {
170
+ parsed = text ? JSON.parse(text) : void 0;
171
+ } catch {
172
+ parsed = text;
173
+ }
174
+ if (!res.ok) {
175
+ const body = parsed;
176
+ throw new MedalApiError(
177
+ res.status,
178
+ body?.error?.code ?? "UNKNOWN_ERROR",
179
+ body?.error?.message ?? `HTTP ${res.status}: ${res.statusText}`,
180
+ body?.error?.details
181
+ );
182
+ }
183
+ return parsed;
184
+ }
185
+ throw new Error("Request failed after retries");
186
+ }
187
+ };
188
+
1
189
  // src/types/capabilities.ts
2
190
  var CAPABILITY_IDS = [
3
191
  "channel.connect_link.create.execute",
@@ -45,13 +233,6 @@ var CAPABILITY_ROUTES = {
45
233
  };
46
234
 
47
235
  // src/capability-confirmer.ts
48
- function newIdempotencyKey() {
49
- const cryptoRef = globalThis.crypto;
50
- if (typeof cryptoRef?.randomUUID === "function") {
51
- return cryptoRef.randomUUID();
52
- }
53
- return `idem_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 12)}`;
54
- }
55
236
  function resolvePath(template, pathParams) {
56
237
  return template.replace(/\{([^}/]+)\}/g, (_match, name) => {
57
238
  const value = pathParams?.[name];
@@ -77,9 +258,10 @@ var CapabilityConfirmer = class {
77
258
  async prepare(request, pathParams, options) {
78
259
  const auto = options?.autoConfirm === false ? void 0 : options?.autoConfirm ?? this.defaults;
79
260
  if (!auto) return options;
80
- if (options?.idempotencyKey && options?.capabilityConfirmation) return options;
261
+ const idempotencyKey = resolveIdempotencyKey(options?.idempotencyKey);
262
+ const callerKeyIsUsable = idempotencyKey === options?.idempotencyKey;
263
+ if (callerKeyIsUsable && options?.capabilityConfirmation) return options;
81
264
  const route = CAPABILITY_ROUTES[request.capabilityId];
82
- const idempotencyKey = options?.idempotencyKey ?? newIdempotencyKey();
83
265
  const path = resolvePath(route.path_template, pathParams);
84
266
  const previewSummary = auto.previewSummary({
85
267
  ...request,
@@ -108,128 +290,159 @@ var CapabilityConfirmer = class {
108
290
  }
109
291
  };
110
292
 
111
- // src/types/common.ts
112
- var MedalApiError = class extends Error {
113
- status;
114
- code;
115
- details;
116
- constructor(status, code, message, details) {
117
- super(message);
118
- this.name = "MedalApiError";
119
- this.status = status;
120
- this.code = code;
121
- this.details = details;
293
+ // src/resources/bookings.ts
294
+ var BookingsManage = class {
295
+ constructor(client) {
296
+ this.client = client;
297
+ }
298
+ client;
299
+ /**
300
+ * Read what the holder of a manage token may see and do. Honour
301
+ * `can_cancel` / `can_reschedule` — they already apply the policy windows.
302
+ */
303
+ async get(token) {
304
+ return this.client.get(`/api/v1/bookings/manage/${encodeURIComponent(token)}`);
305
+ }
306
+ /** Cancel on the customer's behalf. Rejected outside the cancel window. */
307
+ async cancel(token, input, options) {
308
+ return this.client.postOnce(
309
+ `/api/v1/bookings/manage/${encodeURIComponent(token)}/cancel`,
310
+ input ?? {},
311
+ options
312
+ );
313
+ }
314
+ /**
315
+ * Move the booking on the customer's behalf. Rejected outside the reschedule
316
+ * window. Returns a NEW booking id and a new manage token — the old token
317
+ * stops working, so relay the new one into whatever link you send next.
318
+ */
319
+ async reschedule(token, input, options) {
320
+ return this.client.postOnce(
321
+ `/api/v1/bookings/manage/${encodeURIComponent(token)}/reschedule`,
322
+ input,
323
+ options
324
+ );
122
325
  }
123
326
  };
124
-
125
- // src/client.ts
126
- var BaseClient = class {
127
- /** Resolved client configuration. */
128
- config;
129
- constructor(config) {
130
- this.config = config;
327
+ var Bookings = class {
328
+ constructor(client) {
329
+ this.client = client;
330
+ this.manage = new BookingsManage(client);
131
331
  }
132
- /** Execute an authenticated GET request and return the parsed JSON body. */
133
- async get(path, params) {
134
- const url = this.buildUrl(path, params);
135
- return this.request(url, { method: "GET" });
332
+ client;
333
+ /** Customer-side actions addressed by manage token. */
334
+ manage;
335
+ /** List the bookable service catalogue. Active-only unless asked otherwise. */
336
+ async listServices(options) {
337
+ const params = {};
338
+ if (options?.include_inactive !== void 0) {
339
+ params.include_inactive = String(options.include_inactive);
340
+ }
341
+ return this.client.get("/api/v1/bookings/services", params);
136
342
  }
137
- /** Execute an authenticated POST request with a JSON body. */
138
- async post(path, body, options) {
139
- return this.request(this.buildUrl(path), {
140
- method: "POST",
141
- headers: this.writeHeaders(options),
142
- body: body !== void 0 ? JSON.stringify(body) : void 0
143
- });
343
+ /** List the bookable resources staff, rooms, and equipment. */
344
+ async listResources() {
345
+ return this.client.get("/api/v1/bookings/resources");
144
346
  }
145
- /** Execute an authenticated PATCH request with a JSON body. */
146
- async patch(path, body, options) {
147
- return this.request(this.buildUrl(path), {
148
- method: "PATCH",
149
- headers: this.writeHeaders(options),
150
- body: JSON.stringify(body)
151
- });
347
+ /**
348
+ * List free slots for a service over a window. Slots reflect opening hours,
349
+ * time off, buffers, and existing bookings at the moment of the call — they
350
+ * are not held, so a slot can be taken before you book it.
351
+ */
352
+ async availability(options) {
353
+ const params = {
354
+ service_id: options.service_id,
355
+ from_ts: String(options.from_ts),
356
+ to_ts: String(options.to_ts)
357
+ };
358
+ if (options.resource_id) params.resource_id = options.resource_id;
359
+ return this.client.get("/api/v1/bookings/availability", params);
152
360
  }
153
- /** Execute an authenticated DELETE request. */
154
- async delete(path, options) {
155
- return this.request(this.buildUrl(path), {
156
- method: "DELETE",
157
- headers: this.writeHeaders(options)
158
- });
361
+ /**
362
+ * The dates a service can be booked on — the half `availability` cannot
363
+ * answer. Availability returns free slots and nothing else, so a closed day,
364
+ * an evening past closing and a fully booked day are all the same empty
365
+ * array. A date absent from this list is closed; on a listed date, compare
366
+ * `last_start_ts` against the clock to tell "too late today" from "full".
367
+ */
368
+ async schedule(options) {
369
+ const params = {
370
+ service_id: options.service_id,
371
+ from_ts: String(options.from_ts),
372
+ to_ts: String(options.to_ts)
373
+ };
374
+ if (options.resource_id) params.resource_id = options.resource_id;
375
+ return this.client.get("/api/v1/bookings/schedule", params);
159
376
  }
160
- writeHeaders(options) {
161
- const headers = { "content-type": "application/json" };
162
- if (options?.idempotencyKey) {
163
- headers["idempotency-key"] = options.idempotencyKey;
164
- }
165
- if (options?.capabilityConfirmation) {
166
- headers["x-capability-confirmation"] = options.capabilityConfirmation;
167
- }
168
- return headers;
377
+ /**
378
+ * List bookings with cursor-based pagination and optional filters.
379
+ *
380
+ * Check `pagination.truncated`: when true the read window was clipped and
381
+ * matching bookings exist that no cursor reaches — narrow `from_ts`/`to_ts`.
382
+ */
383
+ async list(options) {
384
+ const params = {};
385
+ if (options?.limit !== void 0) params.limit = String(options.limit);
386
+ if (options?.cursor) params.cursor = options.cursor;
387
+ if (options?.status) params.status = options.status;
388
+ if (options?.resource_id) params.resource_id = options.resource_id;
389
+ if (options?.from_ts !== void 0) params.from_ts = String(options.from_ts);
390
+ if (options?.to_ts !== void 0) params.to_ts = String(options.to_ts);
391
+ return this.client.get("/api/v1/bookings", params);
169
392
  }
170
- buildUrl(path, params) {
171
- const url = new URL(`${this.config.baseUrl}${path}`);
172
- if (params) {
173
- for (const [key, value] of Object.entries(params)) {
174
- if (value !== void 0) {
175
- url.searchParams.set(key, value);
176
- }
177
- }
178
- }
179
- return url.toString();
393
+ /**
394
+ * Book a party — every item succeeds or none do (max 50).
395
+ *
396
+ * Each created booking comes back with a `manage_token` exactly once; only
397
+ * its hash is stored, so persist it if you need the customer's manage link.
398
+ *
399
+ * Automatically idempotent: the SDK mints an `Idempotency-Key` so its own
400
+ * 5xx retries replay rather than book the slot twice. Supply
401
+ * `options.idempotencyKey` to deduplicate across your OWN retries too — the
402
+ * server keys on it for 24 hours, so re-sending the same key after a network
403
+ * timeout returns the original bookings instead of a second set.
404
+ */
405
+ async create(input, options) {
406
+ return this.client.postOnce("/api/v1/bookings", input, options);
180
407
  }
181
- async request(url, init) {
182
- const maxAttempts = 3;
183
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
184
- const headers = new Headers(init.headers);
185
- headers.set("authorization", `Bearer ${this.config.token}`);
186
- if (this.config.workspaceId) {
187
- headers.set("x-workspace-id", this.config.workspaceId);
188
- }
189
- try {
190
- headers.set("user-agent", this.config.userAgent);
191
- } catch {
192
- }
193
- const controller = new AbortController();
194
- const timeout = setTimeout(() => controller.abort(), this.config.timeout);
195
- let res;
196
- try {
197
- res = await fetch(url, { ...init, headers, signal: controller.signal });
198
- } finally {
199
- clearTimeout(timeout);
200
- }
201
- if ((res.status === 429 || res.status >= 500 && res.status <= 599) && attempt < maxAttempts) {
202
- const retryAfter = res.headers.get("retry-after");
203
- let delayMs = 0;
204
- if (retryAfter) {
205
- const seconds = Number(retryAfter);
206
- delayMs = Number.isFinite(seconds) ? seconds * 1e3 : 0;
207
- }
208
- if (delayMs <= 0) {
209
- delayMs = 250 * attempt;
210
- }
211
- await new Promise((r) => setTimeout(r, delayMs));
212
- continue;
213
- }
214
- const text = await res.text();
215
- let parsed;
216
- try {
217
- parsed = text ? JSON.parse(text) : void 0;
218
- } catch {
219
- parsed = text;
220
- }
221
- if (!res.ok) {
222
- const body = parsed;
223
- throw new MedalApiError(
224
- res.status,
225
- body?.error?.code ?? "UNKNOWN_ERROR",
226
- body?.error?.message ?? `HTTP ${res.status}: ${res.statusText}`,
227
- body?.error?.details
228
- );
229
- }
230
- return parsed;
231
- }
232
- throw new Error("Request failed after retries");
408
+ /** Get a booking by ID. */
409
+ async get(id) {
410
+ return this.client.get(`/api/v1/bookings/${encodeURIComponent(id)}`);
411
+ }
412
+ /**
413
+ * Annotate a booking. At least one of `notes` (customer-visible) or
414
+ * `internal_notes` (staff-only) is required; `""` clears a field.
415
+ */
416
+ async update(id, input, options) {
417
+ return this.client.patch(`/api/v1/bookings/${encodeURIComponent(id)}`, input, options);
418
+ }
419
+ /** Cancel as the business — the cancel window is bypassed. */
420
+ async cancel(id, input, options) {
421
+ return this.client.postOnce(
422
+ `/api/v1/bookings/${encodeURIComponent(id)}/cancel`,
423
+ input ?? {},
424
+ options
425
+ );
426
+ }
427
+ /**
428
+ * Move a booking as the business the reschedule window is bypassed.
429
+ * Returns a NEW booking id and a new manage token; the old booking is
430
+ * cancelled and its token stops working.
431
+ */
432
+ async reschedule(id, input, options) {
433
+ return this.client.postOnce(
434
+ `/api/v1/bookings/${encodeURIComponent(id)}/reschedule`,
435
+ input,
436
+ options
437
+ );
438
+ }
439
+ /** Mark a booking as a no-show. */
440
+ async markNoShow(id, options) {
441
+ return this.client.postOnce(
442
+ `/api/v1/bookings/${encodeURIComponent(id)}/no-show`,
443
+ void 0,
444
+ options
445
+ );
233
446
  }
234
447
  };
235
448
 
@@ -250,6 +463,13 @@ var CapabilityConfirmations = class {
250
463
  * Setting `user_approved: true` asserts that a human on your side approved
251
464
  * this specific action. `preview_summary` is what they approved, and is
252
465
  * retained for audit — write it for a human reader, not a log parser.
466
+ *
467
+ * Deliberately unkeyed, unlike the writes it authorizes. Minting is not the
468
+ * state change the guarantee exists to protect: the write itself is already
469
+ * bound to `idempotency_key`, so a retry that mints a second token cannot
470
+ * produce a second write. Keying this call would instead park a credential
471
+ * designed to expire in 15 minutes inside a replay cache that answers for 24
472
+ * hours — a worse trade than the duplicate token it would avoid.
253
473
  */
254
474
  async create(input) {
255
475
  return this.client.post("/api/v1/capability-confirmations", input);
@@ -274,6 +494,13 @@ var ChannelConnectLinks = class {
274
494
  *
275
495
  * Requires the `channel.connect.manage` scope; OAuth callers additionally
276
496
  * need the workspace `admin` role.
497
+ *
498
+ * Automatically idempotent: an unkeyed retry mints a SECOND live single-use
499
+ * link for the same person, and only one of the two can ever be consumed —
500
+ * the other stays outstanding until it is revoked or expires. The key the
501
+ * confirmer chose is the key that goes out — a capability confirmation is
502
+ * bound to its idempotency key, so minting a fresh one here would invalidate
503
+ * the confirmation.
277
504
  */
278
505
  async create(input, options) {
279
506
  const resolved = await this.confirmer.prepare(
@@ -281,7 +508,7 @@ var ChannelConnectLinks = class {
281
508
  void 0,
282
509
  options
283
510
  );
284
- return this.client.post("/api/v1/channels/connect-links", input, resolved);
511
+ return this.client.postOnce("/api/v1/channels/connect-links", input, resolved);
285
512
  }
286
513
  /**
287
514
  * List the workspace's connect links (tokens are never returned), newest
@@ -376,9 +603,17 @@ var Contacts = class {
376
603
  if (options?.search) params.search = options.search;
377
604
  return this.client.get("/api/v1/contacts", params);
378
605
  }
379
- /** Create a new contact. Email must be unique in the workspace. */
380
- async create(input) {
381
- return this.client.post("/api/v1/contacts", input);
606
+ /**
607
+ * Create a new contact. Email must be unique in the workspace.
608
+ *
609
+ * Automatically idempotent: the SDK mints an `Idempotency-Key` so its own
610
+ * 5xx retries replay rather than run the create a second time. Uniqueness
611
+ * alone would not save you here — it turns the retry of a committed create
612
+ * into a spurious conflict, which reads as "the contact was not created".
613
+ * Supply `options.idempotencyKey` to deduplicate across your OWN retries too.
614
+ */
615
+ async create(input, options) {
616
+ return this.client.postOnce("/api/v1/contacts", input, options);
382
617
  }
383
618
  /** Get a contact by ID. */
384
619
  async get(id) {
@@ -399,13 +634,26 @@ var Contacts = class {
399
634
  if (options?.cursor) params.cursor = options.cursor;
400
635
  return this.client.get(`/api/v1/contacts/${encodeURIComponent(id)}/activities`, params);
401
636
  }
402
- /** Add a note to a contact's timeline. */
403
- async addNote(id, input) {
404
- return this.client.post(`/api/v1/contacts/${encodeURIComponent(id)}/notes`, input);
637
+ /**
638
+ * Add a note to a contact's timeline.
639
+ *
640
+ * Automatically idempotent: nothing about a note is unique, so an unkeyed
641
+ * retry appends the same text to the timeline twice. Supply
642
+ * `options.idempotencyKey` to deduplicate across your OWN retries too.
643
+ */
644
+ async addNote(id, input, options) {
645
+ return this.client.postOnce(`/api/v1/contacts/${encodeURIComponent(id)}/notes`, input, options);
405
646
  }
406
- /** Bulk import contacts (max 500). Duplicates are skipped. */
407
- async import(contacts) {
408
- return this.client.post("/api/v1/contacts/import", { contacts });
647
+ /**
648
+ * Bulk import contacts (max 500). Duplicates are skipped.
649
+ *
650
+ * Automatically idempotent: the import is processed in chunks, so a retry
651
+ * after a partial failure re-walks the whole batch and reports `added` /
652
+ * `skipped` counts for a run that was not the first. Supply
653
+ * `options.idempotencyKey` to deduplicate across your OWN retries too.
654
+ */
655
+ async import(contacts, options) {
656
+ return this.client.postOnce("/api/v1/contacts/import", { contacts }, options);
409
657
  }
410
658
  };
411
659
 
@@ -424,9 +672,15 @@ var Deals = class {
424
672
  if (options?.search) params.search = options.search;
425
673
  return this.client.get("/api/v1/deals", params);
426
674
  }
427
- /** Create a new deal. */
428
- async create(input) {
429
- return this.client.post("/api/v1/deals", input);
675
+ /**
676
+ * Create a new deal.
677
+ *
678
+ * Automatically idempotent: nothing about a deal is unique, so an unkeyed
679
+ * retry puts a second identical deal in the pipeline. Supply
680
+ * `options.idempotencyKey` to deduplicate across your OWN retries too.
681
+ */
682
+ async create(input, options) {
683
+ return this.client.postOnce("/api/v1/deals", input, options);
430
684
  }
431
685
  /** Get a deal by ID. */
432
686
  async get(id) {
@@ -470,9 +724,18 @@ var Emails = class {
470
724
  /**
471
725
  * Send a transactional email using a template (HTTP 202). The returned `id`
472
726
  * is an email send id — poll `emails.get(id)` with it to track delivery.
727
+ *
728
+ * Automatically idempotent: the SDK mints an `Idempotency-Key` so its own
729
+ * 5xx retries replay rather than queue a second copy into someone's inbox —
730
+ * a send that already committed cannot be un-sent. Supply
731
+ * `options.idempotencyKey` to deduplicate across your OWN retries too.
732
+ *
733
+ * `input.idempotency_key` is the older, body-level form of the same control
734
+ * and still takes precedence server-side, so setting it keeps working
735
+ * unchanged.
473
736
  */
474
- async send(input) {
475
- return this.client.post("/api/v1/emails", input);
737
+ async send(input, options) {
738
+ return this.client.postOnce("/api/v1/emails", input, options);
476
739
  }
477
740
  /** Get the delivery status of a sent email. */
478
741
  async get(id) {
@@ -481,9 +744,14 @@ var Emails = class {
481
744
  /**
482
745
  * Send the same template to multiple recipients (max 100, HTTP 202). Each
483
746
  * queued recipient gets its own send id in `results` for `emails.get(id)`.
747
+ *
748
+ * Automatically idempotent — and this is the call where it matters most: an
749
+ * unkeyed retry of a batch that already committed sends up to 100 duplicate
750
+ * emails. Supply `options.idempotencyKey` to deduplicate across your OWN
751
+ * retries too.
484
752
  */
485
- async batch(input) {
486
- return this.client.post("/api/v1/emails/batch", input);
753
+ async batch(input, options) {
754
+ return this.client.postOnce("/api/v1/emails/batch", input, options);
487
755
  }
488
756
  };
489
757
 
@@ -493,9 +761,17 @@ var Gdpr = class {
493
761
  this.client = client;
494
762
  }
495
763
  client;
496
- /** Request a workspace data export. Runs asynchronously. */
497
- async requestExport() {
498
- return this.client.post("/api/v1/gdpr/export");
764
+ /**
765
+ * Request a workspace data export. Runs asynchronously.
766
+ *
767
+ * Automatically idempotent: the request is recorded and the export is
768
+ * scheduled in one step with no de-duplication of its own, so an unkeyed
769
+ * retry files a second subject-access request and runs a second full export
770
+ * of the workspace. Supply `options.idempotencyKey` to deduplicate across
771
+ * your OWN retries too.
772
+ */
773
+ async requestExport(options) {
774
+ return this.client.postOnce("/api/v1/gdpr/export", void 0, options);
499
775
  }
500
776
  /** List all workspace export requests. */
501
777
  async listExports() {
@@ -505,7 +781,13 @@ var Gdpr = class {
505
781
  async getExport(id) {
506
782
  return this.client.get(`/api/v1/gdpr/exports/${encodeURIComponent(id)}`);
507
783
  }
508
- /** Record a GDPR consent decision for a contact by email. */
784
+ /**
785
+ * Record a GDPR consent decision for a contact by email.
786
+ *
787
+ * Deliberately unkeyed: a decision is stored once per
788
+ * (workspace, email, consent type) and overwritten in place, so re-sending
789
+ * the same body reaches the same state and returns the same record id.
790
+ */
509
791
  async recordConsent(input) {
510
792
  return this.client.post("/api/v1/gdpr/consent", input);
511
793
  }
@@ -513,7 +795,15 @@ var Gdpr = class {
513
795
  async getConsent(email) {
514
796
  return this.client.get(`/api/v1/gdpr/consent/${encodeURIComponent(email)}`);
515
797
  }
516
- /** Record cookie consent from an external site (legacy endpoint). */
798
+ /**
799
+ * Record cookie consent from an external site (legacy endpoint).
800
+ *
801
+ * Deliberately unkeyed: this legacy route predates the versioned API and
802
+ * does not run the `Idempotency-Key` machinery, so a key here would be a
803
+ * header that changes nothing while implying a guarantee the endpoint cannot
804
+ * make. Treat a failed call as "unknown" and re-send only if a missing
805
+ * consent log matters more to you than a duplicate one.
806
+ */
517
807
  async cookieConsent(input) {
518
808
  return this.client.post("/api/cookie-consent", input);
519
809
  }
@@ -577,8 +867,16 @@ var HelpdeskReplies = class {
577
867
  /**
578
868
  * Send an operator reply or internal note. Returns HTTP 201.
579
869
  *
580
- * Pass an `idempotencyKey` so retried requests do not create duplicate
581
- * messages it is REQUIRED for capability-scoped tokens.
870
+ * Automatically idempotent: a reply is a message to a real person, and an
871
+ * unkeyed retry sends it to them twice. The key the confirmer chose is the
872
+ * key that goes out — a capability confirmation is bound to its idempotency
873
+ * key, so minting a fresh one here would invalidate the confirmation.
874
+ *
875
+ * Pass `options.idempotencyKey` to deduplicate across your OWN retries too.
876
+ * It is REQUIRED for capability-scoped tokens, which need it paired with a
877
+ * `capabilityConfirmation` — a generated key satisfies the pairing's key
878
+ * half only; the confirmation is still yours to supply (or to let
879
+ * `autoConfirm` mint).
582
880
  */
583
881
  async create(input, options) {
584
882
  const resolved = await this.confirmer.prepare(
@@ -586,7 +884,7 @@ var HelpdeskReplies = class {
586
884
  void 0,
587
885
  options
588
886
  );
589
- return this.client.post("/api/v1/helpdesk/replies", input, resolved);
887
+ return this.client.postOnce("/api/v1/helpdesk/replies", input, resolved);
590
888
  }
591
889
  };
592
890
  var Helpdesk = class {
@@ -599,6 +897,111 @@ var Helpdesk = class {
599
897
  }
600
898
  };
601
899
 
900
+ // src/resources/portal.ts
901
+ function withSession(session) {
902
+ return { headers: { "x-portal-session": session } };
903
+ }
904
+ var ONCE = { retry: false };
905
+ var PortalLogin = class {
906
+ constructor(client) {
907
+ this.client = client;
908
+ }
909
+ client;
910
+ /**
911
+ * E-mail a one-time code to the address.
912
+ *
913
+ * Always 202 `{ status: "sent" }` — enumeration-safe: `"sent"` does not
914
+ * confirm that the address belongs to a contact. Rate-limited per address
915
+ * and per caller (`429 RATE_LIMITED`).
916
+ */
917
+ async start(input) {
918
+ return this.client.post("/api/v1/portal/login/start", input);
919
+ }
920
+ /**
921
+ * Exchange the e-mailed code for a session.
922
+ *
923
+ * `session_token` is a bearer credential for ONE contact — keep it in an
924
+ * HttpOnly cookie on the site's server. A wrong, burned or expired code all
925
+ * answer `401 PORTAL_CODE_INVALID`; the three are not distinguished, so the
926
+ * response is not an oracle for which codes exist.
927
+ */
928
+ async verify(input) {
929
+ return this.client.post("/api/v1/portal/login/verify", input, ONCE);
930
+ }
931
+ };
932
+ var Portal = class {
933
+ constructor(client) {
934
+ this.client = client;
935
+ this.login = new PortalLogin(client);
936
+ }
937
+ client;
938
+ /** E-mail one-time-code login: `start` sends the code, `verify` exchanges it. */
939
+ login;
940
+ /**
941
+ * Revoke the session. Resolves to `undefined` (the route answers 204).
942
+ *
943
+ * Not keyed: revoking twice reaches the same state — the second call answers
944
+ * `401 PORTAL_SESSION_INVALID`, which is the outcome you wanted anyway.
945
+ */
946
+ async logout(session) {
947
+ await this.client.post("/api/v1/portal/logout", void 0, {
948
+ ...withSession(session),
949
+ ...ONCE
950
+ });
951
+ }
952
+ /** The signed-in contact's own profile. */
953
+ async me(session) {
954
+ return this.client.get("/api/v1/portal/me", void 0, withSession(session));
955
+ }
956
+ /**
957
+ * Update the signed-in contact's profile. Only the supplied fields change;
958
+ * `phone: null` clears the number and `family` replaces the whole list.
959
+ * `marketing_consent` records a `marketing_email` consent decision with
960
+ * source `portal`. Returns the profile as it is after the change.
961
+ */
962
+ /**
963
+ * A profile patch is not idempotency-keyed on the server and a `marketing_consent`
964
+ * change records a dated consent event, so a retry after a committed-but-lost
965
+ * response would repeat that event: sent exactly once, like the other writes.
966
+ */
967
+ async updateMe(session, patch) {
968
+ return this.client.patch("/api/v1/portal/me", patch, { ...withSession(session), ...ONCE });
969
+ }
970
+ /**
971
+ * The contact's bookings split into `upcoming` and `past`. An upcoming
972
+ * booking that is still inside the workspace's policy windows carries
973
+ * `manage_token` and `can_manage: true`; use the token to open the site's
974
+ * manage page (`medal.bookings.manage.*`).
975
+ */
976
+ async myBookings(session) {
977
+ return this.client.get("/api/v1/portal/me/bookings", void 0, withSession(session));
978
+ }
979
+ /**
980
+ * Everything the workspace holds about the contact — profile, family,
981
+ * consents and bookings — as one JSON document (GDPR Art. 15). Synchronous,
982
+ * unlike `medal.gdpr.requestExport()`, which exports the whole workspace.
983
+ *
984
+ * Not keyed: a read-only snapshot, so a retried call costs nothing and
985
+ * duplicates nothing.
986
+ */
987
+ async exportMyData(session) {
988
+ return this.client.post("/api/v1/portal/me/export", void 0, withSession(session));
989
+ }
990
+ /**
991
+ * Erase the contact (GDPR Art. 17). Resolves to `undefined` (the route
992
+ * answers 204); the session is revoked as part of the deletion.
993
+ *
994
+ * Not keyed: deletion is terminal, so a retry meets a revoked session and
995
+ * answers `401 PORTAL_SESSION_INVALID` rather than deleting anything else.
996
+ */
997
+ async deleteMe(session) {
998
+ await this.client.post("/api/v1/portal/me/delete", void 0, {
999
+ ...withSession(session),
1000
+ ...ONCE
1001
+ });
1002
+ }
1003
+ };
1004
+
602
1005
  // src/resources/posts.ts
603
1006
  var Posts = class {
604
1007
  constructor(client) {
@@ -614,9 +1017,15 @@ var Posts = class {
614
1017
  if (options?.type) params.type = options.type;
615
1018
  return this.client.get("/api/v1/posts", params);
616
1019
  }
617
- /** Create a new post with content and target channels. */
618
- async create(input) {
619
- return this.client.post("/api/v1/posts", input);
1020
+ /**
1021
+ * Create a new post with content and target channels.
1022
+ *
1023
+ * Automatically idempotent: the SDK mints an `Idempotency-Key` so its own
1024
+ * 5xx retries replay rather than draft the post twice. Supply
1025
+ * `options.idempotencyKey` to deduplicate across your OWN retries too.
1026
+ */
1027
+ async create(input, options) {
1028
+ return this.client.postOnce("/api/v1/posts", input, options);
620
1029
  }
621
1030
  /** Get a post by ID, including its per-channel variants. */
622
1031
  async get(id) {
@@ -630,11 +1039,26 @@ var Posts = class {
630
1039
  async remove(id) {
631
1040
  return this.client.delete(`/api/v1/posts/${encodeURIComponent(id)}`);
632
1041
  }
633
- /** Schedule a post for future publication. */
1042
+ /**
1043
+ * Schedule a post for future publication.
1044
+ *
1045
+ * Deliberately unkeyed: re-sending the same `scheduled_at` for an
1046
+ * already-scheduled post returns the original `workflow_id` rather than
1047
+ * starting a second one, so a retried schedule cannot double-publish. A
1048
+ * *different* time is rejected — unschedule first.
1049
+ */
634
1050
  async schedule(id, input) {
635
1051
  return this.client.post(`/api/v1/posts/${encodeURIComponent(id)}/schedule`, input);
636
1052
  }
637
- /** Publish a post immediately to all target channels. */
1053
+ /**
1054
+ * Publish a post immediately to all target channels.
1055
+ *
1056
+ * Deliberately unkeyed: publishing moves the post out of the set of statuses
1057
+ * that may be published, so the retry of a publish that already committed is
1058
+ * refused rather than posting a second time. It is refused with a 400 though
1059
+ * — treat an error here as "check the post's status", not as "nothing
1060
+ * happened".
1061
+ */
638
1062
  async publish(id) {
639
1063
  return this.client.post(`/api/v1/posts/${encodeURIComponent(id)}/publish`);
640
1064
  }
@@ -655,10 +1079,15 @@ var Scan = class {
655
1079
  * Queue a scan. Provide exactly one of `url`, `orgnr`, or `name`.
656
1080
  * Runs asynchronously — poll with `get()` or use `waitForResult()`.
657
1081
  *
1082
+ * Automatically idempotent: a scan job is queued the moment it is created,
1083
+ * so an unkeyed retry starts a second crawl of the same site and returns an
1084
+ * id for a job that duplicates one already running. Supply
1085
+ * `options.idempotencyKey` to deduplicate across your OWN retries too.
1086
+ *
658
1087
  * @throws Error before any request when zero or several selectors are set —
659
1088
  * the server would reject the body anyway; failing locally is clearer.
660
1089
  */
661
- async create(input) {
1090
+ async create(input, options) {
662
1091
  const entries = ["url", "orgnr", "name"].filter(
663
1092
  (key2) => input[key2] !== void 0 && input[key2] !== ""
664
1093
  );
@@ -666,7 +1095,7 @@ var Scan = class {
666
1095
  throw new Error("scan.create requires exactly one of url, orgnr, or name");
667
1096
  }
668
1097
  const key = entries[0];
669
- return this.client.post("/api/v1/scan", { [key]: input[key] });
1098
+ return this.client.postOnce("/api/v1/scan", { [key]: input[key] }, options);
670
1099
  }
671
1100
  /** Get a scan job's status and, once done, its findings payload. */
672
1101
  async get(id) {
@@ -724,6 +1153,13 @@ var Webhooks = class {
724
1153
  * `secret` is typed optional because an idempotent replay (retrying with the
725
1154
  * same `Idempotency-Key`, `X-Idempotent-Replayed: true`) returns the existing
726
1155
  * endpoint WITHOUT the secret — handle that case (rotate if you lost it).
1156
+ *
1157
+ * Automatically idempotent: a duplicate endpoint is not a stray row, it is a
1158
+ * second copy of every future delivery to the same URL, forever. The key the
1159
+ * confirmer chose is the key that goes out — a capability confirmation is
1160
+ * bound to its idempotency key, so minting a fresh one here would invalidate
1161
+ * the confirmation. That the SDK now always sends a key is also what makes
1162
+ * the replay-without-secret case above reachable on a plain 5xx retry.
727
1163
  */
728
1164
  async create(input, options) {
729
1165
  const resolved = await this.confirmer.prepare(
@@ -731,7 +1167,7 @@ var Webhooks = class {
731
1167
  void 0,
732
1168
  options
733
1169
  );
734
- return this.client.post("/api/v1/webhooks", input, resolved);
1170
+ return this.client.postOnce("/api/v1/webhooks", input, resolved);
735
1171
  }
736
1172
  /** Get a webhook endpoint by ID. */
737
1173
  async get(id) {
@@ -766,7 +1202,14 @@ var Webhooks = class {
766
1202
  if (options?.limit !== void 0) params.limit = String(options.limit);
767
1203
  return this.client.get(`/api/v1/webhooks/${encodeURIComponent(id)}/deliveries`, params);
768
1204
  }
769
- /** Queue a signed `test.ping` delivery to the endpoint. Returns HTTP 202. */
1205
+ /**
1206
+ * Queue a signed `test.ping` delivery to the endpoint. Returns HTTP 202.
1207
+ *
1208
+ * Deliberately unkeyed: a duplicate ping is the one duplicate that costs
1209
+ * nothing. Real deliveries are retried too, so any endpoint worth pointing at
1210
+ * already tolerates receiving the same event twice — that is what this call
1211
+ * exists to prove.
1212
+ */
770
1213
  async test(id) {
771
1214
  return this.client.post(`/api/v1/webhooks/${encodeURIComponent(id)}/test`);
772
1215
  }
@@ -856,6 +1299,7 @@ async function verifyWebhookSignature(input) {
856
1299
 
857
1300
  // src/index.ts
858
1301
  var Medal = class {
1302
+ bookings;
859
1303
  capabilityConfirmations;
860
1304
  channels;
861
1305
  emails;
@@ -863,6 +1307,7 @@ var Medal = class {
863
1307
  deals;
864
1308
  gdpr;
865
1309
  helpdesk;
1310
+ portal;
866
1311
  posts;
867
1312
  scan;
868
1313
  webhooks;
@@ -885,12 +1330,14 @@ var Medal = class {
885
1330
  this.capabilityConfirmations,
886
1331
  options?.autoConfirmCapabilities
887
1332
  );
1333
+ this.bookings = new Bookings(client);
888
1334
  this.channels = new Channels(client, confirmer);
889
1335
  this.emails = new Emails(client);
890
1336
  this.contacts = new Contacts(client);
891
1337
  this.deals = new Deals(client);
892
1338
  this.gdpr = new Gdpr(client);
893
1339
  this.helpdesk = new Helpdesk(client, confirmer);
1340
+ this.portal = new Portal(client);
894
1341
  this.posts = new Posts(client);
895
1342
  this.scan = new Scan(client);
896
1343
  this.webhooks = new Webhooks(client, confirmer);
@@ -903,6 +1350,7 @@ function createMedalClient(apiKey, options) {
903
1350
  var src_default = Medal;
904
1351
  export {
905
1352
  BaseClient,
1353
+ Bookings,
906
1354
  CAPABILITY_IDS,
907
1355
  CAPABILITY_ROUTES,
908
1356
  CapabilityConfirmations,
@@ -916,6 +1364,7 @@ export {
916
1364
  Helpdesk,
917
1365
  Medal,
918
1366
  MedalApiError,
1367
+ Portal,
919
1368
  Posts,
920
1369
  Scan,
921
1370
  WebhookVerificationError,