@posthaste/sdk 0.1.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 ADDED
@@ -0,0 +1,454 @@
1
+ # @posthaste/sdk
2
+
3
+ The official TypeScript SDK for the [Posthaste](https://posthastemail.dev) transactional email API.
4
+
5
+ **Zero runtime dependencies.** Global `fetch` and `node:crypto`, nothing else — so it installs in
6
+ about a second, adds nothing to your lockfile, and has no transitive supply chain to audit.
7
+
8
+ ```bash
9
+ npm i @posthaste/sdk
10
+ ```
11
+
12
+ Requires Node 22 or newer (or any runtime with a global `fetch`). ESM only.
13
+
14
+ ---
15
+
16
+ ## Quickstart
17
+
18
+ ```ts
19
+ import { Posthaste } from '@posthaste/sdk';
20
+
21
+ const posthaste = new Posthaste({ apiKey: process.env.POSTHASTE_API_KEY! });
22
+
23
+ const sent = await posthaste.emails.send({
24
+ from: 'Acme <billing@acme.com>',
25
+ to: 'customer@example.com',
26
+ subject: 'Your receipt',
27
+ html: '<p>Thanks for your order.</p>',
28
+ text: 'Thanks for your order.',
29
+ idempotencyKey: `receipt-${orderId}`,
30
+ });
31
+
32
+ console.log(sent.id, sent.duplicate); // msg_AZLm3kQ8T2Sf9pXbNc7HrQ false
33
+ ```
34
+
35
+ ## Authentication
36
+
37
+ Posthaste authenticates with an API key sent as a bearer token. Create one in the dashboard under
38
+ **Settings → API keys**; it is shown exactly once.
39
+
40
+ ```ts
41
+ const posthaste = new Posthaste({
42
+ apiKey: process.env.POSTHASTE_API_KEY!, // ph_live_… or ph_test_…
43
+ baseUrl: 'https://api.posthastemail.dev', // the default
44
+ timeoutMs: 30_000, // per attempt
45
+ maxRetries: 2, // retries after the first attempt
46
+ });
47
+ ```
48
+
49
+ A key carries **scopes**, and a call that needs one the key does not hold fails with a `403` naming
50
+ the missing scope. The nine scopes are `account:read`, `domains:read`, `domains:write`,
51
+ `emails:send`, `messages:read`, `suppressions:read`, `suppressions:write`, `webhooks:read`,
52
+ `webhooks:write`.
53
+
54
+ ### Bringing your own `fetch`
55
+
56
+ ```ts
57
+ import { ProxyAgent } from 'undici';
58
+
59
+ const dispatcher = new ProxyAgent('http://proxy.internal:8080');
60
+
61
+ const posthaste = new Posthaste({
62
+ apiKey: process.env.POSTHASTE_API_KEY!,
63
+ fetch: (url, init) => fetch(url, { ...init, dispatcher } as RequestInit),
64
+ });
65
+ ```
66
+
67
+ Useful behind a corporate proxy, for tracing, and for tests — the SDK's own end-to-end suite passes
68
+ a `fetch` that forwards straight into an in-process server.
69
+
70
+ ---
71
+
72
+ ## Sending
73
+
74
+ `emails.send` maps to `POST /v1/emails`. One recipient per call: there is no `cc`, no `bcc` and no
75
+ array — send `to` a list of people by making a call per person, so one bad address cannot spoil the
76
+ rest.
77
+
78
+ ```ts
79
+ await posthaste.emails.send({
80
+ from: 'Acme <billing@acme.com>', // must be on a VERIFIED domain
81
+ to: 'customer@example.com',
82
+ subject: 'Your receipt',
83
+ text: 'Thanks for your order.', // text or html — at least one
84
+ html: '<p>Thanks for your order.</p>',
85
+ replyTo: 'support@acme.com',
86
+ headers: { 'X-Order-Id': orderId },
87
+ listUnsubscribe: '<https://acme.com/unsub?u=123>, <mailto:unsub@acme.com>',
88
+ idempotencyKey: `receipt-${orderId}`,
89
+ });
90
+ ```
91
+
92
+ ### Two success statuses, and why you are told which
93
+
94
+ The API answers a **new** send with `202 { status: 'queued' }` and an **idempotency replay** with
95
+ `200 { status: 'duplicate' }`. Both are success. The SDK returns both, with the difference made
96
+ explicit rather than hidden:
97
+
98
+ ```ts
99
+ const result = await posthaste.emails.send({
100
+ from,
101
+ to,
102
+ text,
103
+ idempotencyKey: `receipt-${orderId}`,
104
+ });
105
+
106
+ if (result.duplicate) {
107
+ // Nothing was sent. `result.id` is the message we accepted the first time.
108
+ } else {
109
+ // A new message exists and is queued for delivery.
110
+ }
111
+ ```
112
+
113
+ If you bill, count, or log per send, that distinction is the difference between an accurate number
114
+ and a slowly-drifting one.
115
+
116
+ ---
117
+
118
+ ## Idempotency
119
+
120
+ **`idempotencyKey` is a BODY field, not a header.**
121
+
122
+ The `Idempotency-Key` HTTP header is in the API's CORS allowlist — so nothing complains if you send
123
+ it — but **no handler reads it**. A client that sets the header and not the field gets no idempotency
124
+ at all, and no warning that it has none: the retry simply sends a second email. This SDK never sends
125
+ the header, and `idempotencyKey` in the params maps to the body field.
126
+
127
+ ```ts
128
+ // Correct. The key travels in the JSON body.
129
+ await posthaste.emails.send({ from, to, text, idempotencyKey: `receipt-${orderId}` });
130
+ ```
131
+
132
+ Details worth knowing:
133
+
134
+ - The key is scoped to your account and lives as long as the message record — there is no expiry
135
+ window.
136
+ - **The body is not part of the comparison.** Reusing a key with different content returns the
137
+ ORIGINAL message, silently. Derive the key from the thing you are sending (`receipt-${orderId}`),
138
+ never from a timestamp or a random value per attempt.
139
+ - A replay is not billed and does not consume quota.
140
+ - The duplicate check runs before the domain lookup, suppression and quota, so a replay is cheap.
141
+
142
+ ---
143
+
144
+ ## Pagination
145
+
146
+ Lists are keyset-paginated: `limit` and `before` in, `{ data, hasMore, nextCursor }` out. `before`
147
+ is the id of the last row you received, which is exactly what `nextCursor` gives you.
148
+
149
+ ```ts
150
+ const page = await posthaste.messages.list({ limit: 50, status: 'bounced' });
151
+ // page.data, page.hasMore, page.nextCursor
152
+ ```
153
+
154
+ ### Loop on `hasMore`, never on `nextCursor`
155
+
156
+ `nextCursor` is derived from the last row of a page, and some endpoints return a **non-null cursor
157
+ on the final page**. The obvious loop —
158
+
159
+ ```ts
160
+ // WRONG. This can never terminate.
161
+ let cursor: string | null | undefined = undefined;
162
+ do {
163
+ const page = await posthaste.messages.list({ before: cursor });
164
+ cursor = page.nextCursor;
165
+ } while (cursor);
166
+ ```
167
+
168
+ — asks for the page after the last one, gets an empty page carrying the same cursor back, and spins
169
+ for ever. Nothing errors; it just never finishes.
170
+
171
+ `autoPaginate` encodes the correct condition, so you cannot get it wrong:
172
+
173
+ ```ts
174
+ for await (const message of posthaste.messages.autoPaginate({ status: 'bounced' })) {
175
+ console.log(message.id, message.to);
176
+ }
177
+ ```
178
+
179
+ It stops on `hasMore === false`, and also stops on an empty page or a cursor that fails to advance —
180
+ so a contract change upstream produces a short result, never an infinite loop.
181
+
182
+ For the common "give me an array" case, with a ceiling you choose:
183
+
184
+ ```ts
185
+ const recent = await posthaste.messages.listAll({ status: 'bounced' }, 500);
186
+ ```
187
+
188
+ `suppressions` has the same three methods. `domains`, `webhooks`, `apiKeys` and
189
+ `billing.invoices` are not paginated — they return `{ data }` whole, capped server-side at 100.
190
+
191
+ ---
192
+
193
+ ## Webhooks
194
+
195
+ Register an endpoint and Posthaste posts events to it as they happen. The signing secret is returned
196
+ **exactly once**, on creation, and is not retrievable afterwards.
197
+
198
+ ```ts
199
+ const hook = await posthaste.webhooks.create({
200
+ url: 'https://acme.com/hooks/posthaste',
201
+ eventTypes: ['delivered', 'bounced', 'complained'], // omit for all ten
202
+ });
203
+
204
+ await store(hook.signingSecret); // whsec_… — you cannot read it again
205
+ ```
206
+
207
+ ### Verifying a delivery
208
+
209
+ ```ts
210
+ import express from 'express';
211
+ import { verifyWebhook, DELIVERY_ID_HEADER } from '@posthaste/sdk';
212
+
213
+ const app = express();
214
+
215
+ app.post(
216
+ '/hooks/posthaste',
217
+ // THE RAW BYTES. Not express.json().
218
+ express.raw({ type: 'application/json' }),
219
+ (req, res) => {
220
+ const result = verifyWebhook(
221
+ req.body, // a Buffer
222
+ req.get('posthaste-signature'),
223
+ process.env.POSTHASTE_WEBHOOK_SECRET!,
224
+ );
225
+
226
+ if (!result.valid) {
227
+ console.warn('rejected webhook', result.reason);
228
+ return res.sendStatus(400);
229
+ }
230
+
231
+ const event = JSON.parse(req.body.toString('utf8'));
232
+
233
+ // Deduplicate on the delivery id — a retry is not a second event.
234
+ enqueue(req.get(DELIVERY_ID_HEADER), event);
235
+
236
+ // Acknowledge FAST and do the work elsewhere: we time out after 10 seconds.
237
+ return res.sendStatus(204);
238
+ },
239
+ );
240
+ ```
241
+
242
+ In Next.js the raw body is `await req.text()`; in Fastify, register the route with a raw body parser.
243
+
244
+ **Verify over the RAW BYTES.** A body that has been parsed and re-serialised will _never_ verify.
245
+ `JSON.parse` followed by `JSON.stringify` is not a byte-level round trip — key order and whitespace
246
+ are both free to change — and the signature covers the bytes we sent, not the object they decode to.
247
+ This is the single most common reason verification "mysteriously" fails, and no amount of correct key
248
+ handling rescues it.
249
+
250
+ The scheme, for reference:
251
+
252
+ ```
253
+ posthaste-signature: t=<unix seconds>,v1=<hex>
254
+ signed payload = `${t}.${rawBody}`
255
+ signature = HMAC-SHA256(secret, signed payload), lower-case hex
256
+ ```
257
+
258
+ `verifyWebhook` parses the header **by key**, not positionally (`v1=` exists so a `v2=` can be added
259
+ beside it), compares in constant time, and rejects a timestamp more than 300 seconds old **or more
260
+ than 300 seconds in the future** — a future timestamp is not clock skew to be generous about, it is
261
+ an attacker buying an unlimited replay window.
262
+
263
+ ```ts
264
+ verifyWebhook(rawBody, header, secret, { toleranceSeconds: 300 });
265
+ // -> { valid: true }
266
+ // -> { valid: false, reason: 'malformed_header' | 'unsupported_version'
267
+ // | 'timestamp_too_old' | 'timestamp_in_future'
268
+ // | 'signature_mismatch' }
269
+ ```
270
+
271
+ `parseWebhookEvent` verifies and JSON-parses in one step, returning `null` on any failure, for the
272
+ common case where a bad delivery just gets a 400.
273
+
274
+ The ten event types are `accepted`, `queued`, `attempted`, `delivered`, `deferred`, `bounced`,
275
+ `complained`, `failed`, `rejected` and `suppressed` — exported as `EVENT_TYPES`.
276
+
277
+ ---
278
+
279
+ ## Errors
280
+
281
+ Every failure — including the ones that never reached a server — throws a `PosthasteError`.
282
+
283
+ ```ts
284
+ import { PosthasteError, isPosthasteError } from '@posthaste/sdk';
285
+
286
+ try {
287
+ await posthaste.emails.send({ from, to, text });
288
+ } catch (err) {
289
+ if (!isPosthasteError(err)) throw err;
290
+
291
+ err.status; // 422 — or 0 when the request never got a response
292
+ err.type; // 'domain_not_verified'
293
+ err.message; // human-readable, safe to log
294
+ err.fields; // [{ path, message }] on SOME 400s — always check
295
+ err.retryAfterSeconds; // when the server said
296
+ err.body; // the parsed body, for the extra keys a refusal carries
297
+ }
298
+ ```
299
+
300
+ Branch on `type`, not on `status`:
301
+
302
+ ```ts
303
+ switch (err.type) {
304
+ case 'domain_not_verified':
305
+ case 'invalid_address':
306
+ case 'suppressed':
307
+ return permanent(err); // do not retry — fix the request
308
+
309
+ case 'rate_limited':
310
+ return retryAfter(err.retryAfterSeconds ?? 60); // seconds; transient
311
+
312
+ case 'daily_limit_reached':
313
+ case 'monthly_limit_reached':
314
+ return alertOps(err); // quota, not throttling. Hours or days away.
315
+
316
+ default:
317
+ return transient(err);
318
+ }
319
+ ```
320
+
321
+ Two traps this SDK handles for you, both worth knowing about:
322
+
323
+ - **`err.fields` is not always there.** It appears on the 400s produced by the shared body validator
324
+ and is absent on the several 400s that are hand-written with a message only. Never index into it
325
+ unchecked.
326
+ - **An unhandled 500 does not use the error envelope.** The API installs no custom error handler, so
327
+ a thrown exception is serialised by the framework as `{ statusCode, error: "Internal Server
328
+ Error", message }` — where `error` is a _string_. Reading `body.error.type` off that is
329
+ `undefined`, and reading `body.error.message` throws. The SDK parses it defensively, gives you the
330
+ framework's `message`, and sets `type` to `'unknown_error'` rather than inventing a documented one.
331
+ A proxy's HTML error page and an empty body are handled the same way.
332
+
333
+ Two synthetic types never sent by the server: `'connection_error'` (the request never opened) and
334
+ `'timeout'` (it opened and never answered). Both carry `status: 0`.
335
+
336
+ ---
337
+
338
+ ## Retries
339
+
340
+ The SDK retries `408`, `429` and `5xx`, and connection failures, with **exponential backoff and full
341
+ jitter** — capped at 8 seconds per wait, two retries by default. It honours `Retry-After` when the
342
+ server sends one.
343
+
344
+ Two rules make this safe rather than merely automatic.
345
+
346
+ **It branches on `error.type`, not on the status.** Three different refusals arrive as `429`:
347
+
348
+ | type | what it means | `Retry-After` | retried? |
349
+ | ----------------------- | ----------------------------------- | -------------- | -------- |
350
+ | `rate_limited` | too many requests for this key | seconds | yes |
351
+ | `daily_limit_reached` | today's warmup cap is spent | until midnight | **no** |
352
+ | `monthly_limit_reached` | the plan's monthly allowance is out | up to a month | **no** |
353
+
354
+ The last two are exhausted quota, not throttling. Retrying them in-process would hammer a wall the
355
+ calendar has to move before it opens, while burning your request-rate limit on the way. They are
356
+ raised immediately, with `err.isQuotaExhausted === true` and the wait on
357
+ `err.retryAfterSeconds`, so _you_ can decide — a queue, a delay, an alert.
358
+
359
+ A `Retry-After` longer than `maxRetryDelayMs` (60 seconds by default) is also not slept through:
360
+ blocking a request handler for fifteen minutes is indistinguishable from a hang.
361
+
362
+ **It never retries a request that repeating could duplicate.** Concretely, a send is retried **only
363
+ when you supplied an `idempotencyKey`**, and `webhooks.create` is never retried (a duplicate
364
+ endpoint would receive every event twice, for ever). Everything else — every `GET`, every `DELETE`,
365
+ `domains.create` (a duplicate is a `409`), `suppressions.create` (an upsert) — is safe to repeat and
366
+ is repeated.
367
+
368
+ Turn it all off with `maxRetries: 0`.
369
+
370
+ ---
371
+
372
+ ## API surface
373
+
374
+ ```
375
+ account.me() GET /v1/me
376
+ account.verify() GET /v1/account/verify
377
+ account.usage() GET /v1/usage
378
+
379
+ domains.create({ name }) POST /v1/domains
380
+ domains.list() GET /v1/domains
381
+ domains.verify(id) POST /v1/domains/:id/verify
382
+ domains.delete(id) DELETE /v1/domains/:id
383
+ domains.setup(id) GET /v1/domains/:id/setup
384
+ domains.connectCloudflare(id, { token }) POST /v1/domains/:id/cloudflare
385
+ domains.disconnectCloudflare() DELETE /v1/account/cloudflare
386
+
387
+ emails.send(params) POST /v1/emails
388
+
389
+ messages.list(params) GET /v1/messages
390
+ messages.autoPaginate(params) GET /v1/messages (all pages)
391
+ messages.listAll(params, maxItems) GET /v1/messages (all pages)
392
+ messages.get(id) GET /v1/messages/:id
393
+ messages.stats(params) GET /v1/stats/messages
394
+
395
+ suppressions.list(params) GET /v1/suppressions
396
+ suppressions.autoPaginate(params) GET /v1/suppressions (all pages)
397
+ suppressions.listAll(params, maxItems) GET /v1/suppressions (all pages)
398
+ suppressions.create({ address, reason }) POST /v1/suppressions
399
+ suppressions.delete(address) DELETE /v1/suppressions/:address
400
+
401
+ webhooks.create({ url, eventTypes }) POST /v1/webhooks
402
+ webhooks.list() GET /v1/webhooks
403
+ webhooks.delete(id) DELETE /v1/webhooks/:id
404
+
405
+ apiKeys.list() GET /v1/api-keys
406
+
407
+ billing.get() GET /v1/billing
408
+ billing.history() GET /v1/billing/history
409
+ billing.invoices() GET /v1/billing/invoices
410
+ billing.invoice(id) GET /v1/billing/invoices/:id
411
+ ```
412
+
413
+ ### What is deliberately not here
414
+
415
+ - **Creating and revoking API keys.** Those require a signed-in owner or admin and refuse a bearer
416
+ key outright — a server-side credential that could mint more credentials would put every narrow
417
+ key one request away from a full one. An SDK method that can only ever `403` is worse than none.
418
+ - **Anything session-only**: sign-in, checkout, plan changes, profile edits. Same reason.
419
+ - **Inbound mail** (`/v1/inbound/*`). The routes exist, but a customer cannot use them: domain setup
420
+ returns no MX record, there is no dashboard for it, and the receiving host is unset in production.
421
+ Shipping them would be a promise the platform does not currently keep.
422
+ - **`/admin/v1/*`**, the platform operator API.
423
+
424
+ ---
425
+
426
+ ## Types
427
+
428
+ Everything is exported: request params, response shapes, `MessageStatus`, `EventType`,
429
+ `SuppressionReason`, `Scope`, `PlanId`, and the error `type` union.
430
+
431
+ ```ts
432
+ import type {
433
+ Message,
434
+ MessageStatus,
435
+ SendEmailParams,
436
+ SendEmailResult,
437
+ EventType,
438
+ WebhookEvent,
439
+ PosthasteErrorType,
440
+ } from '@posthaste/sdk';
441
+ ```
442
+
443
+ Ids are prefixed strings — `msg_`, `dom_`, `whk_`, `sup_`, `key_`, `inv_`, `acct_` — and the types
444
+ returned from the API encode that (`MessageId` is `` `msg_${string}` ``). Methods **accept** plain
445
+ `string`, so an id loaded from your own database needs no cast.
446
+
447
+ `PosthasteErrorType` is a union widened with `(string & {})`: a `switch` stays useful, and a refusal
448
+ reason added after your SDK version still type-checks.
449
+
450
+ ---
451
+
452
+ ## Licence
453
+
454
+ MIT.
@@ -0,0 +1,234 @@
1
+ /**
2
+ * The client.
3
+ *
4
+ * One class, one resource object per area of the API, and every method a thin
5
+ * declaration of a URL, a method, a body and — the only interesting bit —
6
+ * whether repeating the request is safe.
7
+ *
8
+ * Scope: the API-KEY surface only. Endpoints that require a browser session
9
+ * (sign-in, checkout, profile, minting keys) are deliberately absent, because
10
+ * they refuse a Bearer token and an SDK method that can only ever 403 is worse
11
+ * than no method. `/admin/v1/*` is absent for the same reason. `/v1/inbound/*`
12
+ * is absent because a customer cannot use it — see the README.
13
+ */
14
+ import { HttpClient, type PosthasteOptions, type RequestOptions } from './http.js';
15
+ import type { Account, Billing, BillingHistory, ChainVerification, CloudflarePublishResult, ConnectCloudflareParams, CreateDomainParams, CreateSuppressionParams, CreateWebhookParams, CreatedDomain, CreatedSuppression, CreatedWebhook, Domain, DomainSetup, DomainVerification, Invoice, InvoiceDetail, List, ApiKey, ListMessagesParams, ListSuppressionsParams, Message, MessageStats, MessageStatsParams, MessageSummary, Page, SendEmailParams, SendEmailResult, Suppression, Usage, Webhook } from './types.js';
16
+ export declare class Posthaste {
17
+ private readonly http;
18
+ readonly account: AccountResource;
19
+ readonly domains: DomainsResource;
20
+ readonly emails: EmailsResource;
21
+ readonly messages: MessagesResource;
22
+ readonly suppressions: SuppressionsResource;
23
+ readonly webhooks: WebhooksResource;
24
+ readonly apiKeys: ApiKeysResource;
25
+ readonly billing: BillingResource;
26
+ constructor(options: PosthasteOptions);
27
+ }
28
+ export declare class AccountResource {
29
+ private readonly http;
30
+ constructor(http: HttpClient);
31
+ /** `GET /v1/me` — who this key belongs to, its scopes, plan and sending caps. */
32
+ me(options?: RequestOptions): Promise<Account>;
33
+ /**
34
+ * `GET /v1/account/verify` — replay the account's ENTIRE event chain.
35
+ *
36
+ * The strong claim, not the per-message one: nothing in the whole delivery
37
+ * history has been altered or removed. Expensive by design; not a health
38
+ * check to run on every request.
39
+ */
40
+ verify(options?: RequestOptions): Promise<ChainVerification>;
41
+ /** `GET /v1/usage` — the current UTC month, with the daily series behind it. */
42
+ usage(options?: RequestOptions): Promise<Usage>;
43
+ }
44
+ export declare class DomainsResource {
45
+ private readonly http;
46
+ constructor(http: HttpClient);
47
+ /**
48
+ * `POST /v1/domains` — 201 with the DNS records to publish.
49
+ *
50
+ * Safe to repeat: a second create for the same name is refused with `409
51
+ * conflict` rather than producing a second domain, so a retry after a lost
52
+ * response cannot leave duplicates behind.
53
+ */
54
+ create(params: CreateDomainParams, options?: RequestOptions): Promise<CreatedDomain>;
55
+ /** `GET /v1/domains` — every domain, newest first. Not paginated. */
56
+ list(options?: RequestOptions): Promise<List<Domain>>;
57
+ /**
58
+ * `POST /v1/domains/:id/verify` — look for the records and record the result.
59
+ *
60
+ * Branch on `verified`, not on `status`: `checks` reports SPF and DMARC too,
61
+ * and neither of them failing stops the domain being usable.
62
+ */
63
+ verify(id: string, options?: RequestOptions): Promise<DomainVerification>;
64
+ /**
65
+ * `DELETE /v1/domains/:id` — 204.
66
+ *
67
+ * Refused with `409 domain_in_use` while any message references it. That is
68
+ * deliberate: the delivery record is the product, and deleting the domain
69
+ * would take its history with it.
70
+ */
71
+ delete(id: string, options?: RequestOptions): Promise<void>;
72
+ /** `GET /v1/domains/:id/setup` — who runs this domain's DNS, and whether one-click is available. */
73
+ setup(id: string, options?: RequestOptions): Promise<DomainSetup>;
74
+ /**
75
+ * `POST /v1/domains/:id/cloudflare` — publish the DKIM record on the
76
+ * customer's behalf.
77
+ *
78
+ * Only DKIM is written. SPF and DMARC come back under `notPublished`,
79
+ * untouched, because overwriting either is worse than asking somebody to
80
+ * copy a string by hand.
81
+ *
82
+ * Safe to repeat: the record write is an upsert and the token store is an
83
+ * `on conflict do update`.
84
+ */
85
+ connectCloudflare(id: string, params?: ConnectCloudflareParams, options?: RequestOptions): Promise<CloudflarePublishResult>;
86
+ /**
87
+ * `DELETE /v1/account/cloudflare` — forget the stored Cloudflare token.
88
+ *
89
+ * Account-scoped rather than domain-scoped (one token serves every domain),
90
+ * but it lives here because it is part of the DNS story and needs
91
+ * `domains:write`. Always 204, whether a token was stored or not.
92
+ */
93
+ disconnectCloudflare(options?: RequestOptions): Promise<void>;
94
+ }
95
+ export declare class EmailsResource {
96
+ private readonly http;
97
+ constructor(http: HttpClient);
98
+ /**
99
+ * `POST /v1/emails`.
100
+ *
101
+ * TWO success statuses, and they mean different things:
102
+ *
103
+ * 202 `{ status: 'queued' }` — accepted, a new message exists.
104
+ * 200 `{ status: 'duplicate' }` — an idempotency replay. No new message was
105
+ * created; the id is the original one.
106
+ *
107
+ * Both are returned as a `SendEmailResult` with a `duplicate` boolean, rather
108
+ * than collapsed into "it worked". A caller that bills, logs or counts per
109
+ * send needs to know which of the two happened, and finding out from an HTTP
110
+ * status they never see is not a reasonable ask.
111
+ *
112
+ * RETRIES. A send is only repeated automatically when `idempotencyKey` is
113
+ * set, because without one a retry after a lost response sends the email
114
+ * twice. Setting it is the single most useful thing you can do here.
115
+ *
116
+ * IDEMPOTENCY IS A BODY FIELD. It goes in the JSON as `idempotencyKey`. The
117
+ * `Idempotency-Key` HTTP header is in the API's CORS allowlist but no handler
118
+ * reads it, so a client that sends the header and not the field gets no
119
+ * idempotency at all and no warning that it has none. This SDK never sends
120
+ * the header.
121
+ */
122
+ send(params: SendEmailParams, options?: RequestOptions): Promise<SendEmailResult>;
123
+ }
124
+ export declare class MessagesResource {
125
+ private readonly http;
126
+ constructor(http: HttpClient);
127
+ /** `GET /v1/messages` — one keyset page, newest first. */
128
+ list(params?: ListMessagesParams, options?: RequestOptions): Promise<Page<MessageSummary>>;
129
+ /**
130
+ * Every message matching the filter, across every page.
131
+ *
132
+ * Loops on `hasMore`. Never write the `while (nextCursor)` version by hand —
133
+ * see `pagination.ts` for why it does not terminate.
134
+ */
135
+ autoPaginate(params?: ListMessagesParams, options?: RequestOptions): AsyncGenerator<MessageSummary, void, undefined>;
136
+ /** Drain `autoPaginate` into an array, up to `maxItems`. */
137
+ listAll(params?: ListMessagesParams, maxItems?: number, options?: RequestOptions): Promise<MessageSummary[]>;
138
+ /**
139
+ * `GET /v1/messages/:id` — the waybill: content, every event, and the hash
140
+ * linkage, with the chain re-verified on read (`recordIntact`).
141
+ */
142
+ get(id: string, options?: RequestOptions): Promise<Message>;
143
+ /**
144
+ * `GET /v1/stats/messages` — daily volume with the previous window for
145
+ * comparison.
146
+ *
147
+ * Rates here are PERCENTAGES over settled mail (`total` minus `pending`).
148
+ * `GET /v1/usage` reports its rates as fractions over everything sent — the
149
+ * two endpoints genuinely differ, and dividing one by 100 to compare them is
150
+ * not enough.
151
+ */
152
+ stats(params?: MessageStatsParams, options?: RequestOptions): Promise<MessageStats>;
153
+ }
154
+ export declare class SuppressionsResource {
155
+ private readonly http;
156
+ constructor(http: HttpClient);
157
+ /** `GET /v1/suppressions` — one keyset page. `limit` caps at 200. */
158
+ list(params?: ListSuppressionsParams, options?: RequestOptions): Promise<Page<Suppression>>;
159
+ /** Every suppression matching the filter. Loops on `hasMore`. */
160
+ autoPaginate(params?: ListSuppressionsParams, options?: RequestOptions): AsyncGenerator<Suppression, void, undefined>;
161
+ /** Drain `autoPaginate` into an array, up to `maxItems`. */
162
+ listAll(params?: ListSuppressionsParams, maxItems?: number, options?: RequestOptions): Promise<Suppression[]>;
163
+ /**
164
+ * `POST /v1/suppressions` — 201.
165
+ *
166
+ * `reason` here is free text and is stored as the entry's DETAIL. The entry's
167
+ * own reason is always `manual`; only the platform creates `hard_bounce`,
168
+ * `complaint`, `unsubscribe` and `spam_trap` entries.
169
+ *
170
+ * Safe to repeat: the insert is `on conflict do nothing`, and re-adding an
171
+ * address answers 201 again.
172
+ */
173
+ create(params: CreateSuppressionParams, options?: RequestOptions): Promise<CreatedSuppression>;
174
+ /**
175
+ * `DELETE /v1/suppressions/:address` — 204.
176
+ *
177
+ * Addressed by ADDRESS, not by `sup_` id, and this SDK URL-encodes it for
178
+ * you. Two entries are refused: a `complaint` (422 `suppression_protected` —
179
+ * sending again is what gets an IP blocklisted) and a platform-wide entry
180
+ * (422 `suppression_platform`).
181
+ */
182
+ delete(address: string, options?: RequestOptions): Promise<void>;
183
+ }
184
+ export declare class WebhooksResource {
185
+ private readonly http;
186
+ constructor(http: HttpClient);
187
+ /**
188
+ * `POST /v1/webhooks` — 201, carrying `signingSecret`.
189
+ *
190
+ * The secret is shown exactly ONCE and is not retrievable afterwards, because
191
+ * a signing secret that can be re-read is one that anybody with a stolen API
192
+ * key can read too. Store it before you do anything else with the response.
193
+ *
194
+ * NOT auto-retried: repeating this creates a second webhook, and the
195
+ * duplicate would then receive every event twice.
196
+ */
197
+ create(params: CreateWebhookParams, options?: RequestOptions): Promise<CreatedWebhook>;
198
+ /** `GET /v1/webhooks` — newest first. Not paginated. Never includes secrets. */
199
+ list(options?: RequestOptions): Promise<List<Webhook>>;
200
+ /** `DELETE /v1/webhooks/:id` — 204, or 404 for an unknown id. */
201
+ delete(id: string, options?: RequestOptions): Promise<void>;
202
+ }
203
+ export declare class ApiKeysResource {
204
+ private readonly http;
205
+ constructor(http: HttpClient);
206
+ /**
207
+ * `GET /v1/api-keys` — the 100 most recent keys, revoked ones included.
208
+ *
209
+ * Read only, and that is the whole resource. Creating and revoking keys
210
+ * requires a signed-in owner or admin and refuses a Bearer key outright: a
211
+ * server-side credential that could mint more credentials would make every
212
+ * narrow key one request away from a full one.
213
+ */
214
+ list(options?: RequestOptions): Promise<List<ApiKey>>;
215
+ }
216
+ export declare class BillingResource {
217
+ private readonly http;
218
+ constructor(http: HttpClient);
219
+ /**
220
+ * `GET /v1/billing` — current plan, subscription, profile, recent payments
221
+ * and the full price list.
222
+ *
223
+ * All four billing reads accept `billing:read` OR `account:read`; an API key
224
+ * cannot hold `billing:read`, so in practice `account:read` is the one that
225
+ * gets you in. Money is always in minor units.
226
+ */
227
+ get(options?: RequestOptions): Promise<Billing>;
228
+ /** `GET /v1/billing/history` — the hash-chained commercial record, oldest first, plus its verification. */
229
+ history(options?: RequestOptions): Promise<BillingHistory>;
230
+ /** `GET /v1/billing/invoices` — the 100 most recent, newest first. Not paginated. */
231
+ invoices(options?: RequestOptions): Promise<List<Invoice>>;
232
+ /** `GET /v1/billing/invoices/:id` — the same document plus the supplier block. */
233
+ invoice(id: string, options?: RequestOptions): Promise<InvoiceDetail>;
234
+ }