oilpriceapi 0.7.0 → 0.9.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.
Files changed (81) hide show
  1. package/README.md +244 -30
  2. package/dist/cjs/client.js +610 -0
  3. package/dist/cjs/errors.js +80 -0
  4. package/dist/cjs/index.js +96 -0
  5. package/dist/cjs/package.json +1 -0
  6. package/dist/cjs/resources/alerts.js +387 -0
  7. package/dist/cjs/resources/analytics.js +188 -0
  8. package/dist/cjs/resources/bunker-fuels.js +210 -0
  9. package/dist/cjs/resources/commodities.js +115 -0
  10. package/dist/cjs/resources/data-quality.js +144 -0
  11. package/dist/cjs/resources/data-sources.js +298 -0
  12. package/dist/cjs/resources/diesel.js +119 -0
  13. package/dist/cjs/resources/drilling.js +269 -0
  14. package/dist/cjs/resources/ei/drilling-productivity.js +108 -0
  15. package/dist/cjs/resources/ei/forecasts.js +106 -0
  16. package/dist/cjs/resources/ei/frac-focus.js +165 -0
  17. package/dist/cjs/resources/ei/index.js +98 -0
  18. package/dist/cjs/resources/ei/oil-inventories.js +97 -0
  19. package/dist/cjs/resources/ei/opec-production.js +97 -0
  20. package/dist/cjs/resources/ei/rig-counts.js +93 -0
  21. package/dist/cjs/resources/ei/well-permits.js +136 -0
  22. package/dist/cjs/resources/forecasts.js +168 -0
  23. package/dist/cjs/resources/futures.js +424 -0
  24. package/dist/cjs/resources/indicators.js +79 -0
  25. package/dist/cjs/resources/raw.js +128 -0
  26. package/dist/cjs/resources/rig-counts.js +164 -0
  27. package/dist/cjs/resources/spreads.js +105 -0
  28. package/dist/cjs/resources/storage.js +166 -0
  29. package/dist/cjs/resources/streaming.js +350 -0
  30. package/dist/cjs/resources/webhooks.js +283 -0
  31. package/dist/cjs/types.js +2 -0
  32. package/dist/cjs/version.js +24 -0
  33. package/dist/client.d.ts +130 -3
  34. package/dist/client.js +206 -30
  35. package/dist/errors.d.ts +6 -0
  36. package/dist/errors.js +25 -16
  37. package/dist/index.d.ts +28 -5
  38. package/dist/index.js +29 -1
  39. package/dist/resources/alerts.js +31 -77
  40. package/dist/resources/analytics.d.ts +147 -214
  41. package/dist/resources/analytics.js +104 -141
  42. package/dist/resources/bunker-fuels.d.ts +35 -12
  43. package/dist/resources/bunker-fuels.js +41 -26
  44. package/dist/resources/commodities.js +2 -1
  45. package/dist/resources/data-quality.js +2 -1
  46. package/dist/resources/data-sources.d.ts +31 -31
  47. package/dist/resources/data-sources.js +30 -85
  48. package/dist/resources/diesel.d.ts +1 -1
  49. package/dist/resources/diesel.js +9 -38
  50. package/dist/resources/drilling.js +2 -1
  51. package/dist/resources/ei/drilling-productivity.js +2 -1
  52. package/dist/resources/ei/forecasts.js +2 -1
  53. package/dist/resources/ei/frac-focus.d.ts +23 -9
  54. package/dist/resources/ei/frac-focus.js +20 -9
  55. package/dist/resources/ei/index.js +2 -1
  56. package/dist/resources/ei/oil-inventories.js +2 -1
  57. package/dist/resources/ei/opec-production.js +2 -1
  58. package/dist/resources/ei/rig-counts.js +2 -1
  59. package/dist/resources/ei/well-permits.d.ts +25 -9
  60. package/dist/resources/ei/well-permits.js +20 -7
  61. package/dist/resources/forecasts.d.ts +4 -1
  62. package/dist/resources/forecasts.js +13 -6
  63. package/dist/resources/futures.d.ts +178 -1
  64. package/dist/resources/futures.js +199 -8
  65. package/dist/resources/indicators.d.ts +170 -0
  66. package/dist/resources/indicators.js +75 -0
  67. package/dist/resources/raw.d.ts +94 -0
  68. package/dist/resources/raw.js +124 -0
  69. package/dist/resources/rig-counts.js +5 -2
  70. package/dist/resources/spreads.d.ts +121 -0
  71. package/dist/resources/spreads.js +101 -0
  72. package/dist/resources/storage.d.ts +5 -4
  73. package/dist/resources/storage.js +7 -6
  74. package/dist/resources/streaming.d.ts +272 -0
  75. package/dist/resources/streaming.js +342 -0
  76. package/dist/resources/webhooks.d.ts +73 -23
  77. package/dist/resources/webhooks.js +59 -77
  78. package/dist/types.d.ts +43 -1
  79. package/dist/version.d.ts +1 -1
  80. package/dist/version.js +2 -2
  81. package/package.json +21 -6
