@hostwebhook/node 1.2.0 → 1.4.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 CHANGED
@@ -92,19 +92,20 @@ Options: `verify(payload, headers, secret, { maxAgeSec: 300 })` — default 300s
92
92
 
93
93
  Send webhook payloads through HostWebhook for reliable delivery with retries and monitoring.
94
94
 
95
- ### Class-based
95
+ ### Fire & forget (default)
96
96
 
97
97
  ```ts
98
98
  import { HostWebhook } from '@hostwebhook/node';
99
99
 
100
100
  const hw = new HostWebhook({ token: 'your_ingress_token' });
101
101
 
102
+ // Returns immediately with the event ID — you don't know what happened in the pipeline
102
103
  const { eventId } = await hw.send({ event: 'order.created', orderId: '123' });
103
104
  ```
104
105
 
105
- ### Wait for pipeline result (SSE streaming)
106
+ ### Wait for pipeline result (blocking)
106
107
 
107
- Use `waitForResult` to wait for the full pipeline to complete. The SDK opens an SSE stream behind the scenes and returns the result with all pipeline steps.
108
+ Use `waitForResult` to wait for the full pipeline to complete. The SDK opens an SSE stream behind the scenes and returns the result with all pipeline steps. **This blocks until the pipeline finishes** (~120s max).
108
109
 
109
110
  ```ts
110
111
  const result = await hw.send(
@@ -117,7 +118,34 @@ console.log(result.steps); // array of pipeline steps with nodeType, statu
117
118
  console.log(result.response); // response body from target URL
118
119
  ```
119
120
 
