@outposted/node 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/CHANGELOG.md +9 -0
- package/LICENSE +21 -0
- package/README.md +294 -0
- package/package.json +58 -0
- package/src/client.ts +91 -0
- package/src/errors.ts +214 -0
- package/src/index.ts +37 -0
- package/src/resources/api-keys.ts +157 -0
- package/src/resources/brands.ts +127 -0
- package/src/resources/connections.ts +115 -0
- package/src/resources/contents.ts +424 -0
- package/src/resources/deliveries.ts +186 -0
- package/src/resources/oauth.ts +165 -0
- package/src/resources/webhooks.ts +301 -0
- package/src/resources/workspaces.ts +70 -0
- package/src/transport.ts +198 -0
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// @outposted/node/resources/contents — Content publishing surface.
|
|
3
|
+
//
|
|
4
|
+
// A Content is one publish intent that fans out into N Jobs (one per target
|
|
5
|
+
// platform). The API contract (apps/api/.../contents) is the source of truth;
|
|
6
|
+
// shapes here mirror what the routes emit. We intentionally re-export the
|
|
7
|
+
// canonical domain types (`Content`, `ContentPayload`, `MediaItem`, etc) via
|
|
8
|
+
// type-only imports from @outposted/domain so the SDK and the API can never
|
|
9
|
+
// drift apart.
|
|
10
|
+
//
|
|
11
|
+
// Endpoints proxied here:
|
|
12
|
+
// - POST /v1/workspaces/:wsId/contents → create + dispatch
|
|
13
|
+
// - GET /v1/workspaces/:wsId/contents → list summaries
|
|
14
|
+
// - GET /v1/workspaces/:wsId/contents/:contentId → fetch one + jobs
|
|
15
|
+
// - DELETE /v1/workspaces/:wsId/contents/:contentId → cancel (idempotent)
|
|
16
|
+
// - POST /v1/workspaces/:wsId/contents/:contentId/retry → re-attempt publish
|
|
17
|
+
//
|
|
18
|
+
// Note: the API exposes cancel as `DELETE /contents/:id` (no `/cancel` suffix).
|
|
19
|
+
// The SDK still names the method `cancel(...)` because that's what consumers
|
|
20
|
+
// reach for — the HTTP verb is an implementation detail.
|
|
21
|
+
//
|
|
22
|
+
// Response shapes are unwrapped at the boundary. `list` returns
|
|
23
|
+
// `{ data, pagination }` from the API as `{ items, total }` so the caller
|
|
24
|
+
// works with a flat shape (and `total` is the only pagination field most
|
|
25
|
+
// callers need; limit/offset they passed themselves).
|
|
26
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
import type {
|
|
29
|
+
Content,
|
|
30
|
+
ContentPayload,
|
|
31
|
+
ContentStatus,
|
|
32
|
+
ContentType,
|
|
33
|
+
} from '@outposted/domain';
|
|
34
|
+
import type { HttpClient } from '../transport';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Body accepted by `ContentsResource.create`. Mirrors the POST body the API
|
|
38
|
+
* expects (`workspace_id` is taken from the URL, so we omit it here).
|
|
39
|
+
*
|
|
40
|
+
* `target_connections` is optional — the API auto-resolves a single connection
|
|
41
|
+
* per target platform when omitted. Pass an explicit list of
|
|
42
|
+
* `connected_account.id` values when a brand has ≥2 connections on a platform
|
|
43
|
+
* (otherwise the API returns 422 `connections_required`).
|
|
44
|
+
*
|
|
45
|
+
* `schedule_at` is optional ISO-8601 — omit for immediate publish.
|
|
46
|
+
*/
|
|
47
|
+
export interface ContentCreateInput {
|
|
48
|
+
brand_id: string;
|
|
49
|
+
content_type: ContentType;
|
|
50
|
+
target_platforms: string[];
|
|
51
|
+
/**
|
|
52
|
+
* Optional explicit disambiguator when a brand has multiple connections on
|
|
53
|
+
* one of the target platforms. Each entry MUST be a `connected_account.id`.
|
|
54
|
+
*/
|
|
55
|
+
target_connections?: string[];
|
|
56
|
+
content: ContentPayload;
|
|
57
|
+
/** ISO-8601 UTC timestamp. Omit for immediate publish. */
|
|
58
|
+
schedule_at?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Per-job summary embedded in the `create` and `get` responses — one row per
|
|
63
|
+
* target platform (or per target connection when disambiguated).
|
|
64
|
+
*/
|
|
65
|
+
export interface ContentJobSummary {
|
|
66
|
+
/** Job id in the create-response shape uses `job_id`; we expose it as `id` here. */
|
|
67
|
+
job_id: string;
|
|
68
|
+
platform: string;
|
|
69
|
+
status: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Per-slot upload descriptor returned by `create` when the request opted into
|
|
74
|
+
* direct-to-R2 large-media upload (`media[i].upload === true`). Clients PUT
|
|
75
|
+
* raw bytes to `upload_url`; the worker takes over once every slot lands.
|
|
76
|
+
*/
|
|
77
|
+
export interface ContentUploadSlot {
|
|
78
|
+
position: number;
|
|
79
|
+
r2_key: string;
|
|
80
|
+
upload_url: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Response from `ContentsResource.create`. The API returns ONE of two shapes
|
|
85
|
+
* depending on whether the request opted into direct large-media upload:
|
|
86
|
+
*
|
|
87
|
+
* - `awaiting_media` branch: the client must PUT bytes to each
|
|
88
|
+
* `uploads[i].upload_url`; jobs are dispatched once every slot lands.
|
|
89
|
+
* - `queued` / `scheduled` branch: jobs were dispatched immediately (or
|
|
90
|
+
* scheduled for `scheduled_at`).
|
|
91
|
+
*
|
|
92
|
+
* Both shapes share `content_id`, `status`, and `created_at`. The remaining
|
|
93
|
+
* fields are split via a discriminated union so TypeScript can narrow on
|
|
94
|
+
* `status`.
|
|
95
|
+
*/
|
|
96
|
+
export type ContentCreateResponse =
|
|
97
|
+
| {
|
|
98
|
+
content_id: string;
|
|
99
|
+
status: 'awaiting_media';
|
|
100
|
+
created_at: string;
|
|
101
|
+
uploads: ContentUploadSlot[];
|
|
102
|
+
}
|
|
103
|
+
| {
|
|
104
|
+
content_id: string;
|
|
105
|
+
status: 'queued' | 'scheduled';
|
|
106
|
+
created_at: string;
|
|
107
|
+
scheduled_at?: string;
|
|
108
|
+
jobs: ContentJobSummary[];
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Bandwidth-conscious summary returned by `list` — no full payload. Use `get`
|
|
113
|
+
* for the full payload + nested jobs.
|
|
114
|
+
*
|
|
115
|
+
* Mirrors `@outposted/domain` `ContentSummary`.
|
|
116
|
+
*/
|
|
117
|
+
export interface ContentSummary {
|
|
118
|
+
id: string;
|
|
119
|
+
brand_id: string;
|
|
120
|
+
content_type: ContentType;
|
|
121
|
+
status: ContentStatus;
|
|
122
|
+
target_platforms: string[];
|
|
123
|
+
jobs_count: number;
|
|
124
|
+
created_at: string;
|
|
125
|
+
scheduled_at: string | null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Filters accepted by `ContentsResource.list`.
|
|
130
|
+
*/
|
|
131
|
+
export interface ContentListQuery {
|
|
132
|
+
status?: ContentStatus;
|
|
133
|
+
brand_id?: string;
|
|
134
|
+
limit?: number;
|
|
135
|
+
offset?: number;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Detail shape returned by `get(contentId)`. The API rebuilds the payload from
|
|
140
|
+
* the persisted row and attaches the per-platform jobs; we surface that exactly
|
|
141
|
+
* as-is. Note this is a different shape from the canonical `Content` entity
|
|
142
|
+
* (which has no embedded `jobs`) — the API enriches it for clients.
|
|
143
|
+
*/
|
|
144
|
+
export interface ContentDetailJob {
|
|
145
|
+
id: string;
|
|
146
|
+
platform_slug: string;
|
|
147
|
+
provider_slug: string;
|
|
148
|
+
status: string;
|
|
149
|
+
external_post_id: string | null;
|
|
150
|
+
external_post_url: string | null;
|
|
151
|
+
error_code: string | null;
|
|
152
|
+
error_message: string | null;
|
|
153
|
+
attempt_count: number;
|
|
154
|
+
queued_at: string;
|
|
155
|
+
completed_at: string | null;
|
|
156
|
+
poll_count: number | null;
|
|
157
|
+
processing_ms: number | null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Full content detail returned by `get(contentId)`. Note that this is a wire
|
|
162
|
+
* shape — it overlaps with the domain `Content` entity but adds `jobs`,
|
|
163
|
+
* `completed_at`, and optional `media`/`media_progress`. The `payload` is the
|
|
164
|
+
* publishable union of fields any platform might consume; see
|
|
165
|
+
* `ContentPayload` for the exhaustive list.
|
|
166
|
+
*/
|
|
167
|
+
export interface ContentDetail
|
|
168
|
+
extends Pick<
|
|
169
|
+
Content,
|
|
170
|
+
'id' | 'brand_id' | 'content_type' | 'status' | 'target_platforms' | 'created_at' | 'scheduled_at'
|
|
171
|
+
> {
|
|
172
|
+
payload: ContentPayload;
|
|
173
|
+
completed_at: string | null;
|
|
174
|
+
jobs: ContentDetailJob[];
|
|
175
|
+
media_progress?: { uploaded: number; total: number };
|
|
176
|
+
media?: Array<{
|
|
177
|
+
position: number;
|
|
178
|
+
media_type: 'image' | 'video';
|
|
179
|
+
status: string;
|
|
180
|
+
uploaded_at: string | null;
|
|
181
|
+
}>;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Cancel response shape. The API returns the same envelope for both the
|
|
186
|
+
* "actually cancelled now" and the idempotent "already cancelled" cases —
|
|
187
|
+
* callers can treat both as success.
|
|
188
|
+
*/
|
|
189
|
+
export interface ContentCancelResponse {
|
|
190
|
+
id: string;
|
|
191
|
+
status: 'cancelled';
|
|
192
|
+
scheduled_at: string | null;
|
|
193
|
+
cancelled_at: string | null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Retry response shape. `retried_jobs` is the number of non-`published` jobs
|
|
198
|
+
* that were reset to `queued` (already-published jobs are left untouched).
|
|
199
|
+
*/
|
|
200
|
+
export interface ContentRetryResponse {
|
|
201
|
+
content_id: string;
|
|
202
|
+
status: 'queued';
|
|
203
|
+
retried_jobs: number;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
interface ContentListEnvelope {
|
|
207
|
+
data: ContentSummary[];
|
|
208
|
+
pagination: { limit: number; offset: number; total: number };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* SDK accessor for the `/v1/workspaces/:wsId/contents` collection. The core
|
|
213
|
+
* publishing surface of the SDK — `create` is what most callers exercise.
|
|
214
|
+
*
|
|
215
|
+
* @example
|
|
216
|
+
* ```ts
|
|
217
|
+
* const outposted = new Outposted({ apiKey: process.env.OUTPOSTED_API_KEY! });
|
|
218
|
+
* const result = await outposted.contents.create('ws_123', {
|
|
219
|
+
* brand_id: 'brand_abc',
|
|
220
|
+
* content_type: 'post',
|
|
221
|
+
* target_platforms: ['instagram'],
|
|
222
|
+
* content: {
|
|
223
|
+
* caption: 'Hello from the SDK',
|
|
224
|
+
* media: [{ type: 'image', url: 'https://cdn.example.com/cover.jpg' }],
|
|
225
|
+
* },
|
|
226
|
+
* });
|
|
227
|
+
* ```
|
|
228
|
+
*/
|
|
229
|
+
export class ContentsResource {
|
|
230
|
+
constructor(private readonly http: HttpClient) {}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Create a Content and dispatch a publish (or schedule it). For
|
|
234
|
+
* `content_type: 'post'` on Instagram with an image URL, the API queues a
|
|
235
|
+
* single job and returns 202 with the job descriptor.
|
|
236
|
+
*
|
|
237
|
+
* Pass an `Idempotency-Key` header at the transport level if you need
|
|
238
|
+
* exactly-once semantics across retries (V0 only honours the API-side
|
|
239
|
+
* `Idempotency-Key`; the SDK does not auto-generate one).
|
|
240
|
+
*
|
|
241
|
+
* @example curl
|
|
242
|
+
* ```bash
|
|
243
|
+
* curl -X POST -H "Authorization: Bearer $OUTPOSTED_API_KEY" \
|
|
244
|
+
* -H "Content-Type: application/json" \
|
|
245
|
+
* -d '{
|
|
246
|
+
* "brand_id": "brand_abc",
|
|
247
|
+
* "content_type": "post",
|
|
248
|
+
* "target_platforms": ["instagram"],
|
|
249
|
+
* "content": {
|
|
250
|
+
* "caption": "Hello from curl",
|
|
251
|
+
* "media": [{ "type": "image", "url": "https://cdn.example.com/cover.jpg" }]
|
|
252
|
+
* }
|
|
253
|
+
* }' \
|
|
254
|
+
* https://api.outposted.one/api/v1/workspaces/ws_123/contents
|
|
255
|
+
* ```
|
|
256
|
+
*
|
|
257
|
+
* @example SDK (Instagram image post)
|
|
258
|
+
* ```ts
|
|
259
|
+
* const result = await outposted.contents.create('ws_123', {
|
|
260
|
+
* brand_id: 'brand_abc',
|
|
261
|
+
* content_type: 'post',
|
|
262
|
+
* target_platforms: ['instagram'],
|
|
263
|
+
* content: {
|
|
264
|
+
* caption: 'Hello from the SDK',
|
|
265
|
+
* media: [{ type: 'image', url: 'https://cdn.example.com/cover.jpg' }],
|
|
266
|
+
* },
|
|
267
|
+
* });
|
|
268
|
+
* if (result.status !== 'awaiting_media') {
|
|
269
|
+
* console.log(`queued ${result.jobs.length} job(s)`);
|
|
270
|
+
* }
|
|
271
|
+
* ```
|
|
272
|
+
*
|
|
273
|
+
* @example SDK (multi-connection disambiguation)
|
|
274
|
+
* ```ts
|
|
275
|
+
* await outposted.contents.create('ws_123', {
|
|
276
|
+
* brand_id: 'brand_abc',
|
|
277
|
+
* content_type: 'post',
|
|
278
|
+
* target_platforms: ['linkedin'],
|
|
279
|
+
* target_connections: ['conn_personal', 'conn_company'],
|
|
280
|
+
* content: { text: 'Cross-posted to both LinkedIn surfaces' },
|
|
281
|
+
* });
|
|
282
|
+
* ```
|
|
283
|
+
*
|
|
284
|
+
* Throws `OutpostedValidationError` (HTTP 400/422) when payload shape is
|
|
285
|
+
* invalid, content-type/platform matrix is violated, per-platform specs
|
|
286
|
+
* fail, or multiple connections are available without an explicit
|
|
287
|
+
* `target_connections`.
|
|
288
|
+
*/
|
|
289
|
+
async create(
|
|
290
|
+
workspaceId: string,
|
|
291
|
+
input: ContentCreateInput,
|
|
292
|
+
): Promise<ContentCreateResponse> {
|
|
293
|
+
return this.http.request<ContentCreateResponse>({
|
|
294
|
+
method: 'POST',
|
|
295
|
+
path: `/api/v1/workspaces/${encodeURIComponent(workspaceId)}/contents`,
|
|
296
|
+
body: input,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* List contents in a workspace, newest first. Returns lightweight summaries
|
|
302
|
+
* (no full payload) — the API caps `limit` at 100 (default 50) and supports
|
|
303
|
+
* `status` / `brand_id` filters.
|
|
304
|
+
*
|
|
305
|
+
* Returns `{ items, total }` — the API envelope is unwrapped here so callers
|
|
306
|
+
* don't have to dig through `{ data, pagination }`.
|
|
307
|
+
*
|
|
308
|
+
* @example curl
|
|
309
|
+
* ```bash
|
|
310
|
+
* curl -H "Authorization: Bearer $OUTPOSTED_API_KEY" \
|
|
311
|
+
* "https://api.outposted.one/api/v1/workspaces/ws_123/contents?status=published&limit=20"
|
|
312
|
+
* ```
|
|
313
|
+
*
|
|
314
|
+
* @example SDK
|
|
315
|
+
* ```ts
|
|
316
|
+
* const { items, total } = await outposted.contents.list('ws_123', {
|
|
317
|
+
* status: 'published',
|
|
318
|
+
* limit: 20,
|
|
319
|
+
* });
|
|
320
|
+
* console.log(`${items.length} of ${total}`);
|
|
321
|
+
* ```
|
|
322
|
+
*/
|
|
323
|
+
async list(
|
|
324
|
+
workspaceId: string,
|
|
325
|
+
query?: ContentListQuery,
|
|
326
|
+
): Promise<{ items: ContentSummary[]; total: number }> {
|
|
327
|
+
const response = await this.http.request<ContentListEnvelope>({
|
|
328
|
+
method: 'GET',
|
|
329
|
+
path: `/api/v1/workspaces/${encodeURIComponent(workspaceId)}/contents`,
|
|
330
|
+
query: query
|
|
331
|
+
? {
|
|
332
|
+
status: query.status,
|
|
333
|
+
brand_id: query.brand_id,
|
|
334
|
+
limit: query.limit,
|
|
335
|
+
offset: query.offset,
|
|
336
|
+
}
|
|
337
|
+
: undefined,
|
|
338
|
+
});
|
|
339
|
+
return { items: response.data, total: response.pagination.total };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Fetch a single content by id, including the rebuilt publishable payload
|
|
344
|
+
* and the nested per-platform jobs. The primary status-polling endpoint for
|
|
345
|
+
* V0 — webhook delivery (E2) lets you avoid polling in most flows.
|
|
346
|
+
*
|
|
347
|
+
* Throws `OutpostedNotFoundError` (HTTP 404) for missing or cross-tenant
|
|
348
|
+
* content ids (the API does not leak existence across workspaces).
|
|
349
|
+
*
|
|
350
|
+
* @example curl
|
|
351
|
+
* ```bash
|
|
352
|
+
* curl -H "Authorization: Bearer $OUTPOSTED_API_KEY" \
|
|
353
|
+
* https://api.outposted.one/api/v1/workspaces/ws_123/contents/cnt_abc
|
|
354
|
+
* ```
|
|
355
|
+
*
|
|
356
|
+
* @example SDK
|
|
357
|
+
* ```ts
|
|
358
|
+
* const content = await outposted.contents.get('ws_123', 'cnt_abc');
|
|
359
|
+
* console.log(content.status, content.jobs.length);
|
|
360
|
+
* ```
|
|
361
|
+
*/
|
|
362
|
+
async get(workspaceId: string, contentId: string): Promise<ContentDetail> {
|
|
363
|
+
return this.http.request<ContentDetail>({
|
|
364
|
+
method: 'GET',
|
|
365
|
+
path: `/api/v1/workspaces/${encodeURIComponent(workspaceId)}/contents/${encodeURIComponent(contentId)}`,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Cancel a queued or scheduled content. Idempotent — a second cancel on an
|
|
371
|
+
* already-cancelled content returns success (the API never 404s here).
|
|
372
|
+
*
|
|
373
|
+
* The underlying HTTP verb is `DELETE` (RESTful cancel semantics in the
|
|
374
|
+
* API); the SDK exposes it as `cancel(...)` because that's what consumers
|
|
375
|
+
* expect to call. Content in `publishing` returns 409
|
|
376
|
+
* `cancel_too_late` — surfaced here as `OutpostedConflictError`.
|
|
377
|
+
*
|
|
378
|
+
* @example curl
|
|
379
|
+
* ```bash
|
|
380
|
+
* curl -X DELETE -H "Authorization: Bearer $OUTPOSTED_API_KEY" \
|
|
381
|
+
* https://api.outposted.one/api/v1/workspaces/ws_123/contents/cnt_abc
|
|
382
|
+
* ```
|
|
383
|
+
*
|
|
384
|
+
* @example SDK
|
|
385
|
+
* ```ts
|
|
386
|
+
* await outposted.contents.cancel('ws_123', 'cnt_abc');
|
|
387
|
+
* ```
|
|
388
|
+
*/
|
|
389
|
+
async cancel(workspaceId: string, contentId: string): Promise<ContentCancelResponse> {
|
|
390
|
+
return this.http.request<ContentCancelResponse>({
|
|
391
|
+
method: 'DELETE',
|
|
392
|
+
path: `/api/v1/workspaces/${encodeURIComponent(workspaceId)}/contents/${encodeURIComponent(contentId)}`,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Re-attempt publishing a content that failed, partially failed, or got
|
|
398
|
+
* stuck in `publishing` past the in-flight window. No media re-upload —
|
|
399
|
+
* the persisted payload (R2 URLs included) is reused.
|
|
400
|
+
*
|
|
401
|
+
* Only non-`published` jobs are reset; the response's `retried_jobs` is the
|
|
402
|
+
* count that were actually re-enqueued. Returns 409 `not_retryable` (via
|
|
403
|
+
* `OutpostedConflictError`) for terminal-success or actively-publishing
|
|
404
|
+
* content.
|
|
405
|
+
*
|
|
406
|
+
* @example curl
|
|
407
|
+
* ```bash
|
|
408
|
+
* curl -X POST -H "Authorization: Bearer $OUTPOSTED_API_KEY" \
|
|
409
|
+
* https://api.outposted.one/api/v1/workspaces/ws_123/contents/cnt_abc/retry
|
|
410
|
+
* ```
|
|
411
|
+
*
|
|
412
|
+
* @example SDK
|
|
413
|
+
* ```ts
|
|
414
|
+
* const { retried_jobs } = await outposted.contents.retry('ws_123', 'cnt_abc');
|
|
415
|
+
* console.log(`re-queued ${retried_jobs} job(s)`);
|
|
416
|
+
* ```
|
|
417
|
+
*/
|
|
418
|
+
async retry(workspaceId: string, contentId: string): Promise<ContentRetryResponse> {
|
|
419
|
+
return this.http.request<ContentRetryResponse>({
|
|
420
|
+
method: 'POST',
|
|
421
|
+
path: `/api/v1/workspaces/${encodeURIComponent(workspaceId)}/contents/${encodeURIComponent(contentId)}/retry`,
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// @outposted/node/resources/deliveries — Webhook delivery inspection + retry.
|
|
3
|
+
//
|
|
4
|
+
// A WebhookDelivery is one attempt to POST a signed payload at a webhook
|
|
5
|
+
// endpoint. Each endpoint accumulates a delivery history (one row per
|
|
6
|
+
// outbound POST) which the customer inspects to debug receiver failures and
|
|
7
|
+
// manually re-queue stuck ones.
|
|
8
|
+
//
|
|
9
|
+
// Endpoints proxied here (see apps/api/src/app/api/v1/...):
|
|
10
|
+
// - GET /v1/workspaces/:wsId/webhooks/:id/deliveries
|
|
11
|
+
// → list deliveries for an endpoint, paginated, filterable by status
|
|
12
|
+
// + since/until ISO windows. API envelope is `{ data, total, limit,
|
|
13
|
+
// offset }`; we map `data` → `items` for symmetry with the rest of
|
|
14
|
+
// the SDK (every list method returns `{ items, ... }`).
|
|
15
|
+
// - POST /v1/workspaces/:wsId/deliveries/:deliveryId/redeliver
|
|
16
|
+
// → re-queue a delivery for another attempt. Typically used on
|
|
17
|
+
// `failed` or `dlq` rows — the original payload and signing key are
|
|
18
|
+
// reused, so the receiver sees a byte-identical retry (same body,
|
|
19
|
+
// new timestamp/signature). 202 Accepted.
|
|
20
|
+
//
|
|
21
|
+
// Errors are surfaced as the typed OutpostedError subclasses produced by the
|
|
22
|
+
// transport layer (e.g. OutpostedNotFoundError on 404).
|
|
23
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
import type { WebhookDelivery, WebhookDeliveryStatus } from '@outposted/domain';
|
|
26
|
+
import type { HttpClient } from '../transport';
|
|
27
|
+
|
|
28
|
+
/** Query params accepted by `DeliveriesResource.list`. */
|
|
29
|
+
export interface DeliveriesListQuery {
|
|
30
|
+
/** Filter by terminal/in-flight state. Omit for "all statuses". */
|
|
31
|
+
status?: WebhookDeliveryStatus;
|
|
32
|
+
/** ISO-8601 datetime — only deliveries created at-or-after this instant. */
|
|
33
|
+
since?: string;
|
|
34
|
+
/** ISO-8601 datetime — only deliveries created at-or-before this instant. */
|
|
35
|
+
until?: string;
|
|
36
|
+
/** Page size, 1..100 (API default: 25). */
|
|
37
|
+
limit?: number;
|
|
38
|
+
/** Page offset, >=0 (API default: 0). */
|
|
39
|
+
offset?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Paginated list of webhook deliveries. The `items` field is named `data` on
|
|
44
|
+
* the API wire — we rename it here so every SDK list method has a uniform
|
|
45
|
+
* `{ items, total, limit, offset }` shape.
|
|
46
|
+
*/
|
|
47
|
+
export interface DeliveriesListResult {
|
|
48
|
+
items: WebhookDelivery[];
|
|
49
|
+
total: number;
|
|
50
|
+
limit: number;
|
|
51
|
+
offset: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Response shape returned by `POST .../redeliver` (HTTP 202). */
|
|
55
|
+
export interface DeliveryRedeliverResult {
|
|
56
|
+
ok: boolean;
|
|
57
|
+
delivery_id: string;
|
|
58
|
+
/** Lifecycle hint — typically `"queued"` once the workflow trigger lands. */
|
|
59
|
+
status: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface DeliveriesListWireResponse {
|
|
63
|
+
data: WebhookDelivery[];
|
|
64
|
+
total: number;
|
|
65
|
+
limit: number;
|
|
66
|
+
offset: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* SDK accessor for webhook delivery introspection and manual retry.
|
|
71
|
+
*
|
|
72
|
+
* @example
|
|
73
|
+
* ```ts
|
|
74
|
+
* const outposted = new Outposted({ apiKey: process.env.OUTPOSTED_API_KEY! });
|
|
75
|
+
*
|
|
76
|
+
* // Inspect the last 25 failed deliveries for an endpoint
|
|
77
|
+
* const { items } = await outposted.deliveries.list('ws_123', 'wh_456', {
|
|
78
|
+
* status: 'failed',
|
|
79
|
+
* });
|
|
80
|
+
*
|
|
81
|
+
* // Force a retry on a single delivery
|
|
82
|
+
* await outposted.deliveries.redeliver('ws_123', items[0]!.id);
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
export class DeliveriesResource {
|
|
86
|
+
constructor(private readonly http: HttpClient) {}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* List webhook deliveries for an endpoint, newest first.
|
|
90
|
+
*
|
|
91
|
+
* Supports status filtering plus a `since`/`until` ISO-8601 window for
|
|
92
|
+
* targeted post-incident audits. Pagination is offset-based; the response
|
|
93
|
+
* always echoes `limit`/`offset` so callers can drive a "load more"
|
|
94
|
+
* scroller without tracking client-side state.
|
|
95
|
+
*
|
|
96
|
+
* @example curl
|
|
97
|
+
* ```bash
|
|
98
|
+
* curl -H "Authorization: Bearer $OUTPOSTED_API_KEY" \
|
|
99
|
+
* "https://api.outposted.one/api/v1/workspaces/ws_123/webhooks/wh_456/deliveries?status=failed&limit=50"
|
|
100
|
+
* ```
|
|
101
|
+
*
|
|
102
|
+
* @example SDK
|
|
103
|
+
* ```ts
|
|
104
|
+
* const page = await outposted.deliveries.list('ws_123', 'wh_456', {
|
|
105
|
+
* status: 'dlq',
|
|
106
|
+
* since: '2026-06-01T00:00:00.000Z',
|
|
107
|
+
* limit: 50,
|
|
108
|
+
* });
|
|
109
|
+
* for (const delivery of page.items) {
|
|
110
|
+
* console.log(delivery.id, delivery.last_response_status);
|
|
111
|
+
* }
|
|
112
|
+
* ```
|
|
113
|
+
*
|
|
114
|
+
* Throws `OutpostedNotFoundError` (404) if the endpoint id does not exist
|
|
115
|
+
* in the workspace, or `OutpostedValidationError` (400) if the query
|
|
116
|
+
* parameters are malformed (bad ISO date, status out of enum, etc.).
|
|
117
|
+
*/
|
|
118
|
+
async list(
|
|
119
|
+
workspaceId: string,
|
|
120
|
+
webhookId: string,
|
|
121
|
+
query?: DeliveriesListQuery,
|
|
122
|
+
): Promise<DeliveriesListResult> {
|
|
123
|
+
const path = `/api/v1/workspaces/${encodeURIComponent(workspaceId)}/webhooks/${encodeURIComponent(webhookId)}/deliveries`;
|
|
124
|
+
const response = await this.http.request<DeliveriesListWireResponse>({
|
|
125
|
+
method: 'GET',
|
|
126
|
+
path,
|
|
127
|
+
query: {
|
|
128
|
+
status: query?.status,
|
|
129
|
+
since: query?.since,
|
|
130
|
+
until: query?.until,
|
|
131
|
+
limit: query?.limit,
|
|
132
|
+
offset: query?.offset,
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
return {
|
|
136
|
+
items: response.data,
|
|
137
|
+
total: response.total,
|
|
138
|
+
limit: response.limit,
|
|
139
|
+
offset: response.offset,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Force a new delivery attempt for a previously-attempted delivery.
|
|
145
|
+
*
|
|
146
|
+
* Typically used on `failed` or `dlq` rows after the receiver was fixed —
|
|
147
|
+
* the original signed payload is reused, so the customer receives a
|
|
148
|
+
* byte-identical retry (only the `t=` timestamp in `X-Outposted-Signature`
|
|
149
|
+
* changes, by design, since signatures bind the timestamp). The API
|
|
150
|
+
* returns 202 immediately; the actual POST runs asynchronously on the
|
|
151
|
+
* delivery worker.
|
|
152
|
+
*
|
|
153
|
+
* @example curl
|
|
154
|
+
* ```bash
|
|
155
|
+
* curl -X POST -H "Authorization: Bearer $OUTPOSTED_API_KEY" \
|
|
156
|
+
* https://api.outposted.one/api/v1/workspaces/ws_123/deliveries/del_789/redeliver
|
|
157
|
+
* ```
|
|
158
|
+
*
|
|
159
|
+
* @example SDK
|
|
160
|
+
* ```ts
|
|
161
|
+
* // After fixing a receiver, replay every dlq delivery from yesterday
|
|
162
|
+
* const { items } = await outposted.deliveries.list('ws_123', 'wh_456', {
|
|
163
|
+
* status: 'dlq',
|
|
164
|
+
* since: '2026-06-02T00:00:00.000Z',
|
|
165
|
+
* until: '2026-06-03T00:00:00.000Z',
|
|
166
|
+
* });
|
|
167
|
+
* for (const delivery of items) {
|
|
168
|
+
* await outposted.deliveries.redeliver('ws_123', delivery.id);
|
|
169
|
+
* }
|
|
170
|
+
* ```
|
|
171
|
+
*
|
|
172
|
+
* Throws `OutpostedNotFoundError` (404) if the delivery id does not exist
|
|
173
|
+
* in the workspace, or `OutpostedServerError` (500) if the upstream queue
|
|
174
|
+
* (QStash) is misconfigured or rejected the trigger.
|
|
175
|
+
*/
|
|
176
|
+
async redeliver(
|
|
177
|
+
workspaceId: string,
|
|
178
|
+
deliveryId: string,
|
|
179
|
+
): Promise<DeliveryRedeliverResult> {
|
|
180
|
+
const path = `/api/v1/workspaces/${encodeURIComponent(workspaceId)}/deliveries/${encodeURIComponent(deliveryId)}/redeliver`;
|
|
181
|
+
return this.http.request<DeliveryRedeliverResult>({
|
|
182
|
+
method: 'POST',
|
|
183
|
+
path,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|