@medalsocial/sdk 1.5.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/README.md +213 -1
- package/dist/openapi/medal-social.openapi.json +3739 -1411
- package/dist/pilot/index.d.mts +2 -2
- package/dist/pilot/index.d.ts +2 -2
- package/dist/src/index.d.mts +1376 -258
- package/dist/src/index.d.ts +1376 -258
- package/dist/src/index.js +667 -57
- package/dist/src/index.js.map +1 -1
- package/dist/src/index.mjs +661 -57
- package/dist/src/index.mjs.map +1 -1
- package/dist/src/openapi.generated.d.mts +1287 -113
- package/dist/src/openapi.generated.d.ts +1287 -113
- package/dist/src/openapi.generated.js.map +1 -1
- package/openapi/medal-social.openapi.yaml +1394 -27
- package/package.json +18 -17
- package/skills/client/SKILL.md +2 -2
- package/skills/resources/SKILL.md +105 -3
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Medal Social SDK
|
|
2
2
|
|
|
3
|
-
TypeScript SDK for the [Medal Social](https://medalsocial.com) API. Manage posts, emails, contacts, deals, helpdesk conversations, webhooks, and GDPR compliance programmatically.
|
|
3
|
+
TypeScript SDK for the [Medal Social](https://medalsocial.com) API. Manage posts, emails, contacts, deals, helpdesk conversations, webhooks, company/site scans, and GDPR compliance programmatically.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -204,6 +204,74 @@ const { data: removed } = await medal.deals.remove(deal.id);
|
|
|
204
204
|
console.log(updated.success, unlinked.success, removed.success);
|
|
205
205
|
```
|
|
206
206
|
|
|
207
|
+
### Bookings
|
|
208
|
+
|
|
209
|
+
Money is always **integer øre** (`amount_ore`, `price_ore`) — never a float, never kroner. Timestamps come back as ISO 8601 strings; on the way in, either Unix milliseconds or an ISO string is accepted.
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
// Catalogue + free slots
|
|
213
|
+
const { data: services } = await medal.bookings.listServices();
|
|
214
|
+
const { data: resources } = await medal.bookings.listResources();
|
|
215
|
+
const { data: slots } = await medal.bookings.availability({
|
|
216
|
+
service_id: services[0].id,
|
|
217
|
+
from_ts: Date.now(),
|
|
218
|
+
to_ts: Date.now() + 7 * 86_400_000,
|
|
219
|
+
resource_id: resources[0].id, // optional — defaults to every capable resource
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// Book a party — all items succeed or none do (max 50)
|
|
223
|
+
const { data: created } = await medal.bookings.create(
|
|
224
|
+
{
|
|
225
|
+
items: [
|
|
226
|
+
{ service_id: services[0].id, start_ts: slots[0].start_ts! },
|
|
227
|
+
{ service_id: services[0].id, start_ts: slots[1].start_ts!, booked_for_name: 'Ida', booked_for_birth_year: 2018 },
|
|
228
|
+
],
|
|
229
|
+
contact: { phone: '+4790000000', email: 'ida@example.com', name: 'Ida Hansen' },
|
|
230
|
+
notes: 'Bursdag',
|
|
231
|
+
},
|
|
232
|
+
{ idempotencyKey: crypto.randomUUID() }, // optional — see the note below
|
|
233
|
+
);
|
|
234
|
+
// created.bookings[i].manage_token is returned EXACTLY ONCE (only its hash is
|
|
235
|
+
// stored, and an idempotent replay omits it) — persist it for the manage link.
|
|
236
|
+
// It is UNRECOVERABLE if lost: `Booking` has no token field, so re-reading the
|
|
237
|
+
// booking gives you nothing. Reschedule to mint a fresh one, or act by id.
|
|
238
|
+
|
|
239
|
+
// Staff actions — policy windows are bypassed, cancels attributed to staff
|
|
240
|
+
const { data: booking } = await medal.bookings.get(created.bookings[0].id);
|
|
241
|
+
await medal.bookings.update(booking.id, { internal_notes: 'Allergisk mot parfyme' });
|
|
242
|
+
await medal.bookings.cancel(booking.id, { reason: 'Sykdom' });
|
|
243
|
+
const { data: moved } = await medal.bookings.reschedule(booking.id, {
|
|
244
|
+
new_start_ts: '2026-09-02T09:00:00.000Z',
|
|
245
|
+
new_resource_id: resources[0].id,
|
|
246
|
+
});
|
|
247
|
+
// moved.booking_id is a NEW id with a NEW manage_token — the old booking is cancelled
|
|
248
|
+
await medal.bookings.markNoShow(moved.booking_id);
|
|
249
|
+
|
|
250
|
+
// Listing — check `truncated`: when true, matching bookings exist that no
|
|
251
|
+
// cursor reaches, so narrow the from_ts/to_ts window
|
|
252
|
+
const page = await medal.bookings.list({ status: 'confirmed', from_ts: Date.now(), limit: 50 });
|
|
253
|
+
console.log(page.pagination.has_more, page.pagination.next_cursor, page.pagination.truncated);
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
**Customer actions go through `medal.bookings.manage`**, keyed by the manage token instead of the booking id. This is not the same route with a different lookup key: the workspace's cancel/reschedule windows are **enforced**, and the cancel is attributed to the customer. Use it to relay a customer's own click on the link in their confirmation email.
|
|
257
|
+
|
|
258
|
+
```ts
|
|
259
|
+
const { data: summary } = await medal.bookings.manage.get(manageToken);
|
|
260
|
+
if (summary.can_cancel) await medal.bookings.manage.cancel(manageToken, { reason: 'Endret plan' });
|
|
261
|
+
if (summary.can_reschedule) {
|
|
262
|
+
const { data } = await medal.bookings.manage.reschedule(manageToken, {
|
|
263
|
+
new_start_ts: '2026-09-02T09:00:00.000Z',
|
|
264
|
+
});
|
|
265
|
+
console.log(data.booking_id, data.manage_token); // old token stops working
|
|
266
|
+
}
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
`can_cancel` / `can_reschedule` already apply the policy windows — honour them rather than re-deriving from `cancel_window_hours`.
|
|
270
|
+
|
|
271
|
+
**Booking writes are idempotent by default.** The SDK retries 429/5xx automatically, so every booking `POST` (`create`, `cancel`, `reschedule`, `markNoShow`, and both `manage` writes) carries a generated `Idempotency-Key` — a retry after a gateway failure replays the original result instead of booking the slot twice. Supply your own `options.idempotencyKey` to extend that guarantee across *your* retries too: the server remembers a key for 24 hours, keyed by `(key, workspace, method + path)`.
|
|
272
|
+
|
|
273
|
+
`update(id, input)` requires at least one of `notes` / `internal_notes`; `update(id, {})` is a compile error, matching the API's own 400.
|
|
274
|
+
|
|
207
275
|
### GDPR
|
|
208
276
|
|
|
209
277
|
```ts
|
|
@@ -236,6 +304,22 @@ await medal.gdpr.cookieConsent({
|
|
|
236
304
|
});
|
|
237
305
|
```
|
|
238
306
|
|
|
307
|
+
### Scan
|
|
308
|
+
|
|
309
|
+
```ts
|
|
310
|
+
// Find the company in the Norwegian registry (typeahead)
|
|
311
|
+
const { data: hits } = await medal.scan.companies('Eksempel Bygg');
|
|
312
|
+
|
|
313
|
+
// Queue a scan — exactly one of url / orgnr / name
|
|
314
|
+
const { data: job } = await medal.scan.create({ orgnr: hits[0].orgnr });
|
|
315
|
+
|
|
316
|
+
// Poll until it settles (~30 s; done or failed)
|
|
317
|
+
const finished = await medal.scan.waitForResult(job.id);
|
|
318
|
+
if (finished.status === 'done' && finished.result) {
|
|
319
|
+
console.log(finished.result.nettskaar, finished.result.subScores);
|
|
320
|
+
}
|
|
321
|
+
```
|
|
322
|
+
|
|
239
323
|
### Helpdesk
|
|
240
324
|
|
|
241
325
|
```ts
|
|
@@ -269,6 +353,19 @@ const { data: reply } = await medal.helpdesk.replies.create(
|
|
|
269
353
|
);
|
|
270
354
|
```
|
|
271
355
|
|
|
356
|
+
**A `201` from `replies.create` means accepted, not delivered.** The channel hand-off happens asynchronously afterwards. Each message carries `delivery_status` (`pending` | `sent` | `delivered` | `failed`) and `delivery_error`, both `null` for inbound messages and internal notes (neither is ever sent to a channel):
|
|
357
|
+
|
|
358
|
+
```ts
|
|
359
|
+
const { data: messages } = await medal.helpdesk.conversations.messages('conv_id');
|
|
360
|
+
for (const message of messages) {
|
|
361
|
+
if (message.delivery_status === 'failed') {
|
|
362
|
+
console.error(message.id, message.delivery_error);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
Subscribe to `helpdesk.message_delivery_updated` for the same values pushed instead of polled.
|
|
368
|
+
|
|
272
369
|
### Webhooks
|
|
273
370
|
|
|
274
371
|
```ts
|
|
@@ -298,6 +395,8 @@ await medal.webhooks.test(endpoint.id); // queues a 'test.ping' delivery
|
|
|
298
395
|
|
|
299
396
|
Failed deliveries retry with exponential backoff (up to 6 attempts) before being dead-lettered.
|
|
300
397
|
|
|
398
|
+
`deliveries()` returns the most recent attempts only — it takes a `limit` and is **not** cursor-paginated. A delivery's `id` is the same value sent as the `X-Medal-Delivery-Id` and `Idempotency-Key` headers on the outbound request, so you can join your own receiving log to this listing exactly. Deliveries **never carry payload bodies** (payloads can contain customer PII); instead each one exposes correlation fields — `resource_id`, `conversation_id`, `message_id`, `connection_ref`, `channel`, `channel_connection_id` — that let you look the subject up through the regular API. All six are nullable and **fail closed to `null`** when no canonical event exists for the delivery (e.g. `test.ping` deliveries, or events that have aged out of retention), so always null-check them.
|
|
399
|
+
|
|
301
400
|
### Channels (partner connect)
|
|
302
401
|
|
|
303
402
|
Mint hosted connect links that let an external person — e.g. a partner's operator, with no Medal account — attach a channel account (today `telegram_inbox`) to the workspace's helpdesk, then track and disconnect the resulting connections. Requires the `channel.connect.manage` scope; OAuth callers additionally need the workspace `admin` role for the writes.
|
|
@@ -324,10 +423,123 @@ await medal.channels.connectLinks.revoke(link.id);
|
|
|
324
423
|
const { data: connections } = await medal.channels.connections.list();
|
|
325
424
|
// state: 'connecting' | 'active' | 'disconnected' | 'disabled'
|
|
326
425
|
await medal.channels.connections.disconnect(connections[0].id);
|
|
426
|
+
|
|
427
|
+
// Both listings are cursor-paginated (limit defaults to 50, capped at 100)
|
|
428
|
+
let cursor: string | undefined;
|
|
429
|
+
do {
|
|
430
|
+
const page = await medal.channels.connections.list({ limit: 100, cursor });
|
|
431
|
+
for (const connection of page.data) console.log(connection.id, connection.state);
|
|
432
|
+
cursor = page.pagination.has_more ? page.pagination.next_cursor ?? undefined : undefined;
|
|
433
|
+
} while (cursor);
|
|
327
434
|
```
|
|
328
435
|
|
|
436
|
+
Filters (`channel_type`, `status`) are applied **within** each page, so a page may hold fewer than `limit` items while `pagination.has_more` is still `true` — drive the loop off `has_more`, never off the item count.
|
|
437
|
+
|
|
329
438
|
When the person completes the hosted sign-in, the link flips to `consumed` and your webhook endpoint receives `helpdesk.channel_connected` (subscribe via the Webhooks resource above); disconnects emit `helpdesk.channel_disconnected` with a `reason`. Inbound messages on the connected account then flow into the helpdesk — consume them via `helpdesk.message_received` and reply with `medal.helpdesk.replies.create`.
|
|
330
439
|
|
|
440
|
+
### Capability confirmations
|
|
441
|
+
|
|
442
|
+
Medal's confirmable write routes require **both** an `Idempotency-Key` and an `X-Capability-Confirmation` token whenever the calling credential holds the capability scope *directly* — which is the case for every correctly-scoped partner key and OAuth grant. (API keys carrying only legacy scopes are exempt.) Affected routes and their capability ids:
|
|
443
|
+
|
|
444
|
+
| Capability id | Route |
|
|
445
|
+
|---|---|
|
|
446
|
+
| `channel.connect_link.create.execute` | `POST /api/v1/channels/connect-links` |
|
|
447
|
+
| `channel.connect_link.revoke.execute` | `DELETE /api/v1/channels/connect-links/{id}` |
|
|
448
|
+
| `channel.connection.disconnect.execute` | `DELETE /api/v1/channels/connections/{id}` |
|
|
449
|
+
| `helpdesk.conversation.reply.execute` | `POST /api/v1/helpdesk/replies` |
|
|
450
|
+
| `helpdesk.conversation.update.execute` | `PATCH /api/v1/helpdesk/conversations/{id}` |
|
|
451
|
+
| `helpdesk.webhook.create.execute` | `POST /api/v1/webhooks` |
|
|
452
|
+
| `helpdesk.webhook.update.execute` | `PATCH /api/v1/webhooks/{id}` |
|
|
453
|
+
| `helpdesk.webhook.delete.execute` | `DELETE /api/v1/webhooks/{id}` |
|
|
454
|
+
|
|
455
|
+
These are exported as `CAPABILITY_IDS` (a typed union via `CapabilityId`) and `CAPABILITY_ROUTES`.
|
|
456
|
+
|
|
457
|
+
#### Explicit flow
|
|
458
|
+
|
|
459
|
+
```ts
|
|
460
|
+
const idempotencyKey = crypto.randomUUID();
|
|
461
|
+
|
|
462
|
+
const { data: confirmation } = await medal.capabilityConfirmations.create({
|
|
463
|
+
capability_id: 'channel.connect_link.create.execute',
|
|
464
|
+
idempotency_key: idempotencyKey, // the token is bound to this exact key
|
|
465
|
+
preview_summary: 'Mint a Telegram connect link for Acme Support',
|
|
466
|
+
user_approved: true, // a human on your side approved this action
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
const { data: link } = await medal.channels.connectLinks.create(
|
|
470
|
+
{ channel_type: 'telegram_inbox', label: 'Acme Support' },
|
|
471
|
+
{ idempotencyKey, capabilityConfirmation: confirmation.confirmation_token },
|
|
472
|
+
);
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
For an id-bound route, pass `path_params` so the token binds to the concrete path:
|
|
476
|
+
|
|
477
|
+
```ts
|
|
478
|
+
const { data: confirmation } = await medal.capabilityConfirmations.create({
|
|
479
|
+
capability_id: 'channel.connection.disconnect.execute',
|
|
480
|
+
path_params: { id: connectionId },
|
|
481
|
+
idempotency_key: idempotencyKey,
|
|
482
|
+
preview_summary: `Disconnect ${connectionId} — approved by ${operator.email}`,
|
|
483
|
+
user_approved: true,
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
await medal.channels.connections.disconnect(connectionId, {
|
|
487
|
+
idempotencyKey,
|
|
488
|
+
capabilityConfirmation: confirmation.confirmation_token,
|
|
489
|
+
});
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
Tokens expire within 15 minutes and are single-purpose: bound to the workspace, the auth subject, the method + path, the capability's required scopes, and the idempotency key.
|
|
493
|
+
|
|
494
|
+
#### Auto-confirm (opt-in, off by default)
|
|
495
|
+
|
|
496
|
+
If your integration already gates these writes behind a real human approval, let the SDK mint both halves for you:
|
|
497
|
+
|
|
498
|
+
```ts
|
|
499
|
+
const medal = new Medal(process.env.MEDAL_API_KEY, {
|
|
500
|
+
autoConfirmCapabilities: {
|
|
501
|
+
previewSummary: (ctx) => {
|
|
502
|
+
// `ctx` is a discriminated union on `capabilityId` — narrowing gives you
|
|
503
|
+
// the exact request payload type, so the summary can describe the
|
|
504
|
+
// specific action rather than just the route.
|
|
505
|
+
switch (ctx.capabilityId) {
|
|
506
|
+
case 'channel.connect_link.create.execute':
|
|
507
|
+
return `${operator.email} approved a ${ctx.body.channel_type} connect link for "${ctx.body.label}"`;
|
|
508
|
+
case 'helpdesk.conversation.reply.execute':
|
|
509
|
+
return `${operator.email} approved replying to ${ctx.body.conversation_id}: "${ctx.body.body}"`;
|
|
510
|
+
default:
|
|
511
|
+
// DELETE routes have no body; identify them by path instead.
|
|
512
|
+
return `${operator.email} approved ${ctx.method} ${ctx.path}`;
|
|
513
|
+
}
|
|
514
|
+
},
|
|
515
|
+
},
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
// Both headers are minted and attached automatically.
|
|
519
|
+
const { data: link } = await medal.channels.connectLinks.create({
|
|
520
|
+
channel_type: 'telegram_inbox',
|
|
521
|
+
label: 'Acme Support',
|
|
522
|
+
});
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
The callback receives `{ capabilityId, method, path, pathParams, idempotencyKey, body }`. `body` is the exact object you passed to the SDK method, by reference and unmodified — treat it as read-only, since mutating it would change what is actually sent. Prefer a payload-aware summary: `"Reply to conv_1: 'Refund issued'"` is an audit record, `"POST /api/v1/helpdesk/replies"` is not. The server caps `preview_summary` at 4000 characters, so summarise the payload rather than serialising it wholesale.
|
|
526
|
+
|
|
527
|
+
> **Read before enabling.** Every minted token carries `user_approved: true`, which asserts to Medal that *a human on your side approved that specific action*, and the `previewSummary` you return is retained as the audit record of what they approved. Enable it only on code paths where that is genuinely true — never to rubber-stamp unattended writes. Returning a blank summary throws rather than asserting an approval with no description.
|
|
528
|
+
|
|
529
|
+
Per-call control:
|
|
530
|
+
|
|
531
|
+
```ts
|
|
532
|
+
// Opt in for one call only (client default stays off)
|
|
533
|
+
await medal.webhooks.delete(endpointId, {
|
|
534
|
+
autoConfirm: { previewSummary: () => `${operator.email} approved removing ${endpointId}` },
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
// Opt out of a client-level default for one call
|
|
538
|
+
await medal.webhooks.delete(endpointId, { autoConfirm: false });
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
Auto-confirm never overrides what you supply: if a call already carries both `idempotencyKey` and `capabilityConfirmation`, nothing is minted. If it carries only `idempotencyKey`, that key is reused when binding the token.
|
|
542
|
+
|
|
331
543
|
### Workspaces
|
|
332
544
|
|
|
333
545
|
```ts
|