120
- Works with any endpoint regardless of its response mode. Max wait: ~120 seconds.
121
+ ### Non-blocking result (recommended for UIs)
122
+
123
+ Use `onResult` to get the pipeline result in a background callback. `send()` returns immediately so your UI never blocks. Perfect for forms and user-facing apps.
124
+
125
+ ```ts
126
+ const { eventId } = await hw.send(
127
+ { event: 'order.created', orderId: '123' },
128
+ {
129
+ onResult: (result) => {
130
+ // Fires when the pipeline completes (via SSE in background)
131
+ if (result.awaitingApproval) {
132
+ showToast('Awaiting approval from ' + result.approvalNodeName);
133
+ } else if (result.syncStatus === 200) {
134
+ showToast('Delivered successfully');
135
+ } else {
136
+ showToast('Pipeline failed: ' + result.error);
137
+ }
138
+ },
139
+ onStep: (step) => {
140
+ // Optional: fires for each pipeline node as it executes
141
+ updateProgress(step.nodeName, step.status);
142
+ },
143
+ },
144
+ );
145
+ // UI shows "Sent!" immediately here — never blocks
146
+ ```
147
+
148
+ > **Important:** `onResult` requires the endpoint to be in **None** response mode (async). In Sync mode, the server holds the HTTP connection open until the pipeline completes, which blocks `send()` regardless of callbacks. See [Choosing the right mode](#choosing-the-right-mode) below.
121
149
 
122
150
  ### Deduplication detection
123
151
 
@@ -151,6 +179,32 @@ const { eventId } = await send(
151
179
  );
152
180
  ```
153
181
 
182
+ ## Choosing the right mode
183
+
184
+ Your endpoint has a **Response Mode** setting (None or Sync) that affects how `send()` behaves:
185
+
186
+ | Scenario | Endpoint mode | SDK option | Behavior |
187
+ |----------|--------------|------------|----------|
188
+ | **Forms / UIs** | None | `onResult` | `send()` returns instantly. Pipeline result arrives in background callback. **UI never blocks.** |
189
+ | **Backend scripts** | None | `waitForResult` | `send()` blocks until pipeline completes via SSE (~120s max). |
190
+ | **API proxy / Stripe** | Sync | *(none)* | `send()` blocks until pipeline completes via HTTP (~120s max). Result is inline in the response. |
191
+ | **Testing (Postman)** | Sync | *(none)* | Single request, response includes full pipeline result. |
192
+
193
+ > **Warning: Never use Sync mode for user-facing forms or UIs.** Sync mode holds the HTTP connection open on the server, which blocks `send()` and freezes the UI until the pipeline completes. If the pipeline has approval nodes, merge nodes, or long delays, the SDK will timeout. Use **None** mode with `onResult` instead — the user sees "Sent!" immediately and the pipeline result arrives in the background.
194
+
195
+ ### When to use Sync mode
196
+
197
+ - The caller is a **server or script**, not a human looking at a UI
198
+ - The caller **needs the pipeline result in the same HTTP request** (e.g., Stripe expects a specific response body)
199
+ - The pipeline is **fast and linear** (filter -> transform -> action) with no human-gated nodes (approval, merge)
200
+
201
+ ### When to use None mode + onResult
202
+
203
+ - The caller is a **form, UI, or frontend app**
204
+ - You want **instant feedback** ("Sent!") without waiting for the pipeline
205
+ - The pipeline has **approval nodes, merge nodes, or delays**
206
+ - You want **real-time progress** via `onStep` callbacks
207
+
154
208
  ## Configuration
155
209
 
156
210
  | Option | Type | Default | Description |
@@ -169,7 +223,9 @@ const { eventId } = await send(
169
223
  | `headers` | `Record<string, string>` | — | Per-request headers |
170
224
  | `contentType` | `string` | `application/json` | Override content type |
171
225
  | `rawBody` | `string \| Buffer` | — | Send raw body instead of JSON |
172
- | `waitForResult` | `boolean` | `false` | Wait for pipeline result via SSE stream |
226
+ | `waitForResult` | `boolean` | `false` | Wait for pipeline result via SSE stream (blocking) |
227
+ | `onResult` | `(result) => void` | — | Non-blocking pipeline result callback via SSE |
228
+ | `onStep` | `(step) => void` | — | Real-time pipeline step callback (with `onResult` or `waitForResult`) |
173
229
 
174
230
  ## Error Handling
175
231
 
package/dist/index.d.mts CHANGED
@@ -154,15 +154,23 @@ interface StaticSignature {
154
154
  }
155
155
  /** Configuration for the HostWebhook client. */
156
156
  interface HostWebhookConfig {
157
- /** Ingress token from your endpoint settings. */
158
- token: string;
157
+ /**
158
+ * API key (`hwk_...`) for authentication. Required for file uploads
159
+ * and recommended for all operations. Must have appropriate scopes.
160
+ */
161
+ apiKey?: string;
162
+ /**
163
+ * Default ingress token. When set, `send(payload)` uses this token.
164
+ * For multi-endpoint usage, pass the token per-call: `send(token, payload)`.
165
+ */
166
+ token?: string;
159
167
  /** Base URL of the HostWebhook API. Default: `https://api.hostwebhook.com` */
160
168
  baseUrl?: string;
161
169
  /**
162
170
  * HMAC-SHA256 signing secret. When set, every request is signed with the
163
171
  * HostWebhook signature format (`X-HostWebhook-Signature: t=<ts>,v1=<hmac>`).
164
172
  * Pair with `incomingSignatureType: 'hostwebhook'` on the endpoint.
165
- * @deprecated Prefer `signature` for new integrations.
173
+ * @deprecated Prefer `apiKey` for authentication.
166
174
  */
167
175
  signingSecret?: string;
168
176
  /**
@@ -271,10 +279,17 @@ declare class SendError extends Error {
271
279
  /**
272
280
  * HostWebhook client for sending webhooks through the platform.
273
281
  *
274
- * @example
282
+ * @example Single-endpoint
283
+ * ```ts
284
+ * const hw = new HostWebhook({ apiKey: 'hwk_...', token: 'ingress_token' });
285
+ * await hw.send({ event: 'order.created' });
286
+ * ```
287
+ *
288
+ * @example Multi-endpoint
275
289
  * ```ts
276
- * const hw = new HostWebhook({ token: 'your_ingress_token' });
277
- * const { eventId } = await hw.send({ event: 'order.created', orderId: '123' });
290
+ * const hw = new HostWebhook({ apiKey: 'hwk_...' });
291
+ * await hw.send('token_a', { event: 'order.created' });
292
+ * await hw.send('token_b', { event: 'user.updated' });
278
293
  * ```
279
294
  */
280
295
  declare class HostWebhook {
@@ -283,16 +298,13 @@ declare class HostWebhook {
283
298
  /**
284
299
  * Send a webhook payload through HostWebhook.
285
300
  *
286
- * The payload is JSON-stringified and POST'd to the ingress endpoint.
287
- * HostWebhook then handles delivery to the target URL with retries,
288
- * monitoring, transforms, and signing.
301
+ * @overload send(payload, options?) — uses default token from constructor
302
+ * @overload send(token, payload, options?) — specify token per call
289
303
  *
290
- * @param payload - The webhook payload (will be JSON.stringify'd)
291
- * @param options - Optional per-request configuration
292
- * @returns The event ID assigned by HostWebhook
293
304
  * @throws {SendError} If the API returns a non-2xx status
305
+ * @throws {Error} If no token is available
294
306
  */
295
- send(payload: Record<string, unknown>, options?: SendOptions): Promise<SendResult>;
307
+ send(tokenOrPayload: string | Record<string, unknown>, payloadOrOptions?: Record<string, unknown> | SendOptions, maybeOptions?: SendOptions): Promise<SendResult>;
296
308
  /**
297
309
  * Convenience: send multiple payloads in parallel.
298
310
  *
@@ -301,6 +313,33 @@ declare class HostWebhook {
301
313
  * @returns Array of results (in same order as payloads)
302
314
  */
303
315
  sendBatch(payloads: Record<string, unknown>[], options?: SendOptions): Promise<SendResult[]>;
316
+ /**
317
+ * Send a webhook with file attachments.
318
+ *
319
+ * Requires `apiKey` with `canvas:write` scope for file operations.
320
+ * Files are uploaded via presigned URLs, then references are injected into the payload.
321
+ *
322
+ * @overload sendWithFiles(payload, files, options?) — uses default token
323
+ * @overload sendWithFiles(token, payload, files, options?) — specify token per call
324
+ *
325
+ * @example
326
+ * ```ts
327
+ * const hw = new HostWebhook({ apiKey: 'hwk_...', token: 'ingress_token' });
328
+ * await hw.sendWithFiles(
329
+ * { orderId: '123' },
330
+ * { invoice: { data: pdfBuffer, fileName: 'inv.pdf', mimeType: 'application/pdf' } },
331
+ * );
332
+ * ```
333
+ */
334
+ sendWithFiles(tokenOrPayload: string | Record<string, unknown>, payloadOrFiles: Record<string, unknown> | Record<string, {
335
+ data: Buffer | Blob | Uint8Array;
336
+ fileName: string;
337
+ mimeType: string;
338
+ }>, filesOrOptions?: Record<string, {
339
+ data: Buffer | Blob | Uint8Array;
340
+ fileName: string;
341
+ mimeType: string;
342
+ }> | SendOptions, maybeOptions?: SendOptions): Promise<SendResult>;
304
343
  }
305
344
  /**
306
345
  * Standalone send function (no class instantiation needed).
package/dist/index.d.ts CHANGED
@@ -154,15 +154,23 @@ interface StaticSignature {
154
154
  }
155
155
  /** Configuration for the HostWebhook client. */
156
156
  interface HostWebhookConfig {
157
- /** Ingress token from your endpoint settings. */
158
- token: string;
157
+ /**
158
+ * API key (`hwk_...`) for authentication. Required for file uploads
159
+ * and recommended for all operations. Must have appropriate scopes.
160
+ */
161
+ apiKey?: string;
162
+ /**
163
+ * Default ingress token. When set, `send(payload)` uses this token.
164
+ * For multi-endpoint usage, pass the token per-call: `send(token, payload)`.
165
+ */
166
+ token?: string;
159
167
  /** Base URL of the HostWebhook API. Default: `https://api.hostwebhook.com` */
160
168
  baseUrl?: string;
161
169
  /**
162
170
  * HMAC-SHA256 signing secret. When set, every request is signed with the
163
171
  * HostWebhook signature format (`X-HostWebhook-Signature: t=<ts>,v1=<hmac>`).
164
172
  * Pair with `incomingSignatureType: 'hostwebhook'` on the endpoint.
165
- * @deprecated Prefer `signature` for new integrations.
173
+ * @deprecated Prefer `apiKey` for authentication.
166
174
  */
167
175
  signingSecret?: string;
168
176
  /**
@@ -271,10 +279,17 @@ declare class SendError extends Error {
271
279
  /**
272
280
  * HostWebhook client for sending webhooks through the platform.
273
281
  *
274
- * @example
282
+ * @example Single-endpoint
283
+ * ```ts
284
+ * const hw = new HostWebhook({ apiKey: 'hwk_...', token: 'ingress_token' });
285
+ * await hw.send({ event: 'order.created' });
286
+ * ```
287
+ *
288
+ * @example Multi-endpoint
275
289
  * ```ts
276
- * const hw = new HostWebhook({ token: 'your_ingress_token' });
277
- * const { eventId } = await hw.send({ event: 'order.created', orderId: '123' });
290
+ * const hw = new HostWebhook({ apiKey: 'hwk_...' });
291
+ * await hw.send('token_a', { event: 'order.created' });
292
+ * await hw.send('token_b', { event: 'user.updated' });
278
293
  * ```
279
294
  */
280
295
  declare class HostWebhook {
@@ -283,16 +298,13 @@ declare class HostWebhook {
283
298
  /**
284
299
  * Send a webhook payload through HostWebhook.
285
300
  *
286
- * The payload is JSON-stringified and POST'd to the ingress endpoint.
287
- * HostWebhook then handles delivery to the target URL with retries,
288
- * monitoring, transforms, and signing.
301
+ * @overload send(payload, options?) — uses default token from constructor
302
+ * @overload send(token, payload, options?) — specify token per call
289
303
  *
290
- * @param payload - The webhook payload (will be JSON.stringify'd)
291
- * @param options - Optional per-request configuration
292
- * @returns The event ID assigned by HostWebhook
293
304
  * @throws {SendError} If the API returns a non-2xx status
305
+ * @throws {Error} If no token is available
294
306
  */
295
- send(payload: Record<string, unknown>, options?: SendOptions): Promise<SendResult>;
307
+ send(tokenOrPayload: string | Record<string, unknown>, payloadOrOptions?: Record<string, unknown> | SendOptions, maybeOptions?: SendOptions): Promise<SendResult>;
296
308
  /**
297
309
  * Convenience: send multiple payloads in parallel.
298
310
  *
@@ -301,6 +313,33 @@ declare class HostWebhook {
301
313
  * @returns Array of results (in same order as payloads)
302
314
  */
303
315
  sendBatch(payloads: Record<string, unknown>[], options?: SendOptions): Promise<SendResult[]>;
316
+ /**
317
+ * Send a webhook with file attachments.
318
+ *
319
+ * Requires `apiKey` with `canvas:write` scope for file operations.
320
+ * Files are uploaded via presigned URLs, then references are injected into the payload.
321
+ *
322
+ * @overload sendWithFiles(payload, files, options?) — uses default token
323
+ * @overload sendWithFiles(token, payload, files, options?) — specify token per call
324
+ *
325
+ * @example
326
+ * ```ts
327
+ * const hw = new HostWebhook({ apiKey: 'hwk_...', token: 'ingress_token' });
328
+ * await hw.sendWithFiles(
329
+ * { orderId: '123' },
330
+ * { invoice: { data: pdfBuffer, fileName: 'inv.pdf', mimeType: 'application/pdf' } },
331
+ * );
332
+ * ```
333
+ */
334
+ sendWithFiles(tokenOrPayload: string | Record<string, unknown>, payloadOrFiles: Record<string, unknown> | Record<string, {
335
+ data: Buffer | Blob | Uint8Array;
336
+ fileName: string;
337
+ mimeType: string;
338
+ }>, filesOrOptions?: Record<string, {
339
+ data: Buffer | Blob | Uint8Array;
340
+ fileName: string;
341
+ mimeType: string;
342
+ }> | SendOptions, maybeOptions?: SendOptions): Promise<SendResult>;
304
343
  }
305
344
  /**
306
345
  * Standalone send function (no class instantiation needed).
package/dist/index.js CHANGED
@@ -207,24 +207,26 @@ var SendError = class extends Error {
207
207
  // src/client.ts
208
208
  var DEFAULT_BASE_URL = "https://api.hostwebhook.com";
209
209
  var DEFAULT_TIMEOUT = 1e4;
210
- var _token, _baseUrl, _signingSecret, _signature, _defaultHeaders, _timeoutMs, _HostWebhook_instances, waitForStream_fn;
210
+ var _apiKey, _token, _baseUrl, _signingSecret, _signature, _defaultHeaders, _timeoutMs, _HostWebhook_instances, doSend_fn, waitForStream_fn;
211
211
  var HostWebhook = class {
212
212
  constructor(config) {
213
213
  __privateAdd(this, _HostWebhook_instances);
214
+ __privateAdd(this, _apiKey);
214
215
  __privateAdd(this, _token);
215
216
  __privateAdd(this, _baseUrl);
216
217
  __privateAdd(this, _signingSecret);
217
218
  __privateAdd(this, _signature);
218
219
  __privateAdd(this, _defaultHeaders);
219
220
  __privateAdd(this, _timeoutMs);
220
- if (!config.token) {
221
- throw new Error("HostWebhook: token is required");
221
+ if (!config.apiKey && !config.token) {
222
+ throw new Error("HostWebhook: apiKey or token is required");
222
223
  }
223
224
  if (config.signingSecret && config.signature) {
224
225
  throw new Error(
225
226
  "HostWebhook: use either signingSecret or signature, not both"
226
227
  );
227
228
  }
229
+ __privateSet(this, _apiKey, config.apiKey);
228
230
  __privateSet(this, _token, config.token);
229
231
  __privateSet(this, _baseUrl, (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ""));
230
232
  __privateSet(this, _signingSecret, config.signingSecret);
@@ -235,91 +237,29 @@ var HostWebhook = class {
235
237
  /**
236
238
  * Send a webhook payload through HostWebhook.
237
239
  *
238
- * The payload is JSON-stringified and POST'd to the ingress endpoint.
239
- * HostWebhook then handles delivery to the target URL with retries,
240
- * monitoring, transforms, and signing.
240
+ * @overload send(payload, options?) — uses default token from constructor
241
+ * @overload send(token, payload, options?) — specify token per call
241
242
  *
242
- * @param payload - The webhook payload (will be JSON.stringify'd)
243
- * @param options - Optional per-request configuration
244
- * @returns The event ID assigned by HostWebhook
245
243
  * @throws {SendError} If the API returns a non-2xx status
244
+ * @throws {Error} If no token is available
246
245
  */
247
- async send(payload, options) {
248
- const body = options?.rawBody !== void 0 ? typeof options.rawBody === "string" ? options.rawBody : options.rawBody.toString("utf8") : JSON.stringify(payload);
249
- let tz = "";
250
- try {
251
- tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
252
- } catch {
253
- }
254
- const headers = {
255
- "Content-Type": options?.contentType ?? "application/json",
256
- "User-Agent": `HostWebhook-SDK/${"1.2.0"}`,
257
- "X-HostWebhook-Sent-At": (/* @__PURE__ */ new Date()).toISOString(),
258
- ...tz ? { "X-HostWebhook-Timezone": tz } : {},
259
- ...__privateGet(this, _defaultHeaders),
260
- ...options?.headers
261
- };
262
- if (__privateGet(this, _signature)?.type === "static") {
263
- const { secret, header } = __privateGet(this, _signature);
264
- if (header) {
265
- headers[header] = secret;
266
- } else {
267
- headers["Authorization"] = `Bearer ${secret}`;
246
+ async send(tokenOrPayload, payloadOrOptions, maybeOptions) {
247
+ let token;
248
+ let payload;
249
+ let options;
250
+ if (typeof tokenOrPayload === "string") {
251
+ token = tokenOrPayload;
252
+ payload = payloadOrOptions ?? {};
253
+ options = maybeOptions;
254
+ } else {
255
+ if (!__privateGet(this, _token)) {
256
+ throw new Error("HostWebhook: no default token set \u2014 pass token as first argument: send(token, payload)");
268
257
  }
269
- } else if (__privateGet(this, _signingSecret)) {
270
- const timestamp = Math.floor(Date.now() / 1e3);
271
- const sig = (0, import_crypto2.createHmac)("sha256", __privateGet(this, _signingSecret)).update(`${timestamp}.${body}`).digest("hex");
272
- headers["X-HostWebhook-Signature"] = `t=${timestamp},v1=${sig}`;
258
+ token = __privateGet(this, _token);
259
+ payload = tokenOrPayload;
260
+ options = payloadOrOptions;
273
261
  }
274
- const url = `${__privateGet(this, _baseUrl)}/api/in/${__privateGet(this, _token)}`;
275
- const controller = new AbortController();
276
- const timer = setTimeout(() => controller.abort(), __privateGet(this, _timeoutMs));
277
- let ingressResult;
278
- try {
279
- const res = await fetch(url, {
280
- method: "POST",
281
- headers,
282
- body,
283
- signal: controller.signal
284
- });
285
- const text = await res.text();
286
- if (!res.ok) {
287
- throw new SendError(
288
- `HostWebhook ingress error (${res.status}): ${text}`,
289
- res.status,
290
- text
291
- );
292
- }
293
- try {
294
- ingressResult = JSON.parse(text);
295
- } catch {
296
- ingressResult = { eventId: text };
297
- }
298
- } catch (err) {
299
- if (err instanceof SendError) throw err;
300
- if (err instanceof DOMException && err.name === "AbortError") {
301
- throw new SendError(
302
- `HostWebhook request timed out after ${__privateGet(this, _timeoutMs)}ms`,
303
- 0,
304
- ""
305
- );
306
- }
307
- throw err;
308
- } finally {
309
- clearTimeout(timer);
310
- }
311
- if (ingressResult.syncStatus !== void 0) {
312
- if (options?.onResult) options.onResult(ingressResult);
313
- return ingressResult;
314
- }
315
- if (options?.waitForResult && ingressResult.streamToken) {
316
- return __privateMethod(this, _HostWebhook_instances, waitForStream_fn).call(this, ingressResult, options?.onStep);
317
- }
318
- if (options?.onResult && ingressResult.streamToken) {
319
- __privateMethod(this, _HostWebhook_instances, waitForStream_fn).call(this, ingressResult, options?.onStep).then((result) => options.onResult(result)).catch(() => {
320
- });
321
- }
322
- return ingressResult;
262
+ return __privateMethod(this, _HostWebhook_instances, doSend_fn).call(this, token, payload, options);
323
263
  }
324
264
  /**
325
265
  * Convenience: send multiple payloads in parallel.
@@ -331,7 +271,74 @@ var HostWebhook = class {
331
271
  async sendBatch(payloads, options) {
332
272
  return Promise.all(payloads.map((p) => this.send(p, options)));
333
273
  }
274
+ /**
275
+ * Send a webhook with file attachments.
276
+ *
277
+ * Requires `apiKey` with `canvas:write` scope for file operations.
278
+ * Files are uploaded via presigned URLs, then references are injected into the payload.
279
+ *
280
+ * @overload sendWithFiles(payload, files, options?) — uses default token
281
+ * @overload sendWithFiles(token, payload, files, options?) — specify token per call
282
+ *
283
+ * @example
284
+ * ```ts
285
+ * const hw = new HostWebhook({ apiKey: 'hwk_...', token: 'ingress_token' });
286
+ * await hw.sendWithFiles(
287
+ * { orderId: '123' },
288
+ * { invoice: { data: pdfBuffer, fileName: 'inv.pdf', mimeType: 'application/pdf' } },
289
+ * );
290
+ * ```
291
+ */
292
+ async sendWithFiles(tokenOrPayload, payloadOrFiles, filesOrOptions, maybeOptions) {
293
+ let token;
294
+ let payload;
295
+ let files;
296
+ let options;
297
+ if (typeof tokenOrPayload === "string") {
298
+ token = tokenOrPayload;
299
+ payload = payloadOrFiles;
300
+ files = filesOrOptions ?? {};
301
+ options = maybeOptions;
302
+ } else {
303
+ if (!__privateGet(this, _token)) {
304
+ throw new Error("HostWebhook: no default token set \u2014 pass token as first argument");
305
+ }
306
+ token = __privateGet(this, _token);
307
+ payload = tokenOrPayload;
308
+ files = payloadOrFiles ?? {};
309
+ options = filesOrOptions;
310
+ }
311
+ if (!__privateGet(this, _apiKey)) {
312
+ throw new Error("HostWebhook: apiKey is required for sendWithFiles (needs canvas:write scope)");
313
+ }
314
+ const enrichedPayload = { ...payload };
315
+ for (const [fieldName, file] of Object.entries(files)) {
316
+ const uploadRes = await fetch(`${__privateGet(this, _baseUrl)}/api/files/upload-url`, {
317
+ method: "POST",
318
+ headers: {
319
+ "Content-Type": "application/json",
320
+ "Authorization": `Bearer ${__privateGet(this, _apiKey)}`
321
+ },
322
+ body: JSON.stringify({ fileName: file.fileName, mimeType: file.mimeType })
323
+ });
324
+ if (!uploadRes.ok) throw new SendError(`Failed to get upload URL for ${fieldName}`, uploadRes.status, await uploadRes.text());
325
+ const { fileId, uploadUrl, key } = await uploadRes.json();
326
+ const putRes = await fetch(uploadUrl, { method: "PUT", headers: { "Content-Type": file.mimeType }, body: file.data });
327
+ if (!putRes.ok) throw new SendError(`Failed to upload ${fieldName} to storage`, putRes.status, await putRes.text());
328
+ const confirmRes = await fetch(`${__privateGet(this, _baseUrl)}/api/files/${fileId}/confirm`, {
329
+ method: "POST",
330
+ headers: { "Authorization": `Bearer ${__privateGet(this, _apiKey)}` }
331
+ });
332
+ if (!confirmRes.ok) throw new SendError(`Failed to confirm upload for ${fieldName}`, confirmRes.status, await confirmRes.text());
333
+ const confirmed = await confirmRes.json();
334
+ enrichedPayload[fieldName] = {
335
+ _file: { id: confirmed.id, key, originalName: confirmed.originalName, mimeType: confirmed.mimeType, size: confirmed.size }
336
+ };
337
+ }
338
+ return __privateMethod(this, _HostWebhook_instances, doSend_fn).call(this, token, enrichedPayload, options);
339
+ }
334
340
  };
341
+ _apiKey = new WeakMap();
335
342
  _token = new WeakMap();
336
343
  _baseUrl = new WeakMap();
337
344
  _signingSecret = new WeakMap();
@@ -339,6 +346,88 @@ _signature = new WeakMap();
339
346
  _defaultHeaders = new WeakMap();
340
347
  _timeoutMs = new WeakMap();
341
348
  _HostWebhook_instances = new WeakSet();
349
+ doSend_fn = async function(token, payload, options) {
350
+ const body = options?.rawBody !== void 0 ? typeof options.rawBody === "string" ? options.rawBody : options.rawBody.toString("utf8") : JSON.stringify(payload);
351
+ let tz = "";
352
+ try {
353
+ tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
354
+ } catch {
355
+ }
356
+ const headers = {
357
+ "Content-Type": options?.contentType ?? "application/json",
358
+ "User-Agent": `HostWebhook-SDK/${"1.4.0"}`,
359
+ "X-HostWebhook-Sent-At": (/* @__PURE__ */ new Date()).toISOString(),
360
+ ...tz ? { "X-HostWebhook-Timezone": tz } : {},
361
+ ...__privateGet(this, _defaultHeaders),
362
+ ...options?.headers
363
+ };
364
+ if (__privateGet(this, _apiKey)) {
365
+ headers["Authorization"] = `Bearer ${__privateGet(this, _apiKey)}`;
366
+ }
367
+ if (!__privateGet(this, _apiKey)) {
368
+ if (__privateGet(this, _signature)?.type === "static") {
369
+ const { secret, header } = __privateGet(this, _signature);
370
+ if (header) {
371
+ headers[header] = secret;
372
+ } else {
373
+ headers["Authorization"] = `Bearer ${secret}`;
374
+ }
375
+ } else if (__privateGet(this, _signingSecret)) {
376
+ const timestamp = Math.floor(Date.now() / 1e3);
377
+ const sig = (0, import_crypto2.createHmac)("sha256", __privateGet(this, _signingSecret)).update(`${timestamp}.${body}`).digest("hex");
378
+ headers["X-HostWebhook-Signature"] = `t=${timestamp},v1=${sig}`;
379
+ }
380
+ }
381
+ const url = `${__privateGet(this, _baseUrl)}/api/in/${token}`;
382
+ const controller = new AbortController();
383
+ const timer = setTimeout(() => controller.abort(), __privateGet(this, _timeoutMs));
384
+ let ingressResult;
385
+ try {
386
+ const res = await fetch(url, {
387
+ method: "POST",
388
+ headers,
389
+ body,
390
+ signal: controller.signal
391
+ });
392
+ const text = await res.text();
393
+ if (!res.ok) {
394
+ throw new SendError(
395
+ `HostWebhook ingress error (${res.status}): ${text}`,
396
+ res.status,
397
+ text
398
+ );
399
+ }
400
+ try {
401
+ ingressResult = JSON.parse(text);
402
+ } catch {
403
+ ingressResult = { eventId: text };
404
+ }
405
+ } catch (err) {
406
+ if (err instanceof SendError) throw err;
407
+ if (err instanceof DOMException && err.name === "AbortError") {
408
+ throw new SendError(
409
+ `HostWebhook request timed out after ${__privateGet(this, _timeoutMs)}ms`,
410
+ 0,
411
+ ""
412
+ );
413
+ }
414
+ throw err;
415
+ } finally {
416
+ clearTimeout(timer);
417
+ }
418
+ if (ingressResult.syncStatus !== void 0) {
419
+ if (options?.onResult) options.onResult(ingressResult);
420
+ return ingressResult;
421
+ }
422
+ if (options?.waitForResult && ingressResult.streamToken) {
423
+ return __privateMethod(this, _HostWebhook_instances, waitForStream_fn).call(this, ingressResult, options?.onStep);
424
+ }
425
+ if (options?.onResult && ingressResult.streamToken) {
426
+ __privateMethod(this, _HostWebhook_instances, waitForStream_fn).call(this, ingressResult, options?.onStep).then((result) => options.onResult(result)).catch(() => {
427
+ });
428
+ }
429
+ return ingressResult;
430
+ };
342
431
  waitForStream_fn = async function(ingressResult, onStep) {
343
432
  const { eventId, streamToken } = ingressResult;
344
433
  const streamUrl = `${__privateGet(this, _baseUrl)}/api/events/${eventId}/stream?token=${streamToken}`;
package/dist/index.mjs CHANGED
@@ -174,24 +174,26 @@ var SendError = class extends Error {
174
174
  // src/client.ts
175
175
  var DEFAULT_BASE_URL = "https://api.hostwebhook.com";
176
176
  var DEFAULT_TIMEOUT = 1e4;
177
- var _token, _baseUrl, _signingSecret, _signature, _defaultHeaders, _timeoutMs, _HostWebhook_instances, waitForStream_fn;
177
+ var _apiKey, _token, _baseUrl, _signingSecret, _signature, _defaultHeaders, _timeoutMs, _HostWebhook_instances, doSend_fn, waitForStream_fn;
178
178
  var HostWebhook = class {
179
179
  constructor(config) {
180
180
  __privateAdd(this, _HostWebhook_instances);
181
+ __privateAdd(this, _apiKey);
181
182
  __privateAdd(this, _token);
182
183
  __privateAdd(this, _baseUrl);
183
184
  __privateAdd(this, _signingSecret);
184
185
  __privateAdd(this, _signature);
185
186
  __privateAdd(this, _defaultHeaders);
186
187
  __privateAdd(this, _timeoutMs);
187
- if (!config.token) {
188
- throw new Error("HostWebhook: token is required");
188
+ if (!config.apiKey && !config.token) {
189
+ throw new Error("HostWebhook: apiKey or token is required");
189
190
  }
190
191
  if (config.signingSecret && config.signature) {
191
192
  throw new Error(
192
193
  "HostWebhook: use either signingSecret or signature, not both"
193
194
  );
194
195
  }
196
+ __privateSet(this, _apiKey, config.apiKey);
195
197
  __privateSet(this, _token, config.token);
196
198
  __privateSet(this, _baseUrl, (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ""));
197
199
  __privateSet(this, _signingSecret, config.signingSecret);
@@ -202,91 +204,29 @@ var HostWebhook = class {
202
204
  /**
203
205
  * Send a webhook payload through HostWebhook.
204
206
  *
205
- * The payload is JSON-stringified and POST'd to the ingress endpoint.
206
- * HostWebhook then handles delivery to the target URL with retries,
207
- * monitoring, transforms, and signing.
207
+ * @overload send(payload, options?) — uses default token from constructor
208
+ * @overload send(token, payload, options?) — specify token per call
208
209
  *
209
- * @param payload - The webhook payload (will be JSON.stringify'd)
210
- * @param options - Optional per-request configuration
211
- * @returns The event ID assigned by HostWebhook
212
210
  * @throws {SendError} If the API returns a non-2xx status
211
+ * @throws {Error} If no token is available
213
212
  */
214
- async send(payload, options) {
215
- const body = options?.rawBody !== void 0 ? typeof options.rawBody === "string" ? options.rawBody : options.rawBody.toString("utf8") : JSON.stringify(payload);
216
- let tz = "";
217
- try {
218
- tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
219
- } catch {
220
- }
221
- const headers = {
222
- "Content-Type": options?.contentType ?? "application/json",
223
- "User-Agent": `HostWebhook-SDK/${"1.2.0"}`,
224
- "X-HostWebhook-Sent-At": (/* @__PURE__ */ new Date()).toISOString(),
225
- ...tz ? { "X-HostWebhook-Timezone": tz } : {},
226
- ...__privateGet(this, _defaultHeaders),
227
- ...options?.headers
228
- };
229
- if (__privateGet(this, _signature)?.type === "static") {
230
- const { secret, header } = __privateGet(this, _signature);
231
- if (header) {
232
- headers[header] = secret;
233
- } else {
234
- headers["Authorization"] = `Bearer ${secret}`;
213
+ async send(tokenOrPayload, payloadOrOptions, maybeOptions) {
214
+ let token;
215
+ let payload;
216
+ let options;
217
+ if (typeof tokenOrPayload === "string") {
218
+ token = tokenOrPayload;
219
+ payload = payloadOrOptions ?? {};
220
+ options = maybeOptions;
221
+ } else {
222
+ if (!__privateGet(this, _token)) {
223
+ throw new Error("HostWebhook: no default token set \u2014 pass token as first argument: send(token, payload)");
235
224
  }
236
- } else if (__privateGet(this, _signingSecret)) {
237
- const timestamp = Math.floor(Date.now() / 1e3);
238
- const sig = createHmac2("sha256", __privateGet(this, _signingSecret)).update(`${timestamp}.${body}`).digest("hex");
239
- headers["X-HostWebhook-Signature"] = `t=${timestamp},v1=${sig}`;
225
+ token = __privateGet(this, _token);
226
+ payload = tokenOrPayload;
227
+ options = payloadOrOptions;
240
228
  }
241
- const url = `${__privateGet(this, _baseUrl)}/api/in/${__privateGet(this, _token)}`;
242
- const controller = new AbortController();
243
- const timer = setTimeout(() => controller.abort(), __privateGet(this, _timeoutMs));
244
- let ingressResult;
245
- try {
246
- const res = await fetch(url, {
247
- method: "POST",
248
- headers,
249
- body,
250
- signal: controller.signal
251
- });
252
- const text = await res.text();
253
- if (!res.ok) {
254
- throw new SendError(
255
- `HostWebhook ingress error (${res.status}): ${text}`,
256
- res.status,
257
- text
258
- );
259
- }
260
- try {
261
- ingressResult = JSON.parse(text);
262
- } catch {
263
- ingressResult = { eventId: text };
264
- }
265
- } catch (err) {
266
- if (err instanceof SendError) throw err;
267
- if (err instanceof DOMException && err.name === "AbortError") {
268
- throw new SendError(
269
- `HostWebhook request timed out after ${__privateGet(this, _timeoutMs)}ms`,
270
- 0,
271
- ""
272
- );
273
- }
274
- throw err;
275
- } finally {
276
- clearTimeout(timer);
277
- }
278
- if (ingressResult.syncStatus !== void 0) {
279
- if (options?.onResult) options.onResult(ingressResult);
280
- return ingressResult;
281
- }
282
- if (options?.waitForResult && ingressResult.streamToken) {
283
- return __privateMethod(this, _HostWebhook_instances, waitForStream_fn).call(this, ingressResult, options?.onStep);
284
- }
285
- if (options?.onResult && ingressResult.streamToken) {
286
- __privateMethod(this, _HostWebhook_instances, waitForStream_fn).call(this, ingressResult, options?.onStep).then((result) => options.onResult(result)).catch(() => {
287
- });
288
- }
289
- return ingressResult;
229
+ return __privateMethod(this, _HostWebhook_instances, doSend_fn).call(this, token, payload, options);
290
230
  }
291
231
  /**
292
232
  * Convenience: send multiple payloads in parallel.
@@ -298,7 +238,74 @@ var HostWebhook = class {
298
238
  async sendBatch(payloads, options) {
299
239
  return Promise.all(payloads.map((p) => this.send(p, options)));
300
240
  }
241
+ /**
242
+ * Send a webhook with file attachments.
243
+ *
244
+ * Requires `apiKey` with `canvas:write` scope for file operations.
245
+ * Files are uploaded via presigned URLs, then references are injected into the payload.
246
+ *
247
+ * @overload sendWithFiles(payload, files, options?) — uses default token
248
+ * @overload sendWithFiles(token, payload, files, options?) — specify token per call
249
+ *
250
+ * @example
251
+ * ```ts
252
+ * const hw = new HostWebhook({ apiKey: 'hwk_...', token: 'ingress_token' });
253
+ * await hw.sendWithFiles(
254
+ * { orderId: '123' },
255
+ * { invoice: { data: pdfBuffer, fileName: 'inv.pdf', mimeType: 'application/pdf' } },
256
+ * );
257
+ * ```
258
+ */
259
+ async sendWithFiles(tokenOrPayload, payloadOrFiles, filesOrOptions, maybeOptions) {
260
+ let token;
261
+ let payload;
262
+ let files;
263
+ let options;
264
+ if (typeof tokenOrPayload === "string") {
265
+ token = tokenOrPayload;
266
+ payload = payloadOrFiles;
267
+ files = filesOrOptions ?? {};
268
+ options = maybeOptions;
269
+ } else {
270
+ if (!__privateGet(this, _token)) {
271
+ throw new Error("HostWebhook: no default token set \u2014 pass token as first argument");
272
+ }
273
+ token = __privateGet(this, _token);
274
+ payload = tokenOrPayload;
275
+ files = payloadOrFiles ?? {};
276
+ options = filesOrOptions;
277
+ }
278
+ if (!__privateGet(this, _apiKey)) {
279
+ throw new Error("HostWebhook: apiKey is required for sendWithFiles (needs canvas:write scope)");
280
+ }
281
+ const enrichedPayload = { ...payload };
282
+ for (const [fieldName, file] of Object.entries(files)) {
283
+ const uploadRes = await fetch(`${__privateGet(this, _baseUrl)}/api/files/upload-url`, {
284
+ method: "POST",
285
+ headers: {
286
+ "Content-Type": "application/json",
287
+ "Authorization": `Bearer ${__privateGet(this, _apiKey)}`
288
+ },
289
+ body: JSON.stringify({ fileName: file.fileName, mimeType: file.mimeType })
290
+ });
291
+ if (!uploadRes.ok) throw new SendError(`Failed to get upload URL for ${fieldName}`, uploadRes.status, await uploadRes.text());
292
+ const { fileId, uploadUrl, key } = await uploadRes.json();
293
+ const putRes = await fetch(uploadUrl, { method: "PUT", headers: { "Content-Type": file.mimeType }, body: file.data });
294
+ if (!putRes.ok) throw new SendError(`Failed to upload ${fieldName} to storage`, putRes.status, await putRes.text());
295
+ const confirmRes = await fetch(`${__privateGet(this, _baseUrl)}/api/files/${fileId}/confirm`, {
296
+ method: "POST",
297
+ headers: { "Authorization": `Bearer ${__privateGet(this, _apiKey)}` }
298
+ });
299
+ if (!confirmRes.ok) throw new SendError(`Failed to confirm upload for ${fieldName}`, confirmRes.status, await confirmRes.text());
300
+ const confirmed = await confirmRes.json();
301
+ enrichedPayload[fieldName] = {
302
+ _file: { id: confirmed.id, key, originalName: confirmed.originalName, mimeType: confirmed.mimeType, size: confirmed.size }
303
+ };
304
+ }
305
+ return __privateMethod(this, _HostWebhook_instances, doSend_fn).call(this, token, enrichedPayload, options);
306
+ }
301
307
  };
308
+ _apiKey = new WeakMap();
302
309
  _token = new WeakMap();
303
310
  _baseUrl = new WeakMap();
304
311
  _signingSecret = new WeakMap();
@@ -306,6 +313,88 @@ _signature = new WeakMap();
306
313
  _defaultHeaders = new WeakMap();
307
314
  _timeoutMs = new WeakMap();
308
315
  _HostWebhook_instances = new WeakSet();
316
+ doSend_fn = async function(token, payload, options) {
317
+ const body = options?.rawBody !== void 0 ? typeof options.rawBody === "string" ? options.rawBody : options.rawBody.toString("utf8") : JSON.stringify(payload);
318
+ let tz = "";
319
+ try {
320
+ tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
321
+ } catch {
322
+ }
323
+ const headers = {
324
+ "Content-Type": options?.contentType ?? "application/json",
325
+ "User-Agent": `HostWebhook-SDK/${"1.4.0"}`,
326
+ "X-HostWebhook-Sent-At": (/* @__PURE__ */ new Date()).toISOString(),
327
+ ...tz ? { "X-HostWebhook-Timezone": tz } : {},
328
+ ...__privateGet(this, _defaultHeaders),
329
+ ...options?.headers
330
+ };
331
+ if (__privateGet(this, _apiKey)) {
332
+ headers["Authorization"] = `Bearer ${__privateGet(this, _apiKey)}`;
333
+ }
334
+ if (!__privateGet(this, _apiKey)) {
335
+ if (__privateGet(this, _signature)?.type === "static") {
336
+ const { secret, header } = __privateGet(this, _signature);
337
+ if (header) {
338
+ headers[header] = secret;
339
+ } else {
340
+ headers["Authorization"] = `Bearer ${secret}`;
341
+ }
342
+ } else if (__privateGet(this, _signingSecret)) {
343
+ const timestamp = Math.floor(Date.now() / 1e3);
344
+ const sig = createHmac2("sha256", __privateGet(this, _signingSecret)).update(`${timestamp}.${body}`).digest("hex");
345
+ headers["X-HostWebhook-Signature"] = `t=${timestamp},v1=${sig}`;
346
+ }
347
+ }
348
+ const url = `${__privateGet(this, _baseUrl)}/api/in/${token}`;
349
+ const controller = new AbortController();
350
+ const timer = setTimeout(() => controller.abort(), __privateGet(this, _timeoutMs));
351
+ let ingressResult;
352
+ try {
353
+ const res = await fetch(url, {
354
+ method: "POST",
355
+ headers,
356
+ body,
357
+ signal: controller.signal
358
+ });
359
+ const text = await res.text();
360
+ if (!res.ok) {
361
+ throw new SendError(
362
+ `HostWebhook ingress error (${res.status}): ${text}`,
363
+ res.status,
364
+ text
365
+ );
366
+ }
367
+ try {
368
+ ingressResult = JSON.parse(text);
369
+ } catch {
370
+ ingressResult = { eventId: text };
371
+ }
372
+ } catch (err) {
373
+ if (err instanceof SendError) throw err;
374
+ if (err instanceof DOMException && err.name === "AbortError") {
375
+ throw new SendError(
376
+ `HostWebhook request timed out after ${__privateGet(this, _timeoutMs)}ms`,
377
+ 0,
378
+ ""
379
+ );
380
+ }
381
+ throw err;
382
+ } finally {
383
+ clearTimeout(timer);
384
+ }
385
+ if (ingressResult.syncStatus !== void 0) {
386
+ if (options?.onResult) options.onResult(ingressResult);
387
+ return ingressResult;
388
+ }
389
+ if (options?.waitForResult && ingressResult.streamToken) {
390
+ return __privateMethod(this, _HostWebhook_instances, waitForStream_fn).call(this, ingressResult, options?.onStep);
391
+ }
392
+ if (options?.onResult && ingressResult.streamToken) {
393
+ __privateMethod(this, _HostWebhook_instances, waitForStream_fn).call(this, ingressResult, options?.onStep).then((result) => options.onResult(result)).catch(() => {
394
+ });
395
+ }
396
+ return ingressResult;
397
+ };
309
398
  waitForStream_fn = async function(ingressResult, onStep) {
310
399
  const { eventId, streamToken } = ingressResult;
311
400
  const streamUrl = `${__privateGet(this, _baseUrl)}/api/events/${eventId}/stream?token=${streamToken}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hostwebhook/node",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "Official HostWebhook SDK — verify signatures, parse headers, and send webhooks",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",