@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.
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,
@@ -34,6 +35,7 @@ __export(src_exports, {
34
35
  Helpdesk: () => Helpdesk,
35
36
  Medal: () => Medal,
36
37
  MedalApiError: () => MedalApiError,
38
+ Portal: () => Portal,
37
39
  Posts: () => Posts,
38
40
  Scan: () => Scan,
39
41
  WebhookVerificationError: () => WebhookVerificationError,
@@ -45,6 +47,194 @@ __export(src_exports, {
45
47
  });
46
48
  module.exports = __toCommonJS(src_exports);
47
49
 
50
+ // src/types/common.ts
51
+ var MedalApiError = class extends Error {
52
+ status;
53
+ code;
54
+ details;
55
+ constructor(status, code, message, details) {
56
+ super(message);
57
+ this.name = "MedalApiError";
58
+ this.status = status;
59
+ this.code = code;
60
+ this.details = details;
61
+ }
62
+ };
63
+
64
+ // src/client.ts
65
+ function randomIdempotencyKey() {
66
+ const webCrypto = globalThis.crypto;
67
+ if (typeof webCrypto.randomUUID === "function") {
68
+ return webCrypto.randomUUID();
69
+ }
70
+ const bytes = new Uint8Array(16);
71
+ webCrypto.getRandomValues(bytes);
72
+ bytes[6] = bytes[6] & 15 | 64;
73
+ bytes[8] = bytes[8] & 63 | 128;
74
+ const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
75
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
76
+ }
77
+ function resolveIdempotencyKey(supplied) {
78
+ return (supplied ?? "").trim() || randomIdempotencyKey();
79
+ }
80
+ var BaseClient = class {
81
+ /** Resolved client configuration. */
82
+ config;
83
+ constructor(config) {
84
+ this.config = config;
85
+ }
86
+ /** Execute an authenticated GET request and return the parsed JSON body. */
87
+ async get(path, params, options) {
88
+ const url = this.buildUrl(path, params);
89
+ return this.request(url, { method: "GET", headers: options?.headers });
90
+ }
91
+ /** Execute an authenticated POST request with a JSON body. */
92
+ async post(path, body, options) {
93
+ return this.request(
94
+ this.buildUrl(path),
95
+ {
96
+ method: "POST",
97
+ headers: this.writeHeaders(options),
98
+ body: body !== void 0 ? JSON.stringify(body) : void 0
99
+ },
100
+ options?.retry
101
+ );
102
+ }
103
+ /**
104
+ * Execute a POST that must never execute twice, guaranteeing an
105
+ * `Idempotency-Key`.
106
+ *
107
+ * {@link BaseClient.post} retries 429 and 5xx automatically, so a write
108
+ * whose transaction committed before the gateway failed would otherwise be
109
+ * submitted a second time — booking the same slot twice. A key turns that
110
+ * retry into a replay: the server keys on the key, the workspace, and the
111
+ * method+path, and answers a repeat with the stored response, or 409 while
112
+ * the first attempt is still in flight. Either way the write happens once.
113
+ *
114
+ * The key is minted ONCE here, outside the retry loop in `request`, so every
115
+ * attempt of the same logical call carries the same value — a key minted per
116
+ * attempt would deduplicate nothing. A caller-supplied key always wins, so
117
+ * callers keeping their own records stay in control. See
118
+ * {@link resolveIdempotencyKey} for what counts as supplied.
119
+ */
120
+ async postOnce(path, body, options) {
121
+ return this.post(path, body, {
122
+ ...options,
123
+ idempotencyKey: resolveIdempotencyKey(options?.idempotencyKey)
124
+ });
125
+ }
126
+ /** Execute an authenticated PATCH request with a JSON body. */
127
+ async patch(path, body, options) {
128
+ return this.request(
129
+ this.buildUrl(path),
130
+ {
131
+ method: "PATCH",
132
+ headers: this.writeHeaders(options),
133
+ body: JSON.stringify(body)
134
+ },
135
+ options?.retry
136
+ );
137
+ }
138
+ /** Execute an authenticated DELETE request. */
139
+ async delete(path, options) {
140
+ return this.request(
141
+ this.buildUrl(path),
142
+ {
143
+ method: "DELETE",
144
+ headers: this.writeHeaders(options)
145
+ },
146
+ options?.retry
147
+ );
148
+ }
149
+ writeHeaders(options) {
150
+ const headers = {};
151
+ for (const [key, value] of Object.entries(options?.headers ?? {})) {
152
+ headers[key.toLowerCase()] = value;
153
+ }
154
+ headers["content-type"] = "application/json";
155
+ if (options?.idempotencyKey) {
156
+ headers["idempotency-key"] = options.idempotencyKey;
157
+ }
158
+ if (options?.capabilityConfirmation) {
159
+ headers["x-capability-confirmation"] = options.capabilityConfirmation;
160
+ }
161
+ return headers;
162
+ }
163
+ buildUrl(path, params) {
164
+ const url = new URL(`${this.config.baseUrl}${path}`);
165
+ if (params) {
166
+ for (const [key, value] of Object.entries(params)) {
167
+ if (value !== void 0) {
168
+ url.searchParams.set(key, value);
169
+ }
170
+ }
171
+ }
172
+ return url.toString();
173
+ }
174
+ async request(url, init, retry = true) {
175
+ const maxAttempts = retry ? 3 : 1;
176
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
177
+ const headers = new Headers(init.headers);
178
+ headers.set("authorization", `Bearer ${this.config.token}`);
179
+ if (this.config.workspaceId) {
180
+ headers.set("x-workspace-id", this.config.workspaceId);
181
+ }
182
+ try {
183
+ headers.set("user-agent", this.config.userAgent);
184
+ } catch {
185
+ }
186
+ const controller = new AbortController();
187
+ const timeout = setTimeout(() => controller.abort(), this.config.timeout);
188
+ let res;
189
+ let text = "";
190
+ let retrying = false;
191
+ try {
192
+ res = await fetch(url, { ...init, headers, signal: controller.signal });
193
+ retrying = (res.status === 429 || res.status >= 500 && res.status <= 599) && attempt < maxAttempts;
194
+ if (retrying) {
195
+ const drained = res.body ? res.body.pipeTo(new WritableStream()) : res.text();
196
+ await drained.catch(() => {
197
+ });
198
+ } else {
199
+ text = await res.text();
200
+ }
201
+ } finally {
202
+ clearTimeout(timeout);
203
+ }
204
+ if (retrying) {
205
+ const retryAfter = res.headers.get("retry-after");
206
+ let delayMs = 0;
207
+ if (retryAfter) {
208
+ const seconds = Number(retryAfter);
209
+ delayMs = Number.isFinite(seconds) ? seconds * 1e3 : 0;
210
+ }
211
+ if (delayMs <= 0) {
212
+ delayMs = 250 * attempt;
213
+ }
214
+ await new Promise((r) => setTimeout(r, delayMs));
215
+ continue;
216
+ }
217
+ let parsed;
218
+ try {
219
+ parsed = text ? JSON.parse(text) : void 0;
220
+ } catch {
221
+ parsed = text;
222
+ }
223
+ if (!res.ok) {
224
+ const body = parsed;
225
+ throw new MedalApiError(
226
+ res.status,
227
+ body?.error?.code ?? "UNKNOWN_ERROR",
228
+ body?.error?.message ?? `HTTP ${res.status}: ${res.statusText}`,
229
+ body?.error?.details
230
+ );
231
+ }
232
+ return parsed;
233
+ }
234
+ throw new Error("Request failed after retries");
235
+ }
236
+ };
237
+
48
238
  // src/types/capabilities.ts
49
239
  var CAPABILITY_IDS = [
50
240
  "channel.connect_link.create.execute",
@@ -92,13 +282,6 @@ var CAPABILITY_ROUTES = {
92
282
  };
93
283
 
94
284
  // 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
285
  function resolvePath(template, pathParams) {
103
286
  return template.replace(/\{([^}/]+)\}/g, (_match, name) => {
104
287
  const value = pathParams?.[name];
@@ -124,9 +307,10 @@ var CapabilityConfirmer = class {
124
307
  async prepare(request, pathParams, options) {
125
308
  const auto = options?.autoConfirm === false ? void 0 : options?.autoConfirm ?? this.defaults;
126
309
  if (!auto) return options;
127
- if (options?.idempotencyKey && options?.capabilityConfirmation) return options;
310
+ const idempotencyKey = resolveIdempotencyKey(options?.idempotencyKey);
311
+ const callerKeyIsUsable = idempotencyKey === options?.idempotencyKey;
312
+ if (callerKeyIsUsable && options?.capabilityConfirmation) return options;
128
313
  const route = CAPABILITY_ROUTES[request.capabilityId];
129
- const idempotencyKey = options?.idempotencyKey ?? newIdempotencyKey();
130
314
  const path = resolvePath(route.path_template, pathParams);
131
315
  const previewSummary = auto.previewSummary({
132
316
  ...request,
@@ -155,128 +339,159 @@ var CapabilityConfirmer = class {
155
339
  }
156
340
  };
157
341
 
158
- // src/types/common.ts
159
- var MedalApiError = class extends Error {
160
- status;
161
- code;
162
- details;
163
- constructor(status, code, message, details) {
164
- super(message);
165
- this.name = "MedalApiError";
166
- this.status = status;
167
- this.code = code;
168
- this.details = details;
342
+ // src/resources/bookings.ts
343
+ var BookingsManage = class {
344
+ constructor(client) {
345
+ this.client = client;
346
+ }
347
+ client;
348
+ /**
349
+ * Read what the holder of a manage token may see and do. Honour
350
+ * `can_cancel` / `can_reschedule` — they already apply the policy windows.
351
+ */
352
+ async get(token) {
353
+ return this.client.get(`/api/v1/bookings/manage/${encodeURIComponent(token)}`);
354
+ }
355
+ /** Cancel on the customer's behalf. Rejected outside the cancel window. */
356
+ async cancel(token, input, options) {
357
+ return this.client.postOnce(
358
+ `/api/v1/bookings/manage/${encodeURIComponent(token)}/cancel`,
359
+ input ?? {},
360
+ options
361
+ );
362
+ }
363
+ /**
364
+ * Move the booking on the customer's behalf. Rejected outside the reschedule
365
+ * window. Returns a NEW booking id and a new manage token — the old token
366
+ * stops working, so relay the new one into whatever link you send next.
367
+ */
368
+ async reschedule(token, input, options) {
369
+ return this.client.postOnce(
370
+ `/api/v1/bookings/manage/${encodeURIComponent(token)}/reschedule`,
371
+ input,
372
+ options
373
+ );
169
374
  }
170
375
  };
171
-
172
- // src/client.ts
173
- var BaseClient = class {
174
- /** Resolved client configuration. */
175
- config;
176
- constructor(config) {
177
- this.config = config;
376
+ var Bookings = class {
377
+ constructor(client) {
378
+ this.client = client;
379
+ this.manage = new BookingsManage(client);
178
380
  }
179
- /** Execute an authenticated GET request and return the parsed JSON body. */
180
- async get(path, params) {
181
- const url = this.buildUrl(path, params);
182
- return this.request(url, { method: "GET" });
381
+ client;
382
+ /** Customer-side actions addressed by manage token. */
383
+ manage;
384
+ /** List the bookable service catalogue. Active-only unless asked otherwise. */
385
+ async listServices(options) {
386
+ const params = {};
387
+ if (options?.include_inactive !== void 0) {
388
+ params.include_inactive = String(options.include_inactive);
389
+ }
390
+ return this.client.get("/api/v1/bookings/services", params);
183
391
  }
184
- /** Execute an authenticated POST request with a JSON body. */
185
- async post(path, body, options) {
186
- return this.request(this.buildUrl(path), {
187
- method: "POST",
188
- headers: this.writeHeaders(options),
189
- body: body !== void 0 ? JSON.stringify(body) : void 0
190
- });
392
+ /** List the bookable resources staff, rooms, and equipment. */
393
+ async listResources() {
394
+ return this.client.get("/api/v1/bookings/resources");
191
395
  }
192
- /** Execute an authenticated PATCH request with a JSON body. */
193
- async patch(path, body, options) {
194
- return this.request(this.buildUrl(path), {
195
- method: "PATCH",
196
- headers: this.writeHeaders(options),
197
- body: JSON.stringify(body)
198
- });
396
+ /**
397
+ * List free slots for a service over a window. Slots reflect opening hours,
398
+ * time off, buffers, and existing bookings at the moment of the call — they
399
+ * are not held, so a slot can be taken before you book it.
400
+ */
401
+ async availability(options) {
402
+ const params = {
403
+ service_id: options.service_id,
404
+ from_ts: String(options.from_ts),
405
+ to_ts: String(options.to_ts)
406
+ };
407
+ if (options.resource_id) params.resource_id = options.resource_id;
408
+ return this.client.get("/api/v1/bookings/availability", params);
199
409
  }
200
- /** Execute an authenticated DELETE request. */
201
- async delete(path, options) {
202
- return this.request(this.buildUrl(path), {
203
- method: "DELETE",
204
- headers: this.writeHeaders(options)
205
- });
410
+ /**
411
+ * The dates a service can be booked on — the half `availability` cannot
412
+ * answer. Availability returns free slots and nothing else, so a closed day,
413
+ * an evening past closing and a fully booked day are all the same empty
414
+ * array. A date absent from this list is closed; on a listed date, compare
415
+ * `last_start_ts` against the clock to tell "too late today" from "full".
416
+ */
417
+ async schedule(options) {
418
+ const params = {
419
+ service_id: options.service_id,
420
+ from_ts: String(options.from_ts),
421
+ to_ts: String(options.to_ts)
422
+ };
423
+ if (options.resource_id) params.resource_id = options.resource_id;
424
+ return this.client.get("/api/v1/bookings/schedule", params);
206
425
  }
207
- writeHeaders(options) {
208
- const headers = { "content-type": "application/json" };
209
- if (options?.idempotencyKey) {
210
- headers["idempotency-key"] = options.idempotencyKey;
211
- }
212
- if (options?.capabilityConfirmation) {
213
- headers["x-capability-confirmation"] = options.capabilityConfirmation;
214
- }
215
- return headers;
426
+ /**
427
+ * List bookings with cursor-based pagination and optional filters.
428
+ *
429
+ * Check `pagination.truncated`: when true the read window was clipped and
430
+ * matching bookings exist that no cursor reaches — narrow `from_ts`/`to_ts`.
431
+ */
432
+ async list(options) {
433
+ const params = {};
434
+ if (options?.limit !== void 0) params.limit = String(options.limit);
435
+ if (options?.cursor) params.cursor = options.cursor;
436
+ if (options?.status) params.status = options.status;
437
+ if (options?.resource_id) params.resource_id = options.resource_id;
438
+ if (options?.from_ts !== void 0) params.from_ts = String(options.from_ts);
439
+ if (options?.to_ts !== void 0) params.to_ts = String(options.to_ts);
440
+ return this.client.get("/api/v1/bookings", params);
216
441
  }
217
- buildUrl(path, params) {
218
- const url = new URL(`${this.config.baseUrl}${path}`);
219
- if (params) {
220
- for (const [key, value] of Object.entries(params)) {
221
- if (value !== void 0) {
222
- url.searchParams.set(key, value);
223
- }
224
- }
225
- }
226
- return url.toString();
442
+ /**
443
+ * Book a party — every item succeeds or none do (max 50).
444
+ *
445
+ * Each created booking comes back with a `manage_token` exactly once; only
446
+ * its hash is stored, so persist it if you need the customer's manage link.
447
+ *
448
+ * Automatically idempotent: the SDK mints an `Idempotency-Key` so its own
449
+ * 5xx retries replay rather than book the slot twice. Supply
450
+ * `options.idempotencyKey` to deduplicate across your OWN retries too — the
451
+ * server keys on it for 24 hours, so re-sending the same key after a network
452
+ * timeout returns the original bookings instead of a second set.
453
+ */
454
+ async create(input, options) {
455
+ return this.client.postOnce("/api/v1/bookings", input, options);
227
456
  }
228
- async request(url, init) {
229
- const maxAttempts = 3;
230
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
231
- const headers = new Headers(init.headers);
232
- headers.set("authorization", `Bearer ${this.config.token}`);
233
- if (this.config.workspaceId) {
234
- headers.set("x-workspace-id", this.config.workspaceId);
235
- }
236
- try {
237
- headers.set("user-agent", this.config.userAgent);
238
- } catch {
239
- }
240
- const controller = new AbortController();
241
- const timeout = setTimeout(() => controller.abort(), this.config.timeout);
242
- let res;
243
- try {
244
- res = await fetch(url, { ...init, headers, signal: controller.signal });
245
- } finally {
246
- clearTimeout(timeout);
247
- }
248
- if ((res.status === 429 || res.status >= 500 && res.status <= 599) && attempt < maxAttempts) {
249
- const retryAfter = res.headers.get("retry-after");
250
- let delayMs = 0;
251
- if (retryAfter) {
252
- const seconds = Number(retryAfter);
253
- delayMs = Number.isFinite(seconds) ? seconds * 1e3 : 0;
254
- }
255
- if (delayMs <= 0) {
256
- delayMs = 250 * attempt;
257
- }
258
- await new Promise((r) => setTimeout(r, delayMs));
259
- continue;
260
- }
261
- const text = await res.text();
262
- let parsed;
263
- try {
264
- parsed = text ? JSON.parse(text) : void 0;
265
- } catch {
266
- parsed = text;
267
- }
268
- if (!res.ok) {
269
- const body = parsed;
270
- throw new MedalApiError(
271
- res.status,
272
- body?.error?.code ?? "UNKNOWN_ERROR",
273
- body?.error?.message ?? `HTTP ${res.status}: ${res.statusText}`,
274
- body?.error?.details
275
- );
276
- }
277
- return parsed;
278
- }
279
- throw new Error("Request failed after retries");
457
+ /** Get a booking by ID. */
458
+ async get(id) {
459
+ return this.client.get(`/api/v1/bookings/${encodeURIComponent(id)}`);
460
+ }
461
+ /**
462
+ * Annotate a booking. At least one of `notes` (customer-visible) or
463
+ * `internal_notes` (staff-only) is required; `""` clears a field.
464
+ */
465
+ async update(id, input, options) {
466
+ return this.client.patch(`/api/v1/bookings/${encodeURIComponent(id)}`, input, options);
467
+ }
468
+ /** Cancel as the business — the cancel window is bypassed. */
469
+ async cancel(id, input, options) {
470
+ return this.client.postOnce(
471
+ `/api/v1/bookings/${encodeURIComponent(id)}/cancel`,
472
+ input ?? {},
473
+ options
474
+ );
475
+ }
476
+ /**
477
+ * Move a booking as the business the reschedule window is bypassed.
478
+ * Returns a NEW booking id and a new manage token; the old booking is
479
+ * cancelled and its token stops working.
480
+ */
481
+ async reschedule(id, input, options) {
482
+ return this.client.postOnce(
483
+ `/api/v1/bookings/${encodeURIComponent(id)}/reschedule`,
484
+ input,
485
+ options
486
+ );
487
+ }
488
+ /** Mark a booking as a no-show. */
489
+ async markNoShow(id, options) {
490
+ return this.client.postOnce(
491
+ `/api/v1/bookings/${encodeURIComponent(id)}/no-show`,
492
+ void 0,
493
+ options
494
+ );
280
495
  }
281
496
  };
282
497
 
@@ -297,6 +512,13 @@ var CapabilityConfirmations = class {
297
512
  * Setting `user_approved: true` asserts that a human on your side approved
298
513
  * this specific action. `preview_summary` is what they approved, and is
299
514
  * retained for audit — write it for a human reader, not a log parser.
515
+ *
516
+ * Deliberately unkeyed, unlike the writes it authorizes. Minting is not the
517
+ * state change the guarantee exists to protect: the write itself is already
518
+ * bound to `idempotency_key`, so a retry that mints a second token cannot
519
+ * produce a second write. Keying this call would instead park a credential
520
+ * designed to expire in 15 minutes inside a replay cache that answers for 24
521
+ * hours — a worse trade than the duplicate token it would avoid.
300
522
  */
301
523
  async create(input) {
302
524
  return this.client.post("/api/v1/capability-confirmations", input);
@@ -321,6 +543,13 @@ var ChannelConnectLinks = class {
321
543
  *
322
544
  * Requires the `channel.connect.manage` scope; OAuth callers additionally
323
545
  * need the workspace `admin` role.
546
+ *
547
+ * Automatically idempotent: an unkeyed retry mints a SECOND live single-use
548
+ * link for the same person, and only one of the two can ever be consumed —
549
+ * the other stays outstanding until it is revoked or expires. The key the
550
+ * confirmer chose is the key that goes out — a capability confirmation is
551
+ * bound to its idempotency key, so minting a fresh one here would invalidate
552
+ * the confirmation.
324
553
  */
325
554
  async create(input, options) {
326
555
  const resolved = await this.confirmer.prepare(
@@ -328,7 +557,7 @@ var ChannelConnectLinks = class {
328
557
  void 0,
329
558
  options
330
559
  );
331
- return this.client.post("/api/v1/channels/connect-links", input, resolved);
560
+ return this.client.postOnce("/api/v1/channels/connect-links", input, resolved);
332
561
  }
333
562
  /**
334
563
  * List the workspace's connect links (tokens are never returned), newest
@@ -423,9 +652,17 @@ var Contacts = class {
423
652
  if (options?.search) params.search = options.search;
424
653
  return this.client.get("/api/v1/contacts", params);
425
654
  }
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);
655
+ /**
656
+ * Create a new contact. Email must be unique in the workspace.
657
+ *
658
+ * Automatically idempotent: the SDK mints an `Idempotency-Key` so its own
659
+ * 5xx retries replay rather than run the create a second time. Uniqueness
660
+ * alone would not save you here — it turns the retry of a committed create
661
+ * into a spurious conflict, which reads as "the contact was not created".
662
+ * Supply `options.idempotencyKey` to deduplicate across your OWN retries too.
663
+ */
664
+ async create(input, options) {
665
+ return this.client.postOnce("/api/v1/contacts", input, options);
429
666
  }
430
667
  /** Get a contact by ID. */
431
668
  async get(id) {
@@ -446,13 +683,26 @@ var Contacts = class {
446
683
  if (options?.cursor) params.cursor = options.cursor;
447
684
  return this.client.get(`/api/v1/contacts/${encodeURIComponent(id)}/activities`, params);
448
685
  }
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);
686
+ /**
687
+ * Add a note to a contact's timeline.
688
+ *
689
+ * Automatically idempotent: nothing about a note is unique, so an unkeyed
690
+ * retry appends the same text to the timeline twice. Supply
691
+ * `options.idempotencyKey` to deduplicate across your OWN retries too.
692
+ */
693
+ async addNote(id, input, options) {
694
+ return this.client.postOnce(`/api/v1/contacts/${encodeURIComponent(id)}/notes`, input, options);
452
695
  }
453
- /** Bulk import contacts (max 500). Duplicates are skipped. */
454
- async import(contacts) {
455
- return this.client.post("/api/v1/contacts/import", { contacts });
696
+ /**
697
+ * Bulk import contacts (max 500). Duplicates are skipped.
698
+ *
699
+ * Automatically idempotent: the import is processed in chunks, so a retry
700
+ * after a partial failure re-walks the whole batch and reports `added` /
701
+ * `skipped` counts for a run that was not the first. Supply
702
+ * `options.idempotencyKey` to deduplicate across your OWN retries too.
703
+ */
704
+ async import(contacts, options) {
705
+ return this.client.postOnce("/api/v1/contacts/import", { contacts }, options);
456
706
  }
457
707
  };
458
708
 
@@ -471,9 +721,15 @@ var Deals = class {
471
721
  if (options?.search) params.search = options.search;
472
722
  return this.client.get("/api/v1/deals", params);
473
723
  }
474
- /** Create a new deal. */
475
- async create(input) {
476
- return this.client.post("/api/v1/deals", input);
724
+ /**
725
+ * Create a new deal.
726
+ *
727
+ * Automatically idempotent: nothing about a deal is unique, so an unkeyed
728
+ * retry puts a second identical deal in the pipeline. Supply
729
+ * `options.idempotencyKey` to deduplicate across your OWN retries too.
730
+ */
731
+ async create(input, options) {
732
+ return this.client.postOnce("/api/v1/deals", input, options);
477
733
  }
478
734
  /** Get a deal by ID. */
479
735
  async get(id) {
@@ -517,9 +773,18 @@ var Emails = class {
517
773
  /**
518
774
  * Send a transactional email using a template (HTTP 202). The returned `id`
519
775
  * is an email send id — poll `emails.get(id)` with it to track delivery.
776
+ *
777
+ * Automatically idempotent: the SDK mints an `Idempotency-Key` so its own
778
+ * 5xx retries replay rather than queue a second copy into someone's inbox —
779
+ * a send that already committed cannot be un-sent. Supply
780
+ * `options.idempotencyKey` to deduplicate across your OWN retries too.
781
+ *
782
+ * `input.idempotency_key` is the older, body-level form of the same control
783
+ * and still takes precedence server-side, so setting it keeps working
784
+ * unchanged.
520
785
  */
521
- async send(input) {
522
- return this.client.post("/api/v1/emails", input);
786
+ async send(input, options) {
787
+ return this.client.postOnce("/api/v1/emails", input, options);
523
788
  }
524
789
  /** Get the delivery status of a sent email. */
525
790
  async get(id) {
@@ -528,9 +793,14 @@ var Emails = class {
528
793
  /**
529
794
  * Send the same template to multiple recipients (max 100, HTTP 202). Each
530
795
  * queued recipient gets its own send id in `results` for `emails.get(id)`.
796
+ *
797
+ * Automatically idempotent — and this is the call where it matters most: an
798
+ * unkeyed retry of a batch that already committed sends up to 100 duplicate
799
+ * emails. Supply `options.idempotencyKey` to deduplicate across your OWN
800
+ * retries too.
531
801
  */
532
- async batch(input) {
533
- return this.client.post("/api/v1/emails/batch", input);
802
+ async batch(input, options) {
803
+ return this.client.postOnce("/api/v1/emails/batch", input, options);
534
804
  }
535
805
  };
536
806
 
@@ -540,9 +810,17 @@ var Gdpr = class {
540
810
  this.client = client;
541
811
  }
542
812
  client;
543
- /** Request a workspace data export. Runs asynchronously. */
544
- async requestExport() {
545
- return this.client.post("/api/v1/gdpr/export");
813
+ /**
814
+ * Request a workspace data export. Runs asynchronously.
815
+ *
816
+ * Automatically idempotent: the request is recorded and the export is
817
+ * scheduled in one step with no de-duplication of its own, so an unkeyed
818
+ * retry files a second subject-access request and runs a second full export
819
+ * of the workspace. Supply `options.idempotencyKey` to deduplicate across
820
+ * your OWN retries too.
821
+ */
822
+ async requestExport(options) {
823
+ return this.client.postOnce("/api/v1/gdpr/export", void 0, options);
546
824
  }
547
825
  /** List all workspace export requests. */
548
826
  async listExports() {
@@ -552,7 +830,13 @@ var Gdpr = class {
552
830
  async getExport(id) {
553
831
  return this.client.get(`/api/v1/gdpr/exports/${encodeURIComponent(id)}`);
554
832
  }
555
- /** Record a GDPR consent decision for a contact by email. */
833
+ /**
834
+ * Record a GDPR consent decision for a contact by email.
835
+ *
836
+ * Deliberately unkeyed: a decision is stored once per
837
+ * (workspace, email, consent type) and overwritten in place, so re-sending
838
+ * the same body reaches the same state and returns the same record id.
839
+ */
556
840
  async recordConsent(input) {
557
841
  return this.client.post("/api/v1/gdpr/consent", input);
558
842
  }
@@ -560,7 +844,15 @@ var Gdpr = class {
560
844
  async getConsent(email) {
561
845
  return this.client.get(`/api/v1/gdpr/consent/${encodeURIComponent(email)}`);
562
846
  }
563
- /** Record cookie consent from an external site (legacy endpoint). */
847
+ /**
848
+ * Record cookie consent from an external site (legacy endpoint).
849
+ *
850
+ * Deliberately unkeyed: this legacy route predates the versioned API and
851
+ * does not run the `Idempotency-Key` machinery, so a key here would be a
852
+ * header that changes nothing while implying a guarantee the endpoint cannot
853
+ * make. Treat a failed call as "unknown" and re-send only if a missing
854
+ * consent log matters more to you than a duplicate one.
855
+ */
564
856
  async cookieConsent(input) {
565
857
  return this.client.post("/api/cookie-consent", input);
566
858
  }
@@ -624,8 +916,16 @@ var HelpdeskReplies = class {
624
916
  /**
625
917
  * Send an operator reply or internal note. Returns HTTP 201.
626
918
  *
627
- * Pass an `idempotencyKey` so retried requests do not create duplicate
628
- * messages it is REQUIRED for capability-scoped tokens.
919
+ * Automatically idempotent: a reply is a message to a real person, and an
920
+ * unkeyed retry sends it to them twice. The key the confirmer chose is the
921
+ * key that goes out — a capability confirmation is bound to its idempotency
922
+ * key, so minting a fresh one here would invalidate the confirmation.
923
+ *
924
+ * Pass `options.idempotencyKey` to deduplicate across your OWN retries too.
925
+ * It is REQUIRED for capability-scoped tokens, which need it paired with a
926
+ * `capabilityConfirmation` — a generated key satisfies the pairing's key
927
+ * half only; the confirmation is still yours to supply (or to let
928
+ * `autoConfirm` mint).
629
929
  */
630
930
  async create(input, options) {
631
931
  const resolved = await this.confirmer.prepare(
@@ -633,7 +933,7 @@ var HelpdeskReplies = class {
633
933
  void 0,
634
934
  options
635
935
  );
636
- return this.client.post("/api/v1/helpdesk/replies", input, resolved);
936
+ return this.client.postOnce("/api/v1/helpdesk/replies", input, resolved);
637
937
  }
638
938
  };
639
939
  var Helpdesk = class {
@@ -646,6 +946,111 @@ var Helpdesk = class {
646
946
  }
647
947
  };
648
948
 
949
+ // src/resources/portal.ts
950
+ function withSession(session) {
951
+ return { headers: { "x-portal-session": session } };
952
+ }
953
+ var ONCE = { retry: false };
954
+ var PortalLogin = class {
955
+ constructor(client) {
956
+ this.client = client;
957
+ }
958
+ client;
959
+ /**
960
+ * E-mail a one-time code to the address.
961
+ *
962
+ * Always 202 `{ status: "sent" }` — enumeration-safe: `"sent"` does not
963
+ * confirm that the address belongs to a contact. Rate-limited per address
964
+ * and per caller (`429 RATE_LIMITED`).
965
+ */
966
+ async start(input) {
967
+ return this.client.post("/api/v1/portal/login/start", input);
968
+ }
969
+ /**
970
+ * Exchange the e-mailed code for a session.
971
+ *
972
+ * `session_token` is a bearer credential for ONE contact — keep it in an
973
+ * HttpOnly cookie on the site's server. A wrong, burned or expired code all
974
+ * answer `401 PORTAL_CODE_INVALID`; the three are not distinguished, so the
975
+ * response is not an oracle for which codes exist.
976
+ */
977
+ async verify(input) {
978
+ return this.client.post("/api/v1/portal/login/verify", input, ONCE);
979
+ }
980
+ };
981
+ var Portal = class {
982
+ constructor(client) {
983
+ this.client = client;
984
+ this.login = new PortalLogin(client);
985
+ }
986
+ client;
987
+ /** E-mail one-time-code login: `start` sends the code, `verify` exchanges it. */
988
+ login;
989
+ /**
990
+ * Revoke the session. Resolves to `undefined` (the route answers 204).
991
+ *
992
+ * Not keyed: revoking twice reaches the same state — the second call answers
993
+ * `401 PORTAL_SESSION_INVALID`, which is the outcome you wanted anyway.
994
+ */
995
+ async logout(session) {
996
+ await this.client.post("/api/v1/portal/logout", void 0, {
997
+ ...withSession(session),
998
+ ...ONCE
999
+ });
1000
+ }
1001
+ /** The signed-in contact's own profile. */
1002
+ async me(session) {
1003
+ return this.client.get("/api/v1/portal/me", void 0, withSession(session));
1004
+ }
1005
+ /**
1006
+ * Update the signed-in contact's profile. Only the supplied fields change;
1007
+ * `phone: null` clears the number and `family` replaces the whole list.
1008
+ * `marketing_consent` records a `marketing_email` consent decision with
1009
+ * source `portal`. Returns the profile as it is after the change.
1010
+ */
1011
+ /**
1012
+ * A profile patch is not idempotency-keyed on the server and a `marketing_consent`
1013
+ * change records a dated consent event, so a retry after a committed-but-lost
1014
+ * response would repeat that event: sent exactly once, like the other writes.
1015
+ */
1016
+ async updateMe(session, patch) {
1017
+ return this.client.patch("/api/v1/portal/me", patch, { ...withSession(session), ...ONCE });
1018
+ }
1019
+ /**
1020
+ * The contact's bookings split into `upcoming` and `past`. An upcoming
1021
+ * booking that is still inside the workspace's policy windows carries
1022
+ * `manage_token` and `can_manage: true`; use the token to open the site's
1023
+ * manage page (`medal.bookings.manage.*`).
1024
+ */
1025
+ async myBookings(session) {
1026
+ return this.client.get("/api/v1/portal/me/bookings", void 0, withSession(session));
1027
+ }
1028
+ /**
1029
+ * Everything the workspace holds about the contact — profile, family,
1030
+ * consents and bookings — as one JSON document (GDPR Art. 15). Synchronous,
1031
+ * unlike `medal.gdpr.requestExport()`, which exports the whole workspace.
1032
+ *
1033
+ * Not keyed: a read-only snapshot, so a retried call costs nothing and
1034
+ * duplicates nothing.
1035
+ */
1036
+ async exportMyData(session) {
1037
+ return this.client.post("/api/v1/portal/me/export", void 0, withSession(session));
1038
+ }
1039
+ /**
1040
+ * Erase the contact (GDPR Art. 17). Resolves to `undefined` (the route
1041
+ * answers 204); the session is revoked as part of the deletion.
1042
+ *
1043
+ * Not keyed: deletion is terminal, so a retry meets a revoked session and
1044
+ * answers `401 PORTAL_SESSION_INVALID` rather than deleting anything else.
1045
+ */
1046
+ async deleteMe(session) {
1047
+ await this.client.post("/api/v1/portal/me/delete", void 0, {
1048
+ ...withSession(session),
1049
+ ...ONCE
1050
+ });
1051
+ }
1052
+ };
1053
+
649
1054
  // src/resources/posts.ts
650
1055
  var Posts = class {
651
1056
  constructor(client) {
@@ -661,9 +1066,15 @@ var Posts = class {
661
1066
  if (options?.type) params.type = options.type;
662
1067
  return this.client.get("/api/v1/posts", params);
663
1068
  }
664
- /** Create a new post with content and target channels. */
665
- async create(input) {
666
- return this.client.post("/api/v1/posts", input);
1069
+ /**
1070
+ * Create a new post with content and target channels.
1071
+ *
1072
+ * Automatically idempotent: the SDK mints an `Idempotency-Key` so its own
1073
+ * 5xx retries replay rather than draft the post twice. Supply
1074
+ * `options.idempotencyKey` to deduplicate across your OWN retries too.
1075
+ */
1076
+ async create(input, options) {
1077
+ return this.client.postOnce("/api/v1/posts", input, options);
667
1078
  }
668
1079
  /** Get a post by ID, including its per-channel variants. */
669
1080
  async get(id) {
@@ -677,11 +1088,26 @@ var Posts = class {
677
1088
  async remove(id) {
678
1089
  return this.client.delete(`/api/v1/posts/${encodeURIComponent(id)}`);
679
1090
  }
680
- /** Schedule a post for future publication. */
1091
+ /**
1092
+ * Schedule a post for future publication.
1093
+ *
1094
+ * Deliberately unkeyed: re-sending the same `scheduled_at` for an
1095
+ * already-scheduled post returns the original `workflow_id` rather than
1096
+ * starting a second one, so a retried schedule cannot double-publish. A
1097
+ * *different* time is rejected — unschedule first.
1098
+ */
681
1099
  async schedule(id, input) {
682
1100
  return this.client.post(`/api/v1/posts/${encodeURIComponent(id)}/schedule`, input);
683
1101
  }
684
- /** Publish a post immediately to all target channels. */
1102
+ /**
1103
+ * Publish a post immediately to all target channels.
1104
+ *
1105
+ * Deliberately unkeyed: publishing moves the post out of the set of statuses
1106
+ * that may be published, so the retry of a publish that already committed is
1107
+ * refused rather than posting a second time. It is refused with a 400 though
1108
+ * — treat an error here as "check the post's status", not as "nothing
1109
+ * happened".
1110
+ */
685
1111
  async publish(id) {
686
1112
  return this.client.post(`/api/v1/posts/${encodeURIComponent(id)}/publish`);
687
1113
  }
@@ -702,10 +1128,15 @@ var Scan = class {
702
1128
  * Queue a scan. Provide exactly one of `url`, `orgnr`, or `name`.
703
1129
  * Runs asynchronously — poll with `get()` or use `waitForResult()`.
704
1130
  *
1131
+ * Automatically idempotent: a scan job is queued the moment it is created,
1132
+ * so an unkeyed retry starts a second crawl of the same site and returns an
1133
+ * id for a job that duplicates one already running. Supply
1134
+ * `options.idempotencyKey` to deduplicate across your OWN retries too.
1135
+ *
705
1136
  * @throws Error before any request when zero or several selectors are set —
706
1137
  * the server would reject the body anyway; failing locally is clearer.
707
1138
  */
708
- async create(input) {
1139
+ async create(input, options) {
709
1140
  const entries = ["url", "orgnr", "name"].filter(
710
1141
  (key2) => input[key2] !== void 0 && input[key2] !== ""
711
1142
  );
@@ -713,7 +1144,7 @@ var Scan = class {
713
1144
  throw new Error("scan.create requires exactly one of url, orgnr, or name");
714
1145
  }
715
1146
  const key = entries[0];
716
- return this.client.post("/api/v1/scan", { [key]: input[key] });
1147
+ return this.client.postOnce("/api/v1/scan", { [key]: input[key] }, options);
717
1148
  }
718
1149
  /** Get a scan job's status and, once done, its findings payload. */
719
1150
  async get(id) {
@@ -771,6 +1202,13 @@ var Webhooks = class {
771
1202
  * `secret` is typed optional because an idempotent replay (retrying with the
772
1203
  * same `Idempotency-Key`, `X-Idempotent-Replayed: true`) returns the existing
773
1204
  * endpoint WITHOUT the secret — handle that case (rotate if you lost it).
1205
+ *
1206
+ * Automatically idempotent: a duplicate endpoint is not a stray row, it is a
1207
+ * second copy of every future delivery to the same URL, forever. The key the
1208
+ * confirmer chose is the key that goes out — a capability confirmation is
1209
+ * bound to its idempotency key, so minting a fresh one here would invalidate
1210
+ * the confirmation. That the SDK now always sends a key is also what makes
1211
+ * the replay-without-secret case above reachable on a plain 5xx retry.
774
1212
  */
775
1213
  async create(input, options) {
776
1214
  const resolved = await this.confirmer.prepare(
@@ -778,7 +1216,7 @@ var Webhooks = class {
778
1216
  void 0,
779
1217
  options
780
1218
  );
781
- return this.client.post("/api/v1/webhooks", input, resolved);
1219
+ return this.client.postOnce("/api/v1/webhooks", input, resolved);
782
1220
  }
783
1221
  /** Get a webhook endpoint by ID. */
784
1222
  async get(id) {
@@ -813,7 +1251,14 @@ var Webhooks = class {
813
1251
  if (options?.limit !== void 0) params.limit = String(options.limit);
814
1252
  return this.client.get(`/api/v1/webhooks/${encodeURIComponent(id)}/deliveries`, params);
815
1253
  }
816
- /** Queue a signed `test.ping` delivery to the endpoint. Returns HTTP 202. */
1254
+ /**
1255
+ * Queue a signed `test.ping` delivery to the endpoint. Returns HTTP 202.
1256
+ *
1257
+ * Deliberately unkeyed: a duplicate ping is the one duplicate that costs
1258
+ * nothing. Real deliveries are retried too, so any endpoint worth pointing at
1259
+ * already tolerates receiving the same event twice — that is what this call
1260
+ * exists to prove.
1261
+ */
817
1262
  async test(id) {
818
1263
  return this.client.post(`/api/v1/webhooks/${encodeURIComponent(id)}/test`);
819
1264
  }
@@ -903,6 +1348,7 @@ async function verifyWebhookSignature(input) {
903
1348
 
904
1349
  // src/index.ts
905
1350
  var Medal = class {
1351
+ bookings;
906
1352
  capabilityConfirmations;
907
1353
  channels;
908
1354
  emails;
@@ -910,6 +1356,7 @@ var Medal = class {
910
1356
  deals;
911
1357
  gdpr;
912
1358
  helpdesk;
1359
+ portal;
913
1360
  posts;
914
1361
  scan;
915
1362
  webhooks;
@@ -932,12 +1379,14 @@ var Medal = class {
932
1379
  this.capabilityConfirmations,
933
1380
  options?.autoConfirmCapabilities
934
1381
  );
1382
+ this.bookings = new Bookings(client);
935
1383
  this.channels = new Channels(client, confirmer);
936
1384
  this.emails = new Emails(client);
937
1385
  this.contacts = new Contacts(client);
938
1386
  this.deals = new Deals(client);
939
1387
  this.gdpr = new Gdpr(client);
940
1388
  this.helpdesk = new Helpdesk(client, confirmer);
1389
+ this.portal = new Portal(client);
941
1390
  this.posts = new Posts(client);
942
1391
  this.scan = new Scan(client);
943
1392
  this.webhooks = new Webhooks(client, confirmer);
@@ -951,6 +1400,7 @@ var src_default = Medal;
951
1400
  // Annotate the CommonJS export names for ESM import in node:
952
1401
  0 && (module.exports = {
953
1402
  BaseClient,
1403
+ Bookings,
954
1404
  CAPABILITY_IDS,
955
1405
  CAPABILITY_ROUTES,
956
1406
  CapabilityConfirmations,
@@ -964,6 +1414,7 @@ var src_default = Medal;
964
1414
  Helpdesk,
965
1415
  Medal,
966
1416
  MedalApiError,
1417
+ Portal,
967
1418
  Posts,
968
1419
  Scan,
969
1420
  WebhookVerificationError,