@@ -0,0 +1,342 @@
1
+ /**
2
+ * WebSocket Streaming Resource
3
+ *
4
+ * Real-time price streaming via the OilPriceAPI ActionCable endpoint
5
+ * (`wss://api.oilpriceapi.com/cable`).
6
+ *
7
+ * Streaming is a **Reservoir Mastery (Professional+)** feature. Connections
8
+ * authenticate with your API key and subscribe to the `EnergyPricesChannel`,
9
+ * which pushes an initial `welcome` snapshot followed by live `price_update`
10
+ * and (for drilling-tier accounts) `rig_count_update` messages.
11
+ *
12
+ * The implementation speaks the raw ActionCable JSON subprotocol over the
13
+ * `ws` package: it performs the `welcome` -> `subscribe` ->
14
+ * `confirm_subscription` handshake, answers server `ping` frames, and
15
+ * surfaces decoded channel messages as typed events. Auto-reconnect with
16
+ * exponential backoff keeps the stream alive across transient network drops.
17
+ *
18
+ * @example
19
+ * ```typescript
20
+ * const sub = client.stream.prices({}, (update) => {
21
+ * console.log(update.prices.oil.wti?.original_price);
22
+ * });
23
+ *
24
+ * sub.on("rig_count_update", (m) => console.log(m.rig_count.region, m.rig_count.count));
25
+ * sub.on("error", (err) => console.error(err));
26
+ *
27
+ * // later
28
+ * sub.close();
29
+ * ```
30
+ */
31
+ import { EventEmitter } from "node:events";
32
+ import WebSocket from "ws";
33
+ /**
34
+ * The ActionCable channel exposed by the OilPriceAPI server.
35
+ *
36
+ * Confirmed from `app/channels/energy_prices_channel.rb`
37
+ * (`class EnergyPricesChannel`).
38
+ */
39
+ export const ENERGY_PRICES_CHANNEL = "EnergyPricesChannel";
40
+ /** Maps streamed slug <-> upstream code so `commodities` filtering accepts either. */
41
+ const COMMODITY_CODE_TO_SLUG = {
42
+ BRENT_CRUDE_USD: "brent",
43
+ WTI_USD: "wti",
44
+ NATURAL_GAS_GBP: "uk",
45
+ NATURAL_GAS_USD: "us",
46
+ DUTCH_TTF_EUR: "eu",
47
+ };
48
+ /**
49
+ * Handle for an active price stream.
50
+ *
51
+ * Extends `EventEmitter`. Emitted events:
52
+ * - `"connected"` — transport connected and subscription confirmed
53
+ * - `"welcome"` — initial snapshot ({@link WelcomeMessage})
54
+ * - `"price_update"` — live price broadcast ({@link PriceUpdateMessage})
55
+ * - `"rig_count_update"` — drilling broadcast ({@link RigCountUpdateMessage})
56
+ * - `"message"` — every decoded channel message ({@link StreamMessage})
57
+ * - `"reconnecting"` — a reconnect attempt is scheduled (`{ attempt, delay }`)
58
+ * - `"disconnected"` — transport closed (`{ code, reason }`)
59
+ * - `"error"` — an `Error` (transport error, unauthorized, or retries exhausted)
60
+ * - `"close"` — the subscription was closed via {@link PriceStreamSubscription.close}
61
+ */
62
+ export class PriceStreamSubscription extends EventEmitter {
63
+ /**
64
+ * @param url - The `wss://.../cable` endpoint.
65
+ * @param apiKey - API key sent as the ActionCable `Authorization: Token <key>` header.
66
+ * @param options - Reconnect + filter options.
67
+ * @param wsImpl - Injectable WebSocket constructor (used by tests to mock).
68
+ * @internal Construct via {@link StreamingResource.prices}.
69
+ */
70
+ constructor(url, apiKey, options, wsImpl = WebSocket) {
71
+ super();
72
+ this.wsImpl = wsImpl;
73
+ this.ws = null;
74
+ this.closed = false;
75
+ this.subscribed = false;
76
+ this.reconnectAttempts = 0;
77
+ this.reconnectTimer = null;
78
+ this.url = url;
79
+ this.apiKey = apiKey;
80
+ this.options = {
81
+ autoReconnect: options.autoReconnect ?? true,
82
+ reconnectDelay: options.reconnectDelay ?? 1000,
83
+ maxReconnectDelay: options.maxReconnectDelay ?? 30000,
84
+ maxReconnectAttempts: options.maxReconnectAttempts ?? 10,
85
+ commodities: options.commodities,
86
+ };
87
+ // The ActionCable subscription identifier. The channel takes no params;
88
+ // we additionally pass `api_key` in the identifier for parity with the
89
+ // documented native-WebSocket clients, while the server's
90
+ // ApplicationCable::Connection authenticates from the Authorization header.
91
+ this.identifier = JSON.stringify({
92
+ channel: ENERGY_PRICES_CHANNEL,
93
+ api_key: apiKey,
94
+ });
95
+ if (options.commodities && options.commodities.length > 0) {
96
+ this.commodityFilter = new Set(options.commodities.map((c) => COMMODITY_CODE_TO_SLUG[c] ?? c.toLowerCase()));
97
+ }
98
+ else {
99
+ this.commodityFilter = null;
100
+ }
101
+ }
102
+ /**
103
+ * Open the transport and begin the ActionCable handshake.
104
+ * @internal Called once by {@link StreamingResource.prices}.
105
+ */
106
+ connect() {
107
+ if (this.closed)
108
+ return;
109
+ const ws = new this.wsImpl(this.url, {
110
+ headers: {
111
+ Authorization: `Token ${this.apiKey}`,
112
+ },
113
+ });
114
+ this.ws = ws;
115
+ ws.on("open", () => {
116
+ // ActionCable sends a transport `welcome` frame first; we subscribe on
117
+ // open (the server tolerates an early subscribe and replies with
118
+ // confirm_subscription once the connection is established).
119
+ this.send({ command: "subscribe", identifier: this.identifier });
120
+ });
121
+ ws.on("message", (raw) => this.handleRaw(raw));
122
+ ws.on("error", (err) => {
123
+ this.emit("error", err);
124
+ });
125
+ ws.on("close", (code, reason) => {
126
+ this.ws = null;
127
+ this.subscribed = false;
128
+ this.emit("disconnected", { code, reason: reason?.toString() ?? "" });
129
+ if (!this.closed) {
130
+ this.scheduleReconnect();
131
+ }
132
+ });
133
+ }
134
+ handleRaw(raw) {
135
+ let frame;
136
+ try {
137
+ frame = JSON.parse(raw.toString());
138
+ }
139
+ catch {
140
+ // Ignore malformed frames rather than crash the stream.
141
+ return;
142
+ }
143
+ // ActionCable transport-level frames carry a top-level `type`.
144
+ const transportType = frame["type"];
145
+ if (transportType === "ping") {
146
+ // Heartbeat — nothing to do; presence of frames keeps `ws` alive.
147
+ return;
148
+ }
149
+ if (transportType === "welcome") {
150
+ // Transport handshake complete. (subscribe already sent on open.)
151
+ return;
152
+ }
153
+ if (transportType === "confirm_subscription") {
154
+ this.subscribed = true;
155
+ this.reconnectAttempts = 0;
156
+ this.emit("connected");
157
+ return;
158
+ }
159
+ if (transportType === "reject_subscription") {
160
+ this.emit("error", new Error("WebSocket subscription rejected. Streaming requires a Reservoir Mastery " +
161
+ "(Professional+) plan and a valid API key."));
162
+ return;
163
+ }
164
+ if (transportType === "disconnect") {
165
+ // Server-initiated disconnect (e.g. auth failure). Let `close` drive reconnect.
166
+ return;
167
+ }
168
+ // Channel message: payload lives under `message`.
169
+ const payload = frame["message"];
170
+ if (payload && typeof payload === "object") {
171
+ this.dispatch(payload);
172
+ }
173
+ }
174
+ dispatch(message) {
175
+ this.emit("message", message);
176
+ switch (message.type) {
177
+ case "welcome":
178
+ this.emit("welcome", message);
179
+ break;
180
+ case "price_update": {
181
+ const update = message;
182
+ if (this.matchesFilter(update)) {
183
+ this.emit("price_update", update);
184
+ }
185
+ break;
186
+ }
187
+ case "rig_count_update":
188
+ this.emit("rig_count_update", message);
189
+ break;
190
+ default:
191
+ // Unknown message types are still emitted via `message` above.
192
+ break;
193
+ }
194
+ }
195
+ matchesFilter(update) {
196
+ if (!this.commodityFilter)
197
+ return true;
198
+ const { oil, natural_gas } = update.prices ?? {};
199
+ const present = {
200
+ brent: oil?.brent,
201
+ wti: oil?.wti,
202
+ uk: natural_gas?.uk,
203
+ us: natural_gas?.us,
204
+ eu: natural_gas?.eu,
205
+ };
206
+ for (const slug of this.commodityFilter) {
207
+ if (present[slug])
208
+ return true;
209
+ }
210
+ return false;
211
+ }
212
+ scheduleReconnect() {
213
+ if (this.closed || !this.options.autoReconnect)
214
+ return;
215
+ if (this.reconnectAttempts >= this.options.maxReconnectAttempts) {
216
+ this.emit("error", new Error(`WebSocket reconnect failed after ${this.reconnectAttempts} attempt(s); giving up.`));
217
+ return;
218
+ }
219
+ const attempt = this.reconnectAttempts++;
220
+ const delay = Math.min(this.options.reconnectDelay * Math.pow(2, attempt), this.options.maxReconnectDelay);
221
+ this.emit("reconnecting", { attempt: attempt + 1, delay });
222
+ this.reconnectTimer = setTimeout(() => {
223
+ this.reconnectTimer = null;
224
+ this.connect();
225
+ }, delay);
226
+ }
227
+ send(payload) {
228
+ if (this.ws && this.ws.readyState === this.wsImpl.OPEN) {
229
+ this.ws.send(JSON.stringify(payload));
230
+ }
231
+ }
232
+ /** Whether the channel subscription has been confirmed by the server. */
233
+ get isSubscribed() {
234
+ return this.subscribed;
235
+ }
236
+ /**
237
+ * Cleanly tear down the stream: cancels any pending reconnect, unsubscribes
238
+ * from the channel, and closes the socket. Safe to call multiple times.
239
+ * Emits `"close"` once.
240
+ */
241
+ close() {
242
+ if (this.closed)
243
+ return;
244
+ this.closed = true;
245
+ if (this.reconnectTimer) {
246
+ clearTimeout(this.reconnectTimer);
247
+ this.reconnectTimer = null;
248
+ }
249
+ if (this.ws) {
250
+ const ws = this.ws;
251
+ try {
252
+ if (ws.readyState === this.wsImpl.OPEN) {
253
+ ws.send(JSON.stringify({ command: "unsubscribe", identifier: this.identifier }));
254
+ }
255
+ }
256
+ catch {
257
+ // ignore — we're closing anyway
258
+ }
259
+ try {
260
+ ws.close();
261
+ }
262
+ catch {
263
+ // ignore
264
+ }
265
+ this.ws = null;
266
+ }
267
+ this.subscribed = false;
268
+ this.emit("close");
269
+ this.removeAllListeners();
270
+ }
271
+ }
272
+ /**
273
+ * Streaming resource — entry point for real-time price subscriptions.
274
+ *
275
+ * Accessed via `client.stream`.
276
+ */
277
+ export class StreamingResource {
278
+ constructor(client,
279
+ /**
280
+ * Injectable WebSocket implementation. Defaults to the `ws` package;
281
+ * tests pass a mock constructor.
282
+ * @internal
283
+ */
284
+ wsImpl = WebSocket) {
285
+ this.client = client;
286
+ this.wsImpl = wsImpl;
287
+ }
288
+ /**
289
+ * Derive the `wss://.../cable` endpoint from the client's REST base URL.
290
+ *
291
+ * `https://api.oilpriceapi.com` -> `wss://api.oilpriceapi.com/cable`
292
+ * `http://localhost:5000` -> `ws://localhost:5000/cable`
293
+ */
294
+ cableUrl() {
295
+ const base = this.client["baseUrl"];
296
+ const wsBase = base
297
+ .replace(/^http:/, "ws:")
298
+ .replace(/^https:/, "wss:")
299
+ .replace(/\/$/, "");
300
+ return `${wsBase}/cable`;
301
+ }
302
+ /**
303
+ * Open a real-time price stream over the `EnergyPricesChannel`.
304
+ *
305
+ * Returns a {@link PriceStreamSubscription} handle (an `EventEmitter`) you
306
+ * can attach further listeners to and `.close()` when done. The optional
307
+ * `onUpdate` callback is a convenience wired to the `price_update` event.
308
+ *
309
+ * @param options - Filtering and reconnect options.
310
+ * @param onUpdate - Optional callback for each `price_update` message.
311
+ * @returns The subscription handle.
312
+ *
313
+ * @throws {OilPriceAPIError} If no API key is configured on the client.
314
+ *
315
+ * @example
316
+ * ```typescript
317
+ * const client = new OilPriceAPI({ apiKey: process.env.OILPRICEAPI_KEY });
318
+ *
319
+ * const sub = client.stream.prices(
320
+ * { commodities: ["WTI_USD", "BRENT_CRUDE_USD"] },
321
+ * (update) => {
322
+ * const wti = update.prices.oil.wti;
323
+ * if (wti) console.log(`WTI ${wti.original_price} @ ${update.timestamp}`);
324
+ * },
325
+ * );
326
+ *
327
+ * sub.on("connected", () => console.log("streaming live"));
328
+ * sub.on("error", (err) => console.error("stream error:", err));
329
+ *
330
+ * process.on("SIGINT", () => sub.close());
331
+ * ```
332
+ */
333
+ prices(options = {}, onUpdate) {
334
+ const apiKey = this.client["apiKey"];
335
+ const sub = new PriceStreamSubscription(this.cableUrl(), apiKey, options, this.wsImpl);
336
+ if (onUpdate) {
337
+ sub.on("price_update", onUpdate);
338
+ }
339
+ sub.connect();
340
+ return sub;
341
+ }
342
+ }
@@ -10,18 +10,16 @@ import type { OilPriceAPI } from "../client.js";
10
10
  export interface WebhookEndpoint {
11
11
  /** Unique webhook identifier */
12
12
  id: string;
13
- /** User-friendly webhook name */
14
- name: string;
13
+ /** User-friendly description of the webhook */
14
+ description?: string;
15
15
  /** Webhook URL (must be HTTPS) */
16
16
  url: string;
17
17
  /** Event types to subscribe to */
18
18
  events: string[];
19
- /** Whether the webhook is active */
20
- enabled: boolean;
21
- /** Optional secret for signature verification */
19
+ /** Lifecycle status (e.g. 'active', 'disabled') */
20
+ status?: string;
21
+ /** Optional secret for signature verification (generated server-side) */
22
22
  secret?: string;
23
- /** Optional metadata */
24
- metadata?: Record<string, unknown>;
25
23
  /** Number of successful deliveries */
26
24
  successful_deliveries: number;
27
25
  /** Number of failed deliveries */
@@ -37,37 +35,53 @@ export interface WebhookEndpoint {
37
35
  }
38
36
  /**
39
37
  * Parameters for creating a webhook
38
+ *
39
+ * NOTE: The API permits a flat (un-nested) body with the fields below. Earlier
40
+ * SDK versions nested these under a `webhook` key and used `name`/`enabled`,
41
+ * which the controller dropped — the controller reads `description` and `status`.
40
42
  */
41
43
  export interface CreateWebhookParams {
42
- /** User-friendly webhook name */
43
- name: string;
44
44
  /** Webhook URL (must be HTTPS) */
45
45
  url: string;
46
46
  /** Event types to subscribe to */
47
47
  events: string[];
48
- /** Whether to enable immediately (default: true) */
49
- enabled?: boolean;
50
- /** Optional secret for signature verification */
51
- secret?: string;
52
- /** Optional metadata */
53
- metadata?: Record<string, unknown>;
48
+ /** User-friendly description */
49
+ description?: string;
50
+ /** Lifecycle status (e.g. 'active', 'disabled') */
51
+ status?: string;
52
+ /** Commodity codes to filter events to */
53
+ commodity_filters?: string[];
54
+ /** US state codes to filter events to */
55
+ state_filters?: string[];
56
+ /** Per-second delivery rate limit */
57
+ rate_limit_per_second?: number;
58
+ /** Delivery timeout in seconds */
59
+ timeout_seconds?: number;
60
+ /** Max delivery retry attempts */
61
+ max_retries?: number;
54
62
  }
55
63
  /**
56
64
  * Parameters for updating a webhook
57
65
  */
58
66
  export interface UpdateWebhookParams {
59
- /** User-friendly webhook name */
60
- name?: string;
61
67
  /** Webhook URL */
62
68
  url?: string;
63
69
  /** Event types to subscribe to */
64
70
  events?: string[];
65
- /** Whether the webhook is active */
66
- enabled?: boolean;
67
- /** Secret for signature verification */
68
- secret?: string;
69
- /** Metadata */
70
- metadata?: Record<string, unknown>;
71
+ /** User-friendly description */
72
+ description?: string;
73
+ /** Lifecycle status (e.g. 'active', 'disabled') */
74
+ status?: string;
75
+ /** Commodity codes to filter events to */
76
+ commodity_filters?: string[];
77
+ /** US state codes to filter events to */
78
+ state_filters?: string[];
79
+ /** Per-second delivery rate limit */
80
+ rate_limit_per_second?: number;
81
+ /** Delivery timeout in seconds */
82
+ timeout_seconds?: number;
83
+ /** Max delivery retry attempts */
84
+ max_retries?: number;
71
85
  }
72
86
  /**
73
87
  * Webhook test response
@@ -287,4 +301,40 @@ export declare class WebhooksResource {
287
301
  * ```
288
302
  */
289
303
  events(id: string): Promise<WebhookEvent[]>;
304
+ /**
305
+ * Verify a webhook signature.
306
+ *
307
+ * Validates that a webhook payload was sent by OilPriceAPI by checking
308
+ * the HMAC-SHA256 signature. Uses constant-time comparison to prevent
309
+ * timing attacks.
310
+ *
311
+ * @param payload - Raw request body (string or Buffer)
312
+ * @param signature - Value of the X-OilPriceAPI-Signature header (e.g., "sha256=abc123...")
313
+ * @param secret - Your webhook signing secret
314
+ * @returns true if signature is valid
315
+ *
316
+ * @example
317
+ * ```typescript
318
+ * import express from 'express';
319
+ * import { OilPriceAPI } from 'oilpriceapi';
320
+ *
321
+ * const app = express();
322
+ * const client = new OilPriceAPI({ apiKey: 'your_key' });
323
+ *
324
+ * // Use raw body parser for webhook routes
325
+ * app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
326
+ * const signature = req.headers['x-oilpriceapi-signature'] as string;
327
+ * const isValid = client.webhooks.verifySignature(req.body, signature, 'your_secret');
328
+ *
329
+ * if (!isValid) {
330
+ * return res.status(401).send('Invalid signature');
331
+ * }
332
+ *
333
+ * const event = JSON.parse(req.body.toString());
334
+ * console.log('Verified webhook:', event.type);
335
+ * res.sendStatus(200);
336
+ * });
337
+ * ```
338
+ */
339
+ verifySignature(payload: string | Buffer, signature: string, secret: string): boolean;
290
340
  }
