coss 0.0.5 → 0.2.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/LICENSE +21 -0
- package/README.md +395 -0
- package/dist/index.d.ts +2823 -0
- package/dist/index.js +1917 -0
- package/package.json +34 -23
- package/.npmignore +0 -4
- package/Cakefile +0 -46
- package/bower.json +0 -25
- package/coss.js +0 -144
- package/coss.min.js +0 -1
- package/readme.md +0 -75
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Cal.com, Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
# coss
|
|
2
|
+
|
|
3
|
+
The official TypeScript SDK for COSS: one API key for email, and soon SMS, video, scheduling and more.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm install coss
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Published from [cosscom/coss](https://github.com/cosscom/coss) by CI, with
|
|
10
|
+
[provenance](https://docs.npmjs.com/generating-provenance-statements). Versions 0.0.1–0.0.5 of the npm package
|
|
11
|
+
are an earlier, unrelated package by a previous owner (deprecated).
|
|
12
|
+
|
|
13
|
+
- Zero dependencies, ESM-only, fully typed.
|
|
14
|
+
- `coss.email`, `coss.domains`, `coss.webhookEndpoints` and `coss.reputation` behind one API key.
|
|
15
|
+
- Every method returns `{ data, error }`. API and network failures are returned, never thrown.
|
|
16
|
+
- Retries transient failures automatically, and sends are idempotent.
|
|
17
|
+
|
|
18
|
+
## Quick start
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import { Coss } from "coss";
|
|
22
|
+
|
|
23
|
+
const coss = new Coss({ apiKey: process.env.COSS_API_KEY });
|
|
24
|
+
|
|
25
|
+
const { data, error } = await coss.email.send({
|
|
26
|
+
from: "Acme <onboarding@example.com>",
|
|
27
|
+
to: "delivered@example.com",
|
|
28
|
+
subject: "Hello from COSS",
|
|
29
|
+
html: "<p>It works!</p>",
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
if (error) {
|
|
33
|
+
console.error(error.type, error.code, error.message, error.request_id);
|
|
34
|
+
} else {
|
|
35
|
+
console.log(data.id, data.status); // "email_01k…", "queued" (sent moments later)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const { data: email } = await coss.email.get("email_01k…");
|
|
39
|
+
console.log(email?.recipients); // [{ address: "user@example.com", type: "to", status: "delivered", … }]
|
|
40
|
+
|
|
41
|
+
const { data: events } = await coss.email.listEvents("email_01k…"); // email.sent, email.delivered, …
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Keys come in two kinds. `coss_live_…` keys belong to a project's **production** environment and
|
|
45
|
+
deliver email from your organization's verified domains. `coss_test_…` keys belong to
|
|
46
|
+
**development**: nothing is delivered, every email is recorded with `sandbox: true`, and delivery
|
|
47
|
+
events are simulated (send to `bounce@simulator.amazonses.com` or `complaint@…` to test failures).
|
|
48
|
+
|
|
49
|
+
Request and response fields use the API's snake_case names, such as `reply_to` and `created_at`.
|
|
50
|
+
|
|
51
|
+
A runnable example lives in [`examples/send-email.ts`](./examples/send-email.ts):
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
COSS_API_KEY=coss_test_… COSS_BASE_URL=http://localhost:3001 bun packages/sdk/examples/send-email.ts
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Options
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
new Coss({
|
|
61
|
+
apiKey: "coss_test_…", // default: process.env.COSS_API_KEY
|
|
62
|
+
baseUrl: "https://api.coss.com", // default: process.env.COSS_BASE_URL, then https://api.coss.com
|
|
63
|
+
timeout: 30_000, // per attempt, in ms
|
|
64
|
+
maxRetries: 2, // 0 disables retries
|
|
65
|
+
fetch: customFetch, // default: the global fetch
|
|
66
|
+
apiVersion: "2026-10-10", // default: API_VERSION, the version these types describe
|
|
67
|
+
dangerouslyAllowBrowser: false,
|
|
68
|
+
});
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The constructor throws in only two cases, both programmer errors:
|
|
72
|
+
- No API key is found.
|
|
73
|
+
- It runs where a `document` global exists (a browser, or a jsdom or happy-dom test environment)
|
|
74
|
+
without `dangerouslyAllowBrowser: true`.
|
|
75
|
+
|
|
76
|
+
API keys are secrets, so call COSS from your server.
|
|
77
|
+
|
|
78
|
+
Each method also takes per-request options:
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
await coss.email.send(params, { idempotencyKey: "welcome-user_123", signal: controller.signal });
|
|
82
|
+
await coss.email.get(id, { signal });
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## API versions
|
|
86
|
+
|
|
87
|
+
The API is versioned by date. Every request sends a `Coss-Version` header: by default `API_VERSION`,
|
|
88
|
+
the version this SDK's types describe, so upgrading the SDK is how you upgrade the API version.
|
|
89
|
+
Your organization's pinned version (which applies to plain HTTP requests without the header) never
|
|
90
|
+
affects the SDK. Every response says which version it was rendered in, in its `Coss-Version` header.
|
|
91
|
+
|
|
92
|
+
Webhook endpoints have their own `api_version`. Created through the SDK, it defaults to the client's
|
|
93
|
+
version, so events match the `WebhookEvent` types. Each event carries it as `api_version`. After an
|
|
94
|
+
SDK upgrade that moves `API_VERSION`, update your endpoints too:
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
import { API_VERSION } from "coss";
|
|
98
|
+
|
|
99
|
+
await coss.webhookEndpoints.update("whep_01k…", { api_version: API_VERSION });
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Attachments
|
|
103
|
+
|
|
104
|
+
Attach up to 20 files (25 MB combined) as base64. The content type defaults from the extension.
|
|
105
|
+
A `content_id` makes an attachment inline, so the HTML can reference it as `cid:…`:
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
import { readFile } from "node:fs/promises";
|
|
109
|
+
|
|
110
|
+
await coss.email.send({
|
|
111
|
+
from: "Acme <billing@acme.com>",
|
|
112
|
+
to: "user@example.com",
|
|
113
|
+
subject: "Your invoice",
|
|
114
|
+
html: '<img src="cid:logo@acme" alt="Acme"><p>Your invoice is attached.</p>',
|
|
115
|
+
attachments: [
|
|
116
|
+
{ filename: "invoice.pdf", content: (await readFile("invoice.pdf")).toString("base64") },
|
|
117
|
+
{ filename: "logo.png", content: logoBase64, content_id: "logo@acme" },
|
|
118
|
+
],
|
|
119
|
+
});
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Emails only return attachment metadata (`id`, `filename`, `content_type`, `content_id`, `size`).
|
|
123
|
+
COSS discards the content once the email is sent. Executables and other file types the delivery
|
|
124
|
+
provider refuses (`.exe`, `.js`, `.bat`, …) are rejected with a `validation_error`.
|
|
125
|
+
|
|
126
|
+
## Domains
|
|
127
|
+
|
|
128
|
+
Live keys can only send from a verified domain. Domains belong to the **organization**, so a
|
|
129
|
+
`coss_test_…` key manages the same list a `coss_live_…` key does.
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
const { data: domain } = await coss.domains.create({ name: "acme.com" });
|
|
133
|
+
|
|
134
|
+
// Publish these, then check them. `records` carries the result of the last check.
|
|
135
|
+
for (const record of domain?.records ?? []) {
|
|
136
|
+
console.log(record.type, record.name, record.value, record.status); // "CNAME", "…", "…", "missing"
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const { data: checked } = await coss.domains.verify(domain!.id);
|
|
140
|
+
console.log(checked?.status, checked?.can_send); // "verified", true
|
|
141
|
+
|
|
142
|
+
await coss.domains.list();
|
|
143
|
+
await coss.domains.get(domain!.id);
|
|
144
|
+
await coss.domains.delete(domain!.id);
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
`verify` asks the provider and DNS right away instead of waiting for the background check, so it is
|
|
148
|
+
rate limited: a second call within a few seconds answers `429` (the SDK waits out `Retry-After` and
|
|
149
|
+
retries). Verification gives up after 72 hours and the domain becomes `failed`; calling `verify`
|
|
150
|
+
again then starts over with fresh DKIM records, which have to be published too. The `dmarc` record
|
|
151
|
+
is recommended rather than required (`required: false`).
|
|
152
|
+
|
|
153
|
+
## Webhook endpoints
|
|
154
|
+
|
|
155
|
+
Endpoints belong to the API key's own **environment**, so sandbox and production receivers stay
|
|
156
|
+
separate. The signing secret is returned only when the endpoint is created.
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
const { data: endpoint } = await coss.webhookEndpoints.create({
|
|
160
|
+
url: "https://example.com/webhooks/coss",
|
|
161
|
+
description: "Billing service",
|
|
162
|
+
event_types: ["email.bounced", "email.complained"], // omit for every event type
|
|
163
|
+
});
|
|
164
|
+
console.log(endpoint?.secret); // "whsec_…" — store it now, it is not returned again
|
|
165
|
+
|
|
166
|
+
await coss.webhookEndpoints.update(endpoint!.id, { enabled: false }); // pause without deleting
|
|
167
|
+
await coss.webhookEndpoints.list();
|
|
168
|
+
await coss.webhookEndpoints.get(endpoint!.id);
|
|
169
|
+
await coss.webhookEndpoints.delete(endpoint!.id);
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
`update` only changes the fields you pass, and never the signing secret, so receivers keep
|
|
173
|
+
verifying deliveries. `event_types: []` means every event type, including ones added later.
|
|
174
|
+
|
|
175
|
+
## Scheduled sends
|
|
176
|
+
|
|
177
|
+
Pass `scheduled_at` (ISO 8601 with an offset, at most 30 days ahead) to send later. The email
|
|
178
|
+
comes back with the status `scheduled` and is sent at that time; a time that is not in the future
|
|
179
|
+
sends immediately.
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
const { data: email } = await coss.email.send({
|
|
183
|
+
...params,
|
|
184
|
+
scheduled_at: "2026-10-11T09:00:00Z", // or new Date(…).toISOString()
|
|
185
|
+
}); // { status: "scheduled", scheduled_at: "2026-10-11T09:00:00.000Z", … }
|
|
186
|
+
|
|
187
|
+
// Until it is sent, move it to another time (at most 30 days ahead; a past time sends it now)…
|
|
188
|
+
await coss.email.reschedule(email.id, { scheduled_at: "2026-10-12T09:00:00Z" });
|
|
189
|
+
// …or cancel it.
|
|
190
|
+
const { error } = await coss.email.cancel(email.id); // status "canceled"
|
|
191
|
+
// error?.code === "email_not_cancelable" (or "email_not_reschedulable") once it is being sent,
|
|
192
|
+
// was sent or, for reschedule, was canceled.
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Scheduling, rescheduling and canceling are recorded as `email.scheduled`, `email.rescheduled`
|
|
196
|
+
(with the new and previous times in `data.reschedule`) and `email.canceled` events (and webhooks).
|
|
197
|
+
|
|
198
|
+
## Suppressions
|
|
199
|
+
|
|
200
|
+
Each environment has a suppression list. Sends skip suppressed recipients: they get the status
|
|
201
|
+
`suppressed` and an `email.suppressed` event, and the rest of the email is still sent. Permanent
|
|
202
|
+
bounces and spam complaints add an address for every email (`scope: "*"`) automatically.
|
|
203
|
+
|
|
204
|
+
An entry can also cover a single category. That makes the list a good backend for unsubscribe
|
|
205
|
+
links:
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
// The recipient unsubscribed from marketing email.
|
|
209
|
+
await coss.email.suppressions.add({ address: "jane@example.com", scope: "marketing" });
|
|
210
|
+
|
|
211
|
+
// Skipped: the email has the suppressed category.
|
|
212
|
+
await coss.email.send({ ...params, to: "jane@example.com", category: "marketing" });
|
|
213
|
+
// Delivered: receipts are not suppressed.
|
|
214
|
+
await coss.email.send({ ...params, to: "jane@example.com", category: "receipts" });
|
|
215
|
+
|
|
216
|
+
const { data: check } = await coss.email.suppressions.check({
|
|
217
|
+
address: "jane@example.com",
|
|
218
|
+
category: "marketing",
|
|
219
|
+
}); // { suppressed: true, suppressions: [{ id: "sup_01k…", scope: "marketing", … }] }
|
|
220
|
+
|
|
221
|
+
const { data: page } = await coss.email.suppressions.list({ limit: 50 }); // newest first
|
|
222
|
+
// Next page: list({ after: page.data.at(-1).id }) while page.has_more.
|
|
223
|
+
await coss.email.suppressions.remove("sup_01k…"); // e.g. when they resubscribe
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Move whole lists in and out in bulk. `import` sends 1,000 entries per request and adds up the
|
|
227
|
+
results; invalid entries are skipped and reported by their position, and entries already on the
|
|
228
|
+
list are left unchanged, so importing again is safe:
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
const { data: result } = await coss.email.suppressions.import({
|
|
232
|
+
suppressions: [{ address: "jane@example.com" }, { address: "joe@example.com", scope: "marketing" }],
|
|
233
|
+
}); // { created: 2, existing: 0, invalid: [] }
|
|
234
|
+
|
|
235
|
+
// CSV with the columns address,scope,reason,created_at,email_id,id (oldest first).
|
|
236
|
+
const { data: csv } = await coss.email.suppressions.export({ reason: "bounce" });
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Imported entries have the reason `import`. The dashboard imports and exports the same CSV.
|
|
240
|
+
|
|
241
|
+
### Open and click tracking
|
|
242
|
+
|
|
243
|
+
Tracking is off unless the environment turns it on in the dashboard (a default, plus rules per
|
|
244
|
+
category) or a send asks for it. COSS then adds an invisible image to the HTML body and sends its
|
|
245
|
+
links through a redirect. Links with a `data-coss-no-track` attribute are left as they are.
|
|
246
|
+
|
|
247
|
+
```ts
|
|
248
|
+
const { data: sent } = await coss.email.send({
|
|
249
|
+
...params,
|
|
250
|
+
html: '<p><a href="https://acme.com/start">Get started</a></p>',
|
|
251
|
+
tracking: { opens: true, clicks: true }, // overrides the environment's settings for this email
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const { data: email } = await coss.email.get(sent.id);
|
|
255
|
+
console.log(email?.tracking); // { opens: true, clicks: true, open_count: 1, click_count: 0, … }
|
|
256
|
+
|
|
257
|
+
const { data: links } = await coss.email.listLinks(sent.id);
|
|
258
|
+
// [{ url: "https://acme.com/start", click_count: 0, … }]
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Opens and clicks arrive as `email.opened` and `email.clicked` events (in `listEvents` and in
|
|
262
|
+
webhooks), with `data.open` / `data.click`. They name a recipient only when the email had exactly
|
|
263
|
+
one. Mail clients that preload images and link scanners can inflate the numbers.
|
|
264
|
+
|
|
265
|
+
## Reputation
|
|
266
|
+
|
|
267
|
+
COSS watches your live bounce and complaint rates. When one gets too high your organization is
|
|
268
|
+
warned, and past the suspension threshold live sending is suspended automatically (sends fail with
|
|
269
|
+
`permission_error` / `account_suspended`; sandbox keys keep working) until COSS support reinstates it.
|
|
270
|
+
|
|
271
|
+
```ts
|
|
272
|
+
const { data } = await coss.reputation.get();
|
|
273
|
+
// { status: "warning", reason: "bounce_rate", checks: [{ trigger: "bounce_rate", rate: 0.051, … }], … }
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
## Webhooks
|
|
278
|
+
|
|
279
|
+
Webhook endpoints receive `email.sent`, `email.delivered`, `email.delivery_delayed`,
|
|
280
|
+
`email.bounced`, `email.complained`, `email.failed`, `email.suppressed`, `email.opened` and
|
|
281
|
+
`email.clicked` events, plus `account.reputation_warning`, `account.reputation_recovered`,
|
|
282
|
+
`account.suspended` and `account.reinstated` for live environments, signed with
|
|
283
|
+
[Standard Webhooks](https://www.standardwebhooks.com) headers. Verify them with the raw body:
|
|
284
|
+
|
|
285
|
+
```ts
|
|
286
|
+
import { verifyWebhook, WebhookVerificationError } from "coss";
|
|
287
|
+
|
|
288
|
+
export async function POST(request: Request) {
|
|
289
|
+
try {
|
|
290
|
+
const event = await verifyWebhook(await request.text(), request.headers, process.env.COSS_WEBHOOK_SECRET!);
|
|
291
|
+
if (event.type === "email.bounced") console.log(event.data.recipient, event.data.bounce);
|
|
292
|
+
if (event.type === "account.suspended") console.log(event.data.reason, event.data.checks);
|
|
293
|
+
return new Response("ok");
|
|
294
|
+
} catch (error) {
|
|
295
|
+
if (error instanceof WebhookVerificationError) return new Response("invalid", { status: 400 });
|
|
296
|
+
throw error;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
Unlike other methods, `verifyWebhook` throws. Deliveries are retried for about a day and may arrive
|
|
302
|
+
out of order (use `data.occurred_at`) or more than once (deduplicate by `event.id`).
|
|
303
|
+
|
|
304
|
+
## Errors
|
|
305
|
+
|
|
306
|
+
`error` is a plain object:
|
|
307
|
+
|
|
308
|
+
```ts
|
|
309
|
+
interface CossError {
|
|
310
|
+
type: CossErrorType;
|
|
311
|
+
code: string; // e.g. "invalid_api_key", "validation_error"
|
|
312
|
+
message: string;
|
|
313
|
+
status: number | null; // null when no response was received
|
|
314
|
+
request_id: string | null; // include it when contacting support
|
|
315
|
+
param?: string;
|
|
316
|
+
issues?: { path: string; message: string }[]; // per-field validation problems
|
|
317
|
+
}
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
| `type` | Status | Meaning |
|
|
321
|
+
| ----------------------- | ----------------- | ----------------------------------------------------------------- |
|
|
322
|
+
| `invalid_request_error` | 400 / 413 / 422 | Malformed JSON, payload too large, or failed validation (`issues`) |
|
|
323
|
+
| `authentication_error` | 401 | `missing_api_key` or `invalid_api_key` |
|
|
324
|
+
| `permission_error` | 403 | The key lacks the permission (`insufficient_permissions`) |
|
|
325
|
+
| `not_found_error` | 404 | No such resource in the key's environment |
|
|
326
|
+
| `idempotency_error` | 409 / 422 | `request_in_progress` (retried automatically), or `idempotency_key_reused` with a different body |
|
|
327
|
+
| `rate_limit_error` | 429 | Too many requests with the key (`rate_limited`), the send quota is used up (`quota_exceeded`), or a domain was checked moments ago (`domain_recently_checked`) |
|
|
328
|
+
| `api_error` | 5xx, other | Server error. Also used for responses that are not a COSS error envelope (`unexpected_response`) |
|
|
329
|
+
| `connection_error` | — | Network failure (`network_error`), or you aborted (`aborted`) |
|
|
330
|
+
| `timeout_error` | — | An attempt exceeded `timeout` (`request_timeout`) |
|
|
331
|
+
|
|
332
|
+
## Retries and idempotency
|
|
333
|
+
|
|
334
|
+
The SDK retries failed requests up to `maxRetries` times (default 2) on:
|
|
335
|
+
- connection errors and timeouts;
|
|
336
|
+
- `409`, `429` and `5xx` responses.
|
|
337
|
+
|
|
338
|
+
Other errors are returned immediately.
|
|
339
|
+
|
|
340
|
+
`webhookEndpoints.create()` never retries automatically, regardless of `maxRetries`: the API does
|
|
341
|
+
not deduplicate endpoint creation, even with an `Idempotency-Key`. If the response is lost, creation
|
|
342
|
+
may still have succeeded. List endpoints and delete the endpoint with the unknown secret before
|
|
343
|
+
creating a replacement.
|
|
344
|
+
|
|
345
|
+
The wait between attempts grows exponentially (500 ms, 1 s, 2 s, … up to 8 s) with random jitter. When
|
|
346
|
+
the response has a `Retry-After` header, the SDK waits exactly that long instead. If `Retry-After` asks
|
|
347
|
+
for more than 60 s, the SDK returns the error rather than waiting.
|
|
348
|
+
|
|
349
|
+
Every `POST` carries an `Idempotency-Key` header. It is either the key you pass or a random UUID, and
|
|
350
|
+
the same key is reused on every retry of that call. For email sends, the API stores the result for 24 hours, so a
|
|
351
|
+
retried send is never delivered twice:
|
|
352
|
+
- A repeat with the same key and the same body returns the original email.
|
|
353
|
+
- A repeat with the same key and a different body fails with `idempotency_key_reused`.
|
|
354
|
+
|
|
355
|
+
To make a send safe across process restarts too, pass your own key, derived from your data
|
|
356
|
+
(for example `welcome-${userId}`).
|
|
357
|
+
|
|
358
|
+
## Timeouts and cancellation
|
|
359
|
+
|
|
360
|
+
`timeout` applies to each attempt and covers reading the response body. Pass an `AbortSignal` to
|
|
361
|
+
cancel a call. Aborting stops the request in flight and any pending retry, and returns
|
|
362
|
+
`{ type: "connection_error", code: "aborted" }`.
|
|
363
|
+
|
|
364
|
+
## Runtime support
|
|
365
|
+
|
|
366
|
+
The SDK works on Node.js ≥ 22, Bun, Deno and Cloudflare Workers. It only needs the standard `fetch`,
|
|
367
|
+
`AbortSignal.timeout`/`AbortSignal.any` and `crypto.randomUUID`. It never uses Node built-ins.
|
|
368
|
+
|
|
369
|
+
The package is **ESM-only**:
|
|
370
|
+
- `import { Coss } from "coss"` works everywhere.
|
|
371
|
+
- On Node 22.12+ (and 20.19+), CommonJS can also `require("coss")` because Node supports `require(esm)` there.
|
|
372
|
+
|
|
373
|
+
Environment fallbacks read `process.env` where it exists. On Workers or Deno without env access,
|
|
374
|
+
pass `apiKey` (and `baseUrl`) explicitly.
|
|
375
|
+
|
|
376
|
+
## Development
|
|
377
|
+
|
|
378
|
+
```sh
|
|
379
|
+
bun run --cwd packages/sdk typecheck # src (isolatedDeclarations) + tests
|
|
380
|
+
bun run --cwd packages/sdk test # bun:test with a mocked fetch; no network
|
|
381
|
+
bun run --cwd packages/sdk build # tsdown → dist/, then publint and attw
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
`src/version.ts` must match `version` in `package.json`, and a test enforces it. Releases are
|
|
385
|
+
prepared with [Changesets](https://github.com/changesets/changesets): add one with `bun run changeset`
|
|
386
|
+
for every user-facing change. `bun run version-packages` bumps `package.json`, `src/version.ts` and
|
|
387
|
+
`CHANGELOG.md` together, and on `main` CI opens a "Version packages" PR that does the same. Nothing
|
|
388
|
+
is published to npm.
|
|
389
|
+
|
|
390
|
+
`src/api-version.ts` mirrors the API's versions. When the API adds one, add it there, move
|
|
391
|
+
`API_VERSION` and update the wire types in the same change. The contract tests in `apps/api` check
|
|
392
|
+
both.
|
|
393
|
+
|
|
394
|
+
`tsdown` rewrites the `exports`, `main`, `module` and `types` fields in `package.json` on every
|
|
395
|
+
build. The committed values are exactly what it generates.
|