@posthaste/sdk 0.1.0 → 0.3.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.
@@ -1,40 +1,82 @@
1
1
  /**
2
2
  * Keyset pagination.
3
3
  *
4
- * The API pages by cursor rather than by offset: `limit` + `before`, answering
5
- * with `{ data, hasMore, nextCursor }`. `before` is the id of the last row you
6
- * received, which is what `nextCursor` hands you.
7
- *
8
- * THE TRAP THIS MODULE EXISTS TO CLOSE
9
- *
10
- * The obvious loop is `while (nextCursor) { … }`. It is wrong. `nextCursor` is
11
- * derived from the last row of a page, and at least one endpoint on this API
12
- * returns a non-null cursor on its final page — the documentation calls it out
13
- * for `/v1/inbound/messages`. A cursor loop over such an endpoint asks for the
14
- * page after the last one, gets an empty page with the same cursor back, and
15
- * spins forever. Nothing about it looks wrong in a log; it just never finishes.
16
- *
17
- * `hasMore` is the server's actual answer to "is there another page", computed
18
- * by fetching one row more than you asked for. It is the only correct
19
- * condition, so `autoPaginate` is the API this SDK puts in front of people —
20
- * `list` is still there for anyone who wants a single page.
4
+ * The API pages by cursor rather than by offset. Most lists take `limit` +
5
+ * `before` and answer `{ data, hasMore, nextCursor }` or `{ data, nextCursor }`,
6
+ * where `before` is the id of the last row you received — which is what
7
+ * `nextCursor` hands you.
8
+ *
9
+ * THE TWO TRAPS THIS MODULE EXISTS TO CLOSE
10
+ *
11
+ * They are mirror images, which is why neither `hasMore` nor `nextCursor` alone
12
+ * is a correct stopping rule across the whole API:
13
+ *
14
+ * 1. `while (nextCursor)` spins for ever on `/v1/inbound/messages`. Its cursor
15
+ * is derived from the last row of the page and is set whenever the page has
16
+ * any rows at all, including on the final one. A loop over it asks for the
17
+ * page after the last, gets an empty page carrying the same cursor, and
18
+ * never finishes. Nothing about it looks wrong in a log.
19
+ *
20
+ * 2. `if (!page.hasMore) return` stops after ONE page on `/v1/domains`,
21
+ * `/v1/webhooks`, `/v1/api-keys`, `/v1/inbound/addresses`,
22
+ * `/v1/billing/invoices` and `/v1/billing/history`. Those endpoints send no
23
+ * `hasMore` at all, so the read is `undefined`, which is falsy, which looks
24
+ * exactly like "that was everything". This is the failure that shipped: an
25
+ * account with sixty invoices got fifty of them and no indication that ten
26
+ * were missing.
27
+ *
28
+ * So the walker consults BOTH, and treats each as authoritative only where the
29
+ * server actually sends it: stop when `hasMore` is explicitly `false`, and stop
30
+ * when the cursor is absent or fails to advance. Every endpoint is covered by at
31
+ * least one of those, and no endpoint is stopped early by either.
32
+ */
33
+ /**
34
+ * The least a response has to be for the walker to walk it.
35
+ *
36
+ * `hasMore` is optional here on purpose — that is the actual contract, and
37
+ * making it optional in the type is what forces every read of it to be an
38
+ * explicit `=== false` rather than a truthiness test.
39
+ */
40
+ export interface WalkablePage<T> {
41
+ data: T[];
42
+ nextCursor: string | null;
43
+ hasMore?: boolean;
44
+ }
45
+ export type PageFetcher<TItem, TParams> = (params: TParams) => Promise<WalkablePage<TItem>>;
46
+ /**
47
+ * Which request parameter carries the cursor.
48
+ *
49
+ * `before` everywhere except `/v1/billing/history`, which reads forward from a
50
+ * sequence number in `after`.
51
+ */
52
+ export type CursorParam = 'before' | 'after';
53
+ /**
54
+ * The cursor-bearing shape of a list's request parameters.
55
+ *
56
+ * Deliberately not `PaginationParams`: `/v1/billing/history` has no `before` at
57
+ * all, so a constraint written in terms of it would exclude the one endpoint
58
+ * whose cursor convention most needs a helper. Every field is optional, so any
59
+ * of the SDK's `List*Params` interfaces satisfies it structurally.
21
60
  */
