@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 +454 -0
- package/dist/client.d.ts +234 -0
- package/dist/client.js +459 -0
- package/dist/errors.d.ts +100 -0
- package/dist/errors.js +154 -0
- package/dist/http.d.ts +150 -0
- package/dist/http.js +258 -0
- package/dist/ids.d.ts +25 -0
- package/dist/ids.js +15 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +14 -0
- package/dist/pagination.d.ts +45 -0
- package/dist/pagination.js +69 -0
- package/dist/types.d.ts +593 -0
- package/dist/types.js +76 -0
- package/dist/webhooks.d.ts +70 -0
- package/dist/webhooks.js +130 -0
- package/package.json +44 -0
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.
|
package/dist/client.d.ts
ADDED
|
@@ -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
|
+
}
|