@medalsocial/sdk 1.2.0 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +9 -3
- package/skills/client/SKILL.md +105 -0
- package/skills/resources/SKILL.md +241 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@medalsocial/sdk",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"description": "TypeScript SDK for Medal Social API — posts, emails, contacts, deals, and GDPR compliance",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Medal Social / Ali Aljumaili",
|
|
@@ -50,7 +50,11 @@
|
|
|
50
50
|
},
|
|
51
51
|
"files": [
|
|
52
52
|
"dist",
|
|
53
|
-
"openapi"
|
|
53
|
+
"openapi",
|
|
54
|
+
"skills",
|
|
55
|
+
"!skills/_artifacts",
|
|
56
|
+
"!skills/**/_artifacts",
|
|
57
|
+
"!skills/**/_artifacts/**"
|
|
54
58
|
],
|
|
55
59
|
"keywords": [
|
|
56
60
|
"medal",
|
|
@@ -65,7 +69,8 @@
|
|
|
65
69
|
"contacts",
|
|
66
70
|
"deals",
|
|
67
71
|
"posts",
|
|
68
|
-
"social-media"
|
|
72
|
+
"social-media",
|
|
73
|
+
"tanstack-intent"
|
|
69
74
|
],
|
|
70
75
|
"dependencies": {
|
|
71
76
|
"zod": "^3.23.8"
|
|
@@ -78,6 +83,7 @@
|
|
|
78
83
|
"@commitlint/config-conventional": "^19.4.0",
|
|
79
84
|
"@redocly/cli": "^2.31.4",
|
|
80
85
|
"@secretlint/secretlint-rule-preset-recommend": "^9.3.2",
|
|
86
|
+
"@tanstack/intent": "0.0.41",
|
|
81
87
|
"@types/node": "^24.3.2",
|
|
82
88
|
"@vitest/coverage-v8": "^2.0.5",
|
|
83
89
|
"husky": "^9.1.6",
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: client
|
|
3
|
+
description: Use when instantiating the `Medal` class from `@medalsocial/sdk`, configuring auth (API key vs OAuth), setting `baseUrl`, debugging an HTTP response or `MedalApiError`, reasoning about retry behavior, or running the SDK in a browser / edge runtime. Required reading before any code that calls into the SDK at the client level.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Medal Social SDK — Client
|
|
7
|
+
|
|
8
|
+
## When to load this skill
|
|
9
|
+
|
|
10
|
+
- Instantiating `new Medal(...)`.
|
|
11
|
+
- Choosing between an API key and an OAuth access token.
|
|
12
|
+
- Configuring `baseUrl`, `timeout`, or `workspaceId`.
|
|
13
|
+
- Debugging a 4xx / 5xx response.
|
|
14
|
+
- Reasoning about whether a failed request will be retried.
|
|
15
|
+
- Running the SDK in a browser, Cloudflare Worker, Deno, or Bun.
|
|
16
|
+
|
|
17
|
+
## Base URL — the most common pitfall
|
|
18
|
+
|
|
19
|
+
**The Medal Social API base URL is `https://io.medalsocial.com`.**
|
|
20
|
+
|
|
21
|
+
It is NOT `https://api.medalsocial.com`. Setting `baseUrl: "https://api.medalsocial.com"` returns DNS or 404 errors. The SDK defaults to the correct value when `baseUrl` is omitted — only set it explicitly when targeting a non-prod environment.
|
|
22
|
+
|
|
23
|
+
## Instantiation — token is positional
|
|
24
|
+
|
|
25
|
+
The token is the **first positional argument**, not a key on an options object:
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import { Medal } from "@medalsocial/sdk";
|
|
29
|
+
|
|
30
|
+
// API key (recommended for server-side; scoped to one workspace)
|
|
31
|
+
const medal = new Medal("medal_xxx");
|
|
32
|
+
|
|
33
|
+
// OAuth access token (requires workspaceId — OAuth tokens span workspaces)
|
|
34
|
+
const medal = new Medal("oauth_access_token", {
|
|
35
|
+
workspaceId: "workspace_id_here",
|
|
36
|
+
});
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`new Medal({ apiKey: ... })` is wrong — there is no `apiKey` option. The constructor throws if the token is empty.
|
|
40
|
+
|
|
41
|
+
## Options
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
interface MedalOptions {
|
|
45
|
+
baseUrl?: string; // defaults to https://io.medalsocial.com
|
|
46
|
+
timeout?: number; // ms; default 30000
|
|
47
|
+
workspaceId?: string; // required for OAuth tokens, ignored for API keys
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
API keys start with `medal_` and are scoped to a single workspace — the SDK reads the workspace from the key. OAuth tokens are workspace-agnostic, so you must pass `workspaceId`.
|
|
52
|
+
|
|
53
|
+
## Auth headers
|
|
54
|
+
|
|
55
|
+
Every request gets:
|
|
56
|
+
- `Authorization: Bearer <token>`
|
|
57
|
+
- `x-workspace-id: <workspaceId>` (only if `workspaceId` was set on the constructor)
|
|
58
|
+
- `User-Agent: medalsocial-sdk/<version>` (best-effort — browsers reject custom User-Agent; the SDK swallows that error silently)
|
|
59
|
+
|
|
60
|
+
## Retry behavior
|
|
61
|
+
|
|
62
|
+
`BaseClient.request` retries on **429 and 5xx** for up to **3 attempts total**:
|
|
63
|
+
|
|
64
|
+
- If the response has a `retry-after` header (in seconds), the SDK waits that long.
|
|
65
|
+
- Otherwise it waits `250 * attempt` ms (so 250, 500 between the first three attempts).
|
|
66
|
+
- Other 4xx errors are NOT retried — they throw `MedalApiError` immediately.
|
|
67
|
+
- Network errors (fetch throws) are NOT retried — they bubble up.
|
|
68
|
+
- The request is aborted via `AbortController` after `timeout` ms.
|
|
69
|
+
|
|
70
|
+
The SDK does **not** drain the response body between retries — if you observe a connection leak in long-running processes, that's worth investigating.
|
|
71
|
+
|
|
72
|
+
## Errors
|
|
73
|
+
|
|
74
|
+
Non-2xx responses throw `MedalApiError`:
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
import { Medal, MedalApiError } from "@medalsocial/sdk";
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
await medal.posts.create({ content: "hi", channel_ids: ["ch_1"] });
|
|
81
|
+
} catch (err) {
|
|
82
|
+
if (err instanceof MedalApiError) {
|
|
83
|
+
err.status; // HTTP status code
|
|
84
|
+
err.code; // API error code (e.g. "INVALID_REQUEST") or "UNKNOWN_ERROR"
|
|
85
|
+
err.message; // error.message from the API body, or "HTTP <status>: <statusText>"
|
|
86
|
+
err.details; // optional details from the API body
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
The `Medal` instance does **not** expose the underlying `BaseClient` — it's created as a local `const` in the constructor and only passed into the resource classes. There is no public way to read the resolved config from a `Medal` instance. If you need the same config later (for logging, custom requests), store your `MedalOptions` separately when you construct the client.
|
|
92
|
+
|
|
93
|
+
## OIDC publishing — consumers do not need a token
|
|
94
|
+
|
|
95
|
+
The package is published with provenance attestation via npm OIDC trusted publishing. Consumers do not need an `NPM_TOKEN` to install. There is no static publish token; releases run from the locked `prod` branch only via GitHub Actions.
|
|
96
|
+
|
|
97
|
+
## Runtimes
|
|
98
|
+
|
|
99
|
+
The SDK uses standard Web Fetch + `AbortController` and has no Node-only APIs, so it runs in Deno, Bun, Cloudflare Workers, and modern browsers.
|
|
100
|
+
|
|
101
|
+
**Browser usage caveat:** the Medal API does not currently support CORS for arbitrary origins — calls from browser code typically need a server-side proxy that holds the API key. Don't embed a `medal_*` key in client-side JavaScript regardless; it grants full workspace access.
|
|
102
|
+
|
|
103
|
+
**Node:** `package.json` enforces `engines.node >=24`. Older Node is blocked at install time. If you need Node 18–22 support, relax `engines` in your fork and verify against the SDK's test suite first.
|
|
104
|
+
|
|
105
|
+
**Cloudflare Workers / edge:** works out of the box; the `User-Agent` set is silently rejected (workers also disallow it) and the SDK swallows the error.
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: resources
|
|
3
|
+
description: Use when calling any of the SDK resources (contacts, deals, emails, gdpr, posts, workspaces) — listing with pagination, sending transactional or batch emails, scheduling and publishing posts, recording GDPR consent or running an export workflow, fetching a contact's activity timeline — or when needing OpenAPI-derived TypeScript types or the raw OpenAPI document from `@medalsocial/sdk`.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Medal Social SDK — Resources
|
|
7
|
+
|
|
8
|
+
## When to load this skill
|
|
9
|
+
|
|
10
|
+
- Calling `medal.contacts.*`, `medal.deals.*`, `medal.emails.*`, `medal.gdpr.*`, `medal.posts.*`, or `medal.workspaces.*`.
|
|
11
|
+
- Looking up an exact method signature or response shape.
|
|
12
|
+
- Building a list view that needs pagination.
|
|
13
|
+
- Sending a single transactional email or a bulk batch.
|
|
14
|
+
- Running a GDPR data-export workflow (request → poll → fetch).
|
|
15
|
+
- Importing contacts from a CSV-like source.
|
|
16
|
+
- Needing OpenAPI-derived types for a custom fetch wrapper, generated mocks, or contract tests.
|
|
17
|
+
|
|
18
|
+
## Response shapes
|
|
19
|
+
|
|
20
|
+
Most methods return one of:
|
|
21
|
+
|
|
22
|
+
- **`ApiResponse<T>`** — single-result envelope: `{ data: T }` plus optional metadata.
|
|
23
|
+
- **`PaginatedResponse<T>`** — list envelope: `{ data: T[], pagination: { has_more: boolean, next_cursor: string | null } }`.
|
|
24
|
+
|
|
25
|
+
**One documented exception:** `medal.gdpr.cookieConsent(input)` returns a plain `{ success: boolean, logId?: string }` directly — no `data` envelope. Don't destructure `{ data }` from it. See the GDPR section below.
|
|
26
|
+
|
|
27
|
+
Errors throw `MedalApiError` (see the `client` skill for details).
|
|
28
|
+
|
|
29
|
+
## Resource map
|
|
30
|
+
|
|
31
|
+
| Namespace | Source | Methods |
|
|
32
|
+
|---|---|---|
|
|
33
|
+
| `medal.contacts` | `src/resources/contacts.ts` | `list(opts?)`, `create(input)`, `get(id)`, `update(id, input)`, `remove(id)`, `activities(id, opts?)`, `addNote(id, { content })`, `import(contacts[])` |
|
|
34
|
+
| `medal.deals` | `src/resources/deals.ts` | `list(opts?)`, `create(input)`, `get(id)`, `update(id, input)`, `remove(id)` |
|
|
35
|
+
| `medal.emails.templates` | `src/resources/emails.ts` (`EmailTemplates`) | `list()`, `get(slug, opts?)` |
|
|
36
|
+
| `medal.emails` | `src/resources/emails.ts` (`Emails`) | `send(input)`, `get(id)`, `batch(input)` |
|
|
37
|
+
| `medal.gdpr` | `src/resources/gdpr.ts` | `requestExport()`, `listExports()`, `getExport(id)`, `recordConsent(input)`, `getConsent(email)`, `cookieConsent(input)` |
|
|
38
|
+
| `medal.posts` | `src/resources/posts.ts` | `list(opts?)`, `create(input)`, `get(id)`, `update(id, input)`, `remove(id)`, `schedule(id, input)`, `publish(id)`, `channels()` |
|
|
39
|
+
| `medal.workspaces` | `src/resources/workspaces.ts` | `list()` |
|
|
40
|
+
|
|
41
|
+
**Note on naming:** `contacts.remove(id)` is `remove`, not `delete` — `delete` is a reserved word and was avoided. Same for `deals.remove(id)`, `posts.remove(id)`.
|
|
42
|
+
|
|
43
|
+
## Contacts
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
import { Medal } from "@medalsocial/sdk";
|
|
47
|
+
const medal = new Medal("medal_xxx");
|
|
48
|
+
|
|
49
|
+
// List with filters (paginated)
|
|
50
|
+
const { data: contacts, pagination } = await medal.contacts.list({ status: "lead" });
|
|
51
|
+
|
|
52
|
+
// Create
|
|
53
|
+
const { data: contact } = await medal.contacts.create({
|
|
54
|
+
email: "alice@example.com",
|
|
55
|
+
first_name: "Alice",
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Add a note to the contact's timeline — input is { content }, NOT { body }
|
|
59
|
+
await medal.contacts.addNote(contact.id, { content: "Followed up via email" });
|
|
60
|
+
|
|
61
|
+
// Fetch the activity timeline (paginated; events include notes, deals, emails, etc.)
|
|
62
|
+
const { data: activities } = await medal.contacts.activities(contact.id, { limit: 50 });
|
|
63
|
+
|
|
64
|
+
// Bulk import — MAX 500 contacts per call; duplicates are skipped server-side
|
|
65
|
+
await medal.contacts.import([
|
|
66
|
+
{ email: "a@x.com", first_name: "A" },
|
|
67
|
+
{ email: "b@x.com", first_name: "B" },
|
|
68
|
+
]);
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Posts — create, schedule, publish
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
// List channels first to know what channel_ids to target
|
|
75
|
+
const { data: channels } = await medal.posts.channels();
|
|
76
|
+
|
|
77
|
+
// Create
|
|
78
|
+
const { data: post } = await medal.posts.create({
|
|
79
|
+
content: "Hello world!",
|
|
80
|
+
channel_ids: [channels[0].id],
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Schedule for a future ISO 8601 timestamp
|
|
84
|
+
await medal.posts.schedule(post.id, { scheduled_at: "2026-06-15T10:00:00Z" });
|
|
85
|
+
|
|
86
|
+
// Or publish immediately (no schedule)
|
|
87
|
+
await medal.posts.publish(post.id);
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`channels()` is the canonical way to discover what publishing destinations a workspace has connected — don't hard-code channel IDs.
|
|
91
|
+
|
|
92
|
+
## Emails — transactional + batch
|
|
93
|
+
|
|
94
|
+
**Single send (HTTP 202 — queued, not delivered):**
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
const { data: result } = await medal.emails.send({
|
|
98
|
+
template_slug: "welcome",
|
|
99
|
+
to: "user@example.com",
|
|
100
|
+
name: "Alice",
|
|
101
|
+
locale: "en",
|
|
102
|
+
fallback_locale: "en",
|
|
103
|
+
variables: { name: "Alice", trial_days: "14" },
|
|
104
|
+
contact_id: contact.id, // optional — links the send to a contact
|
|
105
|
+
});
|
|
106
|
+
// result.status is "queued"; poll medal.emails.get(result.id) for delivery state
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
**Templates:**
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
const { data: templates } = await medal.emails.templates.list();
|
|
113
|
+
const { data: detail } = await medal.emails.templates.get("welcome", { locale: "en" });
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
**Batch send (same template to many recipients) — MAX 100 recipients per call:**
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
const { data: summary } = await medal.emails.batch({
|
|
120
|
+
template_slug: "newsletter-may",
|
|
121
|
+
default_locale: "en",
|
|
122
|
+
recipients: [
|
|
123
|
+
{ email: "a@x.com", name: "A", variables: { unsubscribe_token: "..." } },
|
|
124
|
+
{ email: "b@x.com", name: "B", locale: "fr", variables: { unsubscribe_token: "..." } },
|
|
125
|
+
],
|
|
126
|
+
});
|
|
127
|
+
// summary = { batch_id, total, queued, failed }
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
For more than 100 recipients, chunk into multiple `batch()` calls. There is no built-in chunker.
|
|
131
|
+
|
|
132
|
+
## GDPR — consent + export workflow
|
|
133
|
+
|
|
134
|
+
**Consent (per-contact):**
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
await medal.gdpr.recordConsent({
|
|
138
|
+
email: "user@example.com",
|
|
139
|
+
consent_type: "marketing_email",
|
|
140
|
+
granted: true,
|
|
141
|
+
});
|
|
142
|
+
const { data: history } = await medal.gdpr.getConsent("user@example.com");
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
**Cookie consent** has a distinct return shape: `{ success: boolean, logId?: string }` — not the `ApiResponse<T>` envelope. Don't try to destructure `.data` from it.
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
const result = await medal.gdpr.cookieConsent({ /* ... */ });
|
|
149
|
+
result.success; // boolean
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
**Export workflow** — async; you initiate, then poll:
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
const { data: req } = await medal.gdpr.requestExport();
|
|
156
|
+
// req = { request_id, status }
|
|
157
|
+
|
|
158
|
+
// Poll until terminal. GdprExport.status is one of:
|
|
159
|
+
// "pending" | "in_progress" | "completed" | "failed" | string
|
|
160
|
+
// "completed" is success; "failed" is terminal failure; anything else is still running.
|
|
161
|
+
async function waitForExport(id: string) {
|
|
162
|
+
while (true) {
|
|
163
|
+
const { data: exp } = await medal.gdpr.getExport(id);
|
|
164
|
+
if (exp.status === "completed") return exp;
|
|
165
|
+
if (exp.status === "failed") throw new Error(`Export ${id} failed`);
|
|
166
|
+
await new Promise((r) => setTimeout(r, 5_000));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const exp = await waitForExport(req.request_id);
|
|
170
|
+
// exp.download_url and exp.expires_at are populated once completed
|
|
171
|
+
|
|
172
|
+
// Or list everything the workspace has ever exported
|
|
173
|
+
const { data: all } = await medal.gdpr.listExports();
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## Pagination — concrete loop
|
|
177
|
+
|
|
178
|
+
`PaginationOptions` is `{ limit?: number, cursor?: string }`. To page through everything:
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
async function listAllContacts() {
|
|
182
|
+
const out: Contact[] = [];
|
|
183
|
+
let cursor: string | undefined;
|
|
184
|
+
do {
|
|
185
|
+
const page = await medal.contacts.list({ limit: 200, cursor });
|
|
186
|
+
out.push(...page.data);
|
|
187
|
+
cursor = page.pagination.has_more ? page.pagination.next_cursor ?? undefined : undefined;
|
|
188
|
+
} while (cursor);
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
`has_more` is the loop condition; `next_cursor` is what you pass on the next call. Don't loop on `next_cursor` alone — the API can return `has_more: false` with a non-null cursor when paging ends.
|
|
194
|
+
|
|
195
|
+
## OpenAPI types — for raw fetch, mocks, or custom wrappers
|
|
196
|
+
|
|
197
|
+
The package ships OpenAPI-derived types via a dedicated subpath:
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
import type { paths, components } from "@medalsocial/sdk/openapi-types";
|
|
201
|
+
|
|
202
|
+
type GetContactsResponse =
|
|
203
|
+
paths["/contacts"]["get"]["responses"]["200"]["content"]["application/json"];
|
|
204
|
+
|
|
205
|
+
type Contact = components["schemas"]["Contact"];
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Use these when building a custom fetch wrapper (e.g. in an edge function where you want zero dependencies), generating mocks for tests, or extending the SDK with a not-yet-wrapped endpoint.
|
|
209
|
+
|
|
210
|
+
## OpenAPI document — for codegen, docs, contracts
|
|
211
|
+
|
|
212
|
+
The raw OpenAPI 3.1 document is exported too:
|
|
213
|
+
|
|
214
|
+
```ts
|
|
215
|
+
// ESM import attributes (Node 24+, modern bundlers)
|
|
216
|
+
import openapi from "@medalsocial/sdk/openapi.json" with { type: "json" };
|
|
217
|
+
|
|
218
|
+
// or read the YAML directly from disk (Node)
|
|
219
|
+
import { readFileSync } from "node:fs";
|
|
220
|
+
import { fileURLToPath } from "node:url";
|
|
221
|
+
const yamlPath = fileURLToPath(new URL("./node_modules/@medalsocial/sdk/openapi/medal-social.openapi.yaml", import.meta.url));
|
|
222
|
+
const yaml = readFileSync(yamlPath, "utf8");
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
The `with { type: "json" }` import-attribute syntax requires Node 24+ or a bundler with import-attribute support (Vite, esbuild, Webpack 5+). For older runtimes, read the JSON via `fs` instead.
|
|
226
|
+
|
|
227
|
+
## Anti-patterns
|
|
228
|
+
|
|
229
|
+
| Anti-pattern | Why it's wrong | Correct approach |
|
|
230
|
+
|---|---|---|
|
|
231
|
+
| `medal.contacts.delete(id)` | Method is `remove`, not `delete` (`delete` is reserved) | `medal.contacts.remove(id)` |
|
|
232
|
+
| `medal.contacts.addNote(id, { body })` | Input shape is `{ content }`, not `{ body }` | `medal.contacts.addNote(id, { content })` |
|
|
233
|
+
| `new Medal({ apiKey: "..." })` | Token is positional, not an option | `new Medal("medal_xxx")` |
|
|
234
|
+
| `medal.emails.list()` | Templates live at `medal.emails.templates.list()`; `emails.list()` doesn't exist | `medal.emails.templates.list()` |
|
|
235
|
+
| Treating `cookieConsent` result like `ApiResponse<T>` | Returns `{ success, logId? }` directly, not wrapped in `{ data }` | Read `result.success` directly |
|
|
236
|
+
| Treating `emails.send` as confirmation of delivery | API returns HTTP 202; status is `"queued"` | Poll `medal.emails.get(result.id)` for delivery state |
|
|
237
|
+
| `emails.batch` with > 100 recipients | API rejects; no client-side chunking | Chunk into 100-recipient batches yourself |
|
|
238
|
+
| `contacts.import` with > 500 contacts | API rejects | Chunk into 500-contact batches yourself |
|
|
239
|
+
| Hard-coded channel IDs in `posts.create` | Channels are workspace-specific | Call `posts.channels()` to discover them |
|
|
240
|
+
| Looping on `next_cursor` alone for pagination | Can be non-null when `has_more: false` | Loop on `pagination.has_more` |
|
|
241
|
+
| Building a custom client when only types are needed | Reinventing the wheel | Import from `@medalsocial/sdk/openapi-types` |
|