22
- import type { Page, PaginationParams } from './types.js';
23
- export type PageFetcher<TItem, TParams> = (params: TParams) => Promise<Page<TItem>>;
61
+ export interface CursorParams {
62
+ limit?: number;
63
+ before?: string;
64
+ after?: string | number;
65
+ }
24
66
  /**
25
67
  * Walk every page and yield every item.
26
68
  *
27
69
  * Belt and braces, because an infinite loop in someone's job runner is a bad
28
- * way to learn about a contract change:
70
+ * way to learn about a contract change, and a silent truncation is a worse one:
29
71
  *
30
- * - stops on `hasMore === false`, always;
72
+ * - stops when the server says `hasMore: false`, on the endpoints that say it;
31
73
  * - stops if a page comes back empty, because there is nothing to advance to;
74
+ * - stops when there is no next cursor, which is how the endpoints without
75
+ * `hasMore` signal the end;
32
76
  * - stops if the cursor does not MOVE, which is what a non-null terminal
33
77
  * cursor looks like from here.
34
78
  */
35
- export declare function autoPaginate<TItem extends {
36
- id: string;
37
- }, TParams extends PaginationParams>(fetchPage: PageFetcher<TItem, TParams>, params: TParams): AsyncGenerator<TItem, void, undefined>;
79
+ export declare function autoPaginate<TItem, TParams extends CursorParams>(fetchPage: PageFetcher<TItem, TParams>, params: TParams, cursorParam?: CursorParam): AsyncGenerator<TItem, void, undefined>;
38
80
  /**
39
81
  * Collect an auto-paginated stream into an array.
40
82
  *
@@ -1,54 +1,86 @@
1
1
  /**
2
2
  * Keyset pagination.
3
3
  *
4
- * The API pages by cursor rather than by offset: `limit` + `before`, answering
5
- * with `{ data, hasMore, nextCursor }`. `before` is the id of the last row you
6
- * received, which is what `nextCursor` hands you.
4
+ * The API pages by cursor rather than by offset. Most lists take `limit` +
5
+ * `before` and answer `{ data, hasMore, nextCursor }` or `{ data, nextCursor }`,
6
+ * where `before` is the id of the last row you received — which is what
7
+ * `nextCursor` hands you.
7
8
  *
8
- * THE TRAP THIS MODULE EXISTS TO CLOSE
9
+ * THE TWO TRAPS THIS MODULE EXISTS TO CLOSE
9
10
  *
10
- * The obvious loop is `while (nextCursor) { }`. It is wrong. `nextCursor` is
11
- * derived from the last row of a page, and at least one endpoint on this API
12
- * returns a non-null cursor on its final page — the documentation calls it out
13
- * for `/v1/inbound/messages`. A cursor loop over such an endpoint asks for the
14
- * page after the last one, gets an empty page with the same cursor back, and
15
- * spins forever. Nothing about it looks wrong in a log; it just never finishes.
11
+ * They are mirror images, which is why neither `hasMore` nor `nextCursor` alone
12
+ * is a correct stopping rule across the whole API:
16
13
  *
17
- * `hasMore` is the server's actual answer to "is there another page", computed
18
- * by fetching one row more than you asked for. It is the only correct
19
- * condition, so `autoPaginate` is the API this SDK puts in front of people
20
- * `list` is still there for anyone who wants a single page.
14
+ * 1. `while (nextCursor)` spins for ever on `/v1/inbound/messages`. Its cursor
15
+ * is derived from the last row of the page and is set whenever the page has
16
+ * any rows at all, including on the final one. A loop over it asks for the
17
+ * page after the last, gets an empty page carrying the same cursor, and
18
+ * never finishes. Nothing about it looks wrong in a log.
19
+ *
20
+ * 2. `if (!page.hasMore) return` stops after ONE page on `/v1/domains`,
21
+ * `/v1/webhooks`, `/v1/api-keys`, `/v1/inbound/addresses`,
22
+ * `/v1/billing/invoices` and `/v1/billing/history`. Those endpoints send no
23
+ * `hasMore` at all, so the read is `undefined`, which is falsy, which looks
24
+ * exactly like "that was everything". This is the failure that shipped: an
25
+ * account with sixty invoices got fifty of them and no indication that ten
26
+ * were missing.
27
+ *
28
+ * So the walker consults BOTH, and treats each as authoritative only where the
29
+ * server actually sends it: stop when `hasMore` is explicitly `false`, and stop
30
+ * when the cursor is absent or fails to advance. Every endpoint is covered by at
31
+ * least one of those, and no endpoint is stopped early by either.
21
32
  */