@@ -3,6 +3,8 @@
3
3
  *
4
4
  * Manage webhook endpoints for real-time event notifications.
5
5
  */
6
+ import { ValidationError } from "../errors.js";
7
+ import { verifyWebhookSignature } from "../index.js";
6
8
  /**
7
9
  * Webhooks Resource
8
10
  *
@@ -85,7 +87,7 @@ export class WebhooksResource {
85
87
  */
86
88
  async get(id) {
87
89
  if (!id || typeof id !== "string") {
88
- throw new Error("Webhook ID must be a non-empty string");
90
+ throw new ValidationError("Webhook ID must be a non-empty string");
89
91
  }
90
92
  const response = await this.client["request"](`/v1/webhooks/${id}`, {});
91
93
  return "webhook" in response ? response.webhook : response;
@@ -112,41 +114,18 @@ export class WebhooksResource {
112
114
  * ```
113
115
  */
114
116
  async create(params) {
115
- if (!params.name || typeof params.name !== "string") {
116
- throw new Error("Webhook name is required");
117
- }
118
117
  if (!params.url || !params.url.startsWith("https://")) {
119
- throw new Error("Webhook URL must use HTTPS protocol");
118
+ throw new ValidationError("Webhook URL must use HTTPS protocol");
120
119
  }
121
- if (!params.events ||
122
- !Array.isArray(params.events) ||
123
- params.events.length === 0) {
124
- throw new Error("At least one event type is required");
120
+ if (!params.events || !Array.isArray(params.events) || params.events.length === 0) {
121
+ throw new ValidationError("At least one event type is required");
125
122
  }
126
- const url = `${this.client["baseUrl"]}/v1/webhooks`;
127
- const response = await fetch(url, {
123
+ // The controller reads a flat (un-nested) body via params.permit(...).
124
+ const response = await this.client["request"]("/v1/webhooks", {}, {
128
125
  method: "POST",
129
- headers: {
130
- Authorization: `Bearer ${this.client["apiKey"]}`,
131
- "Content-Type": "application/json",
132
- },
133
- body: JSON.stringify({
134
- webhook: {
135
- name: params.name,
136
- url: params.url,
137
- events: params.events,
138
- enabled: params.enabled ?? true,
139
- secret: params.secret,
140
- metadata: params.metadata,
141
- },
142
- }),
126
+ body: { ...params },
143
127
  });
144
- if (!response.ok) {
145
- const errorText = await response.text();
146
- throw new Error(`Failed to create webhook: ${response.status} ${errorText}`);
147
- }
148
- const data = (await response.json());
149
- return "webhook" in data ? data.webhook : data;
128
+ return "webhook" in response ? response.webhook : response;
150
129
  }
151
130
  /**
152
131
  * Update a webhook endpoint
@@ -171,32 +150,20 @@ export class WebhooksResource {
171
150
  */
172
151
  async update(id, params) {
173
152
  if (!id || typeof id !== "string") {
174
- throw new Error("Webhook ID must be a non-empty string");
153
+ throw new ValidationError("Webhook ID must be a non-empty string");
175
154
  }
176
155
  if (params.url !== undefined && !params.url.startsWith("https://")) {
177
- throw new Error("Webhook URL must use HTTPS protocol");
156
+ throw new ValidationError("Webhook URL must use HTTPS protocol");
178
157
  }
179
158
  if (params.events !== undefined &&
180
159
  (!Array.isArray(params.events) || params.events.length === 0)) {
181
- throw new Error("Events must be a non-empty array");
160
+ throw new ValidationError("Events must be a non-empty array");
182
161
  }
183
- const url = `${this.client["baseUrl"]}/v1/webhooks/${id}`;
184
- const response = await fetch(url, {
162
+ const response = await this.client["request"](`/v1/webhooks/${id}`, {}, {
185
163
  method: "PATCH",
186
- headers: {
187
- Authorization: `Bearer ${this.client["apiKey"]}`,
188
- "Content-Type": "application/json",
189
- },
190
- body: JSON.stringify({
191
- webhook: params,
192
- }),
164
+ body: { ...params },
193
165
  });
194
- if (!response.ok) {
195
- const errorText = await response.text();
196
- throw new Error(`Failed to update webhook: ${response.status} ${errorText}`);
197
- }
198
- const data = (await response.json());
199
- return "webhook" in data ? data.webhook : data;
166
+ return "webhook" in response ? response.webhook : response;
200
167
  }
201
168
  /**
202
169
  * Delete a webhook endpoint
@@ -214,20 +181,9 @@ export class WebhooksResource {
214
181
  */
215
182
  async delete(id) {
216
183
  if (!id || typeof id !== "string") {
217
- throw new Error("Webhook ID must be a non-empty string");
218
- }
219
- const url = `${this.client["baseUrl"]}/v1/webhooks/${id}`;
220
- const response = await fetch(url, {
221
- method: "DELETE",
222
- headers: {
223
- Authorization: `Bearer ${this.client["apiKey"]}`,
224
- "Content-Type": "application/json",
225
- },
226
- });
227
- if (!response.ok) {
228
- const errorText = await response.text();
229
- throw new Error(`Failed to delete webhook: ${response.status} ${errorText}`);
184
+ throw new ValidationError("Webhook ID must be a non-empty string");
230
185
  }
186
+ await this.client["request"](`/v1/webhooks/${id}`, {}, { method: "DELETE" });
231
187
  }
232
188
  /**
233
189
  * Test a webhook endpoint
@@ -252,21 +208,9 @@ export class WebhooksResource {
252
208
  */
253
209
  async test(id) {
254
210
  if (!id || typeof id !== "string") {
255
- throw new Error("Webhook ID must be a non-empty string");
256
- }
257
- const url = `${this.client["baseUrl"]}/v1/webhooks/${id}/test`;
258
- const response = await fetch(url, {
259
- method: "POST",
260
- headers: {
261
- Authorization: `Bearer ${this.client["apiKey"]}`,
262
- "Content-Type": "application/json",
263
- },
264
- });
265
- if (!response.ok) {
266
- const errorText = await response.text();
267
- throw new Error(`Failed to test webhook: ${response.status} ${errorText}`);
211
+ throw new ValidationError("Webhook ID must be a non-empty string");
268
212
  }
269
- return response.json();
213
+ return this.client["request"](`/v1/webhooks/${id}/test`, {}, { method: "POST" });
270
214
  }
271
215
  /**
272
216
  * Get webhook event history
@@ -289,9 +233,47 @@ export class WebhooksResource {
289
233
  */
290
234
  async events(id) {
291
235
  if (!id || typeof id !== "string") {
292
- throw new Error("Webhook ID must be a non-empty string");
236
+ throw new ValidationError("Webhook ID must be a non-empty string");
293
237
  }
294
238
  const response = await this.client["request"](`/v1/webhooks/${id}/events`, {});
295
239
  return Array.isArray(response) ? response : response.events;
296
240
  }
241
+ /**
242
+ * Verify a webhook signature.
243
+ *
244
+ * Validates that a webhook payload was sent by OilPriceAPI by checking
245
+ * the HMAC-SHA256 signature. Uses constant-time comparison to prevent
246
+ * timing attacks.
247
+ *
248
+ * @param payload - Raw request body (string or Buffer)
249
+ * @param signature - Value of the X-OilPriceAPI-Signature header (e.g., "sha256=abc123...")
250
+ * @param secret - Your webhook signing secret
251
+ * @returns true if signature is valid
252
+ *
253
+ * @example
254
+ * ```typescript
255
+ * import express from 'express';
256
+ * import { OilPriceAPI } from 'oilpriceapi';
257
+ *
258
+ * const app = express();
259
+ * const client = new OilPriceAPI({ apiKey: 'your_key' });
260
+ *
261
+ * // Use raw body parser for webhook routes
262
+ * app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
263
+ * const signature = req.headers['x-oilpriceapi-signature'] as string;
264
+ * const isValid = client.webhooks.verifySignature(req.body, signature, 'your_secret');
265
+ *
266
+ * if (!isValid) {
267
+ * return res.status(401).send('Invalid signature');
268
+ * }
269
+ *
270
+ * const event = JSON.parse(req.body.toString());
271
+ * console.log('Verified webhook:', event.type);
272
+ * res.sendStatus(200);
273
+ * });
274
+ * ```
275
+ */
276
+ verifySignature(payload, signature, secret) {
277
+ return verifyWebhookSignature(payload, signature, secret);
278
+ }
297
279
  }