22
33
  /**
23
34
  * Walk every page and yield every item.
24
35
  *
25
36
  * Belt and braces, because an infinite loop in someone's job runner is a bad
26
- * way to learn about a contract change:
37
+ * way to learn about a contract change, and a silent truncation is a worse one:
27
38
  *
28
- * - stops on `hasMore === false`, always;
39
+ * - stops when the server says `hasMore: false`, on the endpoints that say it;
29
40
  * - stops if a page comes back empty, because there is nothing to advance to;
41
+ * - stops when there is no next cursor, which is how the endpoints without
42
+ * `hasMore` signal the end;
30
43
  * - stops if the cursor does not MOVE, which is what a non-null terminal
31
44
  * cursor looks like from here.
32
45
  */
33
- export async function* autoPaginate(fetchPage, params) {
34
- let before = params.before;
46
+ export async function* autoPaginate(fetchPage, params, cursorParam = 'before') {
47
+ let cursor = params[cursorParam];
35
48
  let seen;
36
49
  for (;;) {
37
- const page = await fetchPage({ ...params, before });
50
+ const page = await fetchPage({ ...params, [cursorParam]: cursor });
38
51
  for (const item of page.data)
39
52
  yield item;
40
- if (!page.hasMore)
53
+ // `=== false`, never `!page.hasMore`: the endpoints that omit the field
54
+ // must fall through to the cursor check rather than be stopped here.
55
+ if (page.hasMore === false)
41
56
  return;
42
57
  if (page.data.length === 0)
43
58
  return;
44
- // Prefer the server's cursor; fall back to the last row's id, which is what
45
- // the cursor is anyway. Either way the NEXT request must ask for something
46
- // different from the last one.
47
- const next = page.nextCursor ?? page.data[page.data.length - 1]?.id;
48
- if (!next || next === seen)
59
+ /*
60
+ * Prefer the server's cursor. Fall back to the last row's id which is
61
+ * what the cursor is anyway on every `before`-paged list — but ONLY when
62
+ * the server has affirmatively said there is another page.
63
+ *
64
+ * That condition is load-bearing, not caution. On the endpoints with no
65
+ * `hasMore`, a null `nextCursor` IS the end-of-list signal; an unconditional
66
+ * fallback would quietly replace it with the last row's id, and the walker
67
+ * would re-request the final page before the "cursor did not move" guard
68
+ * caught it. The fallback exists only for the opposite case — a page that
69
+ * claims `hasMore: true` and sends no cursor — so it is scoped to exactly
70
+ * that.
71
+ *
72
+ * Rows on `/v1/billing/history` have a `seq` and no `id`, so there is
73
+ * nothing to fall back to there in any case. Correct rather than lucky:
74
+ * that endpoint's `nextCursor` is null exactly on the last page.
75
+ */
76
+ const last = page.data[page.data.length - 1];
77
+ const next = page.nextCursor ?? (page.hasMore === true ? last?.id : undefined);
78
+ // Either way the NEXT request must ask for something different from the
79
+ // last one.
80
+ if (next == null || next === seen)
49
81
  return;
50
82
  seen = next;
51
- before = next;
83
+ cursor = next;
52
84
  }
53
85
  }
54
86
  /**
package/dist/types.d.ts CHANGED
@@ -12,16 +12,17 @@
12
12
  */
13
13
  import type { AccountId, ApiKeyId, DomainId, InvoiceId, MessageId, PaymentId, SubscriptionId, SuppressionId, WebhookId } from './ids.js';
14
14
  /** `message_status` in the database. The lifecycle a message can be in. */
15
- export declare const MESSAGE_STATUSES: readonly ['queued', 'sending', 'delivered', 'bounced', 'complained', 'failed', 'rejected'];
15
+ export declare const MESSAGE_STATUSES: readonly ['queued', 'sending', 'delivered', 'bounced', 'complained', 'failed', 'scheduled', 'canceled', 'rejected'];
16
16
  export type MessageStatus = (typeof MESSAGE_STATUSES)[number];
17
17
  /**
18
- * The ten webhook event types (`event_type` in the database).
18
+ * The webhook event types (`event_type` in the database).
19
19
  *
20
20
  * Note that these are NOT the same set as `MessageStatus`: `accepted`,
21
21
  * `attempted`, `deferred` and `suppressed` are things that happen to a message
22
- * without changing the status it rests in.
22
+ * without changing the status it rests in. The `schedule_*` pair and
23
+ * `scheduled` narrate a scheduled message's life before it enters delivery.
23
24
  */
24
- export declare const EVENT_TYPES: readonly ['accepted', 'queued', 'attempted', 'delivered', 'deferred', 'bounced', 'complained', 'failed', 'rejected', 'suppressed'];
25
+ export declare const EVENT_TYPES: readonly ['accepted', 'queued', 'attempted', 'delivered', 'deferred', 'bounced', 'complained', 'failed', 'rejected', 'suppressed', 'scheduled', 'schedule_canceled', 'schedule_failed'];
25
26
  export type EventType = (typeof EVENT_TYPES)[number];
26
27
  /**
27
28
  * `suppression_reason` in the database.
@@ -54,25 +55,65 @@ export type KeyEnvironment = 'live' | 'test';
54
55
  export declare const SCOPES: readonly ['account:read', 'domains:read', 'domains:write', 'emails:send', 'messages:read', 'suppressions:read', 'suppressions:write', 'webhooks:read', 'webhooks:write'];
55
56
  export type Scope = (typeof SCOPES)[number];
56
57
  /**
57
- * A keyset page.
58
+ * A keyset page carrying a cursor and nothing else.
58
59
  *
59
- * `hasMore` is the ONLY correct loop condition. `nextCursor` is populated from
60
- * the last row of a full page, and there are endpoints and edge cases where it
61
- * is non-null on the final page — a `while (nextCursor)` loop over those never
62
- * terminates. `autoPaginate` on each resource encodes the right condition so
63
- * this cannot be got wrong by hand.
60
+ * This is the shape of every list that was RETROFITTED with pagination:
61
+ * `/v1/domains`, `/v1/webhooks`, `/v1/api-keys`, `/v1/inbound/addresses`,
62
+ * `/v1/billing/invoices` and `/v1/billing/history`. They answer
63
+ * `{ nextCursor, data }` and send no `hasMore`.
64
+ *
65
+ * On these, `nextCursor` IS the loop condition and it is trustworthy: the
66
+ * handler asks the database for one row more than you did and sets the cursor
67
+ * only when that extra row came back, so it is null exactly when the page you
68
+ * are holding is the last one.
69
+ *
70
+ * Kept as a type of its own rather than folded into `Page` with an optional
71
+ * `hasMore`, because the two are mirror images and confusing them is the whole
72
+ * bug class. On a `Page` the server's `hasMore` is authoritative and the cursor
73
+ * is not; here there is no `hasMore` to consult at all — and code that reads
74
+ * `page.hasMore` off one of these gets `undefined`, treats it as "no more", and
75
+ * stops after the first page. That is the silent truncation this split exists
76
+ * to make unwritable.
64
77
  */
65
- export interface Page<T> {
78
+ export interface CursorPage<T> {
66
79
  data: T[];
67
- hasMore: boolean;
68
80
  nextCursor: string | null;
69
81
  }
70
- /** A list that is not paginated. Returned whole. */
71
- export interface List<T> {
72
- data: T[];
82
+ /**
83
+ * A keyset page that also carries the server's own answer to "is there more?".
84
+ *
85
+ * Sent by `/v1/messages`, `/v1/suppressions` and `/v1/inbound/messages` — the
86
+ * lists that were paginated from the start.
87
+ *
88
+ * Here `hasMore` is the ONLY correct loop condition. `nextCursor` is populated
89
+ * from the last row of a page, and `/v1/inbound/messages` returns a non-null
90
+ * cursor on its final page — a `while (nextCursor)` loop over that endpoint
91
+ * never terminates. `autoPaginate` on each resource encodes the right condition
92
+ * so neither half of this can be got wrong by hand.
93
+ */
94
+ export interface Page<T> extends CursorPage<T> {
95
+ hasMore: boolean;
73
96
  }
97
+ /**
98
+ * @deprecated `List` meant "a list the API returns WHOLE, with no cursor".
99
+ * There is no such list any more — every one of them now pages, and a caller
100
+ * that treats the first response as complete silently loses everything past the
101
+ * first page. It is an alias of {@link CursorPage} so existing imports keep
102
+ * compiling; read `nextCursor`, or use the resource's `autoPaginate`.
103
+ */
104
+ export type List<T> = CursorPage<T>;
105
+ /**
106
+ * The cursor convention shared by every list except `/v1/billing/history`,
107
+ * which reads FORWARD from a sequence number — see
108
+ * {@link ListBillingHistoryParams}.
109
+ */
74
110
  export interface PaginationParams {
75
- /** Page size. Messages cap at 100, suppressions at 200. */
111
+ /**
112
+ * Page size. The bound and the default are per-endpoint: 1–100 default 25 for
113
+ * messages and inbound messages, 1–200 default 50 for suppressions, 1–100
114
+ * default 50 for webhooks, API keys, inbound addresses and invoices, and
115
+ * 1–100 default 100 for domains.
116
+ */
76
117
  limit?: number;
77
118
  /** The `nextCursor` from the previous page. */
78
119
  before?: string;
@@ -154,6 +195,16 @@ export interface DnsRecord {
154
195
  /** Present when publishing the record carries a risk (SPF, DMARC). */
155
196
  warning?: string;
156
197
  }
198
+ export interface ListDomainsParams extends PaginationParams {
199
+ /**
200
+ * 1–100, default 100.
201
+ *
202
+ * The default is the maximum here, so the first page is every domain on all
203
+ * but the largest accounts — but it is a page, not the whole list, and past
204
+ * a hundred domains `nextCursor` is how you reach the rest.
205
+ */
206
+ limit?: number;
207
+ }
157
208
  export interface CreateDomainParams {
158
209
  name: string;
159
210
  }
@@ -237,7 +288,29 @@ export interface CloudflarePublishResult {
237
288
  }
238
289
  export interface SendEmailParams {
239
290
  from: string;
240
- to: string;
291
+ /**
292
+ * One address, or up to 50 across `to`, `cc` and `bcc` combined.
293
+ *
294
+ * `to: 'x'` and `to: ['x']` are the same request. Every recipient becomes
295
+ * its own message with its own delivery record and its own bounce
296
+ * attribution — which is also why every recipient counts as one send
297
+ * against your allowance. See `cc`.
298
+ */
299
+ to: string | string[];
300
+ /**
301
+ * Named on the other copies, and charged like any other recipient.
302
+ *
303
+ * A Cc is not a free rider on somebody else's message: it is delivered
304
+ * separately, so a message to one `to` and two `cc` costs three.
305
+ */
306
+ cc?: string[];
307
+ /**
308
+ * Delivered, and named nowhere.
309
+ *
310
+ * A blind copy is its own message that lists no other recipient — there is
311
+ * no Bcc header on anything we send. Charged like the others.
312
+ */
313
+ bcc?: string[];
241
314
  subject?: string;
242
315
  text?: string;
243
316
  html?: string;
@@ -253,6 +326,45 @@ export interface SendEmailParams {
253
326
  * retry; without it a send is never retried automatically.
254
327
  */
255
328
  idempotencyKey?: string;
329
+ /**
330
+ * Up to 10 files, at most 10 MiB combined (decoded). `content` is the raw
331
+ * bytes — pass a `Uint8Array`/`Buffer` and the SDK base64-encodes it, or a
332
+ * string that is ALREADY base64 (it is sent as-is, never double-encoded).
333
+ * Executable types (`.exe`, `.js`, `.bat`, …) are refused by the API with
334
+ * `attachment_type_blocked` — the big mailbox providers bounce them anyway.
335
+ * For an inline image referenced from the html as `<img src="cid:logo">`,
336
+ * set `disposition: 'inline'` and `cid: 'logo'`.
337
+ */
338
+ attachments?: SendAttachment[];
339
+ /**
340
+ * Send later — ISO-8601 with an offset (what `Date.toISOString()` makes).
341
+ * At most 7 days ahead; within 60 seconds is treated as an immediate send.
342
+ * Quota is decided on the SEND day, and the message is cancellable with
343
+ * `emails.cancelSchedule(id)` until it is released.
344
+ */
345
+ scheduledAt?: string;
346
+ }
347
+ export interface SendAttachment {
348
+ /** Shown to the recipient. No path separators; 255 chars max. */
349
+ filename: string;
350
+ /** `type/subtype` only — no parameters. */
351
+ contentType: string;
352
+ /** Raw bytes, or a string already base64-encoded. */
353
+ content: Uint8Array | string;
354
+ disposition?: 'attachment' | 'inline';
355
+ /** Content-ID for inline parts, referenced as `cid:` from the html. */
356
+ cid?: string;
357
+ }
358
+ /** What happened to one recipient of a multi-recipient send. */
359
+ export interface SentCopy {
360
+ /** Absent when the recipient was skipped. */
361
+ id?: MessageId;
362
+ to: string;
363
+ /** Which field the address was taken from, after de-duplication. */
364
+ kind: 'to' | 'cc' | 'bcc';
365
+ status: 'queued' | 'scheduled' | 'duplicate' | 'suppressed';
366
+ /** Why it was skipped: `hard_bounce`, `complaint`, `spam_trap`, `manual`. */
367
+ reason?: string;
256
368
  }
257
369
  export interface SendEmailResult {
258
370
  id: MessageId;
@@ -261,7 +373,7 @@ export interface SendEmailResult {
261
373
  * (HTTP 200). Both are success, and both carry the id of the one real
262
374
  * message.
263
375
  */
264
- status: 'queued' | 'duplicate';
376
+ status: 'queued' | 'scheduled' | 'duplicate';
265
377
  /**
266
378
  * True when this request matched an earlier one by `idempotencyKey` and no
267
379
  * new message was created. Surfaced rather than hidden, because "we did
@@ -269,6 +381,30 @@ export interface SendEmailResult {
269
381
  * answer from "accepted".
270
382
  */
271
383
  duplicate: boolean;
384
+ /** Echoed back for a scheduled send. */
385
+ scheduledAt?: string;
386
+ /**
387
+ * Ties the copies of a multi-recipient send together. Absent for a single
388
+ * recipient, which has nothing to be grouped with.
389
+ */
390
+ groupId?: string;
391
+ /**
392
+ * Every requested recipient, exactly once — present only when there was more
393
+ * than one. `id` is the FIRST To copy and is kept so code reading
394
+ * `{ id, status }` still works, but this array is what is authoritative.
395
+ */
396
+ emails?: SentCopy[];
397
+ /**
398
+ * Recipients that were skipped because they are suppressed.
399
+ *
400
+ * Repeated here as well as in `emails` deliberately: a recipient we did not
401
+ * send to is the one outcome that must be impossible to miss. A suppressed
402
+ * address is never sent to and never charged.
403
+ */
404
+ suppressed?: {
405
+ to: string;
406
+ reason: string;
407
+ }[];
272
408
  }
273
409
  export interface ListMessagesParams extends PaginationParams {
274
410
  /** Max 100, default 25. */
@@ -310,9 +446,26 @@ export interface MessageContent {
310
446
  raw: string | null;
311
447
  replyTo: string | null;
312
448
  headers: Record<string, string>;
449
+ /**
450
+ * Metadata only — download the bytes with `emails.downloadAttachment`.
451
+ * Empty array for a message sent without attachments; the whole `content`
452
+ * object is null once the body ages out of retention, attachments with it.
453
+ */
454
+ attachments: AttachmentMeta[];
455
+ }
456
+ export interface AttachmentMeta {
457
+ /** Bare uuid, used in the download URL. */
458
+ id: string;
459
+ filename: string;
460
+ contentType: string;
461
+ disposition: 'attachment' | 'inline' | (string & {});
462
+ cid: string | null;
463
+ sizeBytes: number;
313
464
  }
314
465
  export interface Message {
315
466
  id: MessageId;
467
+ /** Present on anything that was ever scheduled; `status` says whether it still is. */
468
+ scheduledAt?: string;
316
469
  /** The hash chain over this message's own events was re-verified on read. */
317
470
  recordIntact: boolean;
318
471
  /** Present only when `recordIntact` is false. */
@@ -417,6 +570,10 @@ export interface CreatedSuppression {
417
570
  address: string;
418
571
  reason: 'manual';
419
572
  }
573
+ export interface ListWebhooksParams extends PaginationParams {
574
+ /** 1–100, default 50. */
575
+ limit?: number;
576
+ }
420
577
  export interface CreateWebhookParams {
421
578
  /** http or https only. */
422
579
  url: string;
@@ -453,6 +610,10 @@ export interface WebhookEvent {
453
610
  detail?: Record<string, unknown>;
454
611
  };
455
612
  }
613
+ export interface ListApiKeysParams extends PaginationParams {
614
+ /** 1–100, default 50. Revoked keys are listed too and count towards it. */
615
+ limit?: number;
616
+ }
456
617
  export interface ApiKey {
457
618
  id: ApiKeyId;
458
619
  name: string;
@@ -549,14 +710,57 @@ export interface BillingEvent {
549
710
  occurredAt: string;
550
711
  hash: string;
551
712
  }
552
- export interface BillingHistory {
553
- /** The chain replayed in the database, so the record can be checked. */
713
+ /**
714
+ * `/v1/billing/history` pages by SEQUENCE NUMBER, and forwards.
715
+ *
716
+ * It is the one list on this API that does not use `limit` + `before`, and the
717
+ * difference is not cosmetic. The billing record is a hash chain, a chain reads
718
+ * forward from its start, and the order it renders in has to be the order it
719
+ * verifies in — so this endpoint is ascending, oldest first, and resumes from
720
+ * `after` rather than `before`. Everything else on the API is newest-first and
721
+ * resumes from a row id.
722
+ *
723
+ * Two things follow that a caller cannot guess:
724
+ *
725
+ * - `after` is a `BillingEvent.seq` — a number — not a `bil_`-style id. The
726
+ * server rejects anything that is not a non-negative integer with `400
727
+ * invalid_request`.
728
+ * - The page you get back is the events AFTER that sequence number, so the
729
+ * natural resume value is the `seq` of the last event you have processed.
730
+ * `nextCursor` already is exactly that, as a string.
731
+ */
732
+ export interface ListBillingHistoryParams {
733
+ /** 1–200, default 100. Wider than the rest of the API, because it is a ledger. */
734
+ limit?: number;
735
+ /**
736
+ * A sequence number, NOT an id. Returns the events whose `seq` is strictly
737
+ * greater. `nextCursor` from the previous page is the value to pass.
738
+ */
739
+ after?: string | number;
740
+ }
741
+ export interface BillingHistory extends CursorPage<BillingEvent> {
742
+ /**
743
+ * The chain replayed in the database, so the record can be checked.
744
+ *
745
+ * Verified over the WHOLE account history, not over the page — so it means
746
+ * the same thing on page four as on page one, and paging does not weaken it.
747
+ */
554
748
  chain: {
555
749
  valid: boolean;
556
750
  brokenAt: number | null;
557
751
  reason: string | null;
558
752
  };
753
+ /** Oldest first — the opposite of every other list. */
559
754
  data: BillingEvent[];
755
+ /**
756
+ * The `seq` of the last event on this page, as a string, or `null` when this
757
+ * is the last page. Pass it as `after`.
758
+ */
759
+ nextCursor: string | null;
760
+ }
761
+ export interface ListInvoicesParams extends PaginationParams {
762
+ /** 1–100, default 50. */
763
+ limit?: number;
560
764
  }
561
765
  export interface Invoice {
562
766
  id: InvoiceId;
package/dist/types.js CHANGED
@@ -21,14 +21,17 @@ export const MESSAGE_STATUSES = [
21
21
  'bounced',
22
22
  'complained',
23
23
  'failed',
24
+ 'scheduled',
25
+ 'canceled',
24
26
  'rejected',
25
27
  ];
26
28
  /**
27
- * The ten webhook event types (`event_type` in the database).
29
+ * The webhook event types (`event_type` in the database).
28
30
  *
29
31
  * Note that these are NOT the same set as `MessageStatus`: `accepted`,
30
32
  * `attempted`, `deferred` and `suppressed` are things that happen to a message
31
- * without changing the status it rests in.
33
+ * without changing the status it rests in. The `schedule_*` pair and
34
+ * `scheduled` narrate a scheduled message's life before it enters delivery.
32
35
  */
33
36
  export const EVENT_TYPES = [
34
37
  'accepted',
@@ -41,6 +44,9 @@ export const EVENT_TYPES = [
41
44
  'failed',
42
45
  'rejected',
43
46
  'suppressed',
47
+ 'scheduled',
48
+ 'schedule_canceled',
49
+ 'schedule_failed',
44
50
  ];
45
51
  /**
46
52
  * `suppression_reason` in the database.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@posthaste/sdk",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Official TypeScript SDK for the Posthaste transactional email API.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {