1pub mod webhook;
2
3pub use webhook::{
4 ActivateWebhookParams, BitcoinWalletFilterTemplate, CreateWebhookFromTemplateParams,
5 EvmAbiFilterTemplate, EvmContractEventsTemplate, EvmWalletFilterTemplate, GetWebhooksParams,
6 HyperliquidWalletEventsFilterTemplate, ListWebhooksResponse, SolanaWalletFilterTemplate,
7 StellarWalletTransactionsFilterTemplate, TemplateArgs, UpdateWebhookParams,
8 UpdateWebhookTemplateParams, Webhook, WebhookDestinationAttributes,
9 WebhookEnabledCountResponse, WebhookStartFrom, WebhookTemplateId, XrplWalletFilterTemplate,
10};
11
12use crate::{config::WebhooksConfig, errors::SdkError, SdkConfig};
13
14const WEBHOOKS_BASE_URL: &str = "https://api.quicknode.com/webhooks/rest/v1/";
15
16pub(crate) struct ResolvedWebhooksConfig {
17 pub(crate) base_url: reqwest::Url,
18}
19
20impl ResolvedWebhooksConfig {
21 pub(crate) fn from_config(config: Option<&WebhooksConfig>) -> Result<Self, SdkError> {
22 let url_str = config
23 .and_then(|s| s.base_url.as_deref())
24 .unwrap_or(WEBHOOKS_BASE_URL);
25 let mut base_url = reqwest::Url::parse(url_str)?;
26 if !base_url.path().ends_with('/') {
27 base_url.set_path(&format!("{}/", base_url.path()));
28 }
29 Ok(Self { base_url })
30 }
31}
32
33#[derive(Debug, Clone)]
38pub struct WebhooksApiClient {
39 config: SdkConfig,
40}
41
42impl WebhooksApiClient {
43 pub fn new(config: SdkConfig) -> Self {
44 Self { config }
45 }
46
47 pub async fn list_webhooks(
53 &self,
54 params: &GetWebhooksParams,
55 ) -> Result<ListWebhooksResponse, SdkError> {
56 let mut url = self.config.webhooks().base_url.join("webhooks")?;
57 {
58 let mut pairs = url.query_pairs_mut();
59 if let Some(v) = params.limit {
60 pairs.append_pair("limit", &v.to_string());
61 }
62 if let Some(v) = params.offset {
63 pairs.append_pair("offset", &v.to_string());
64 }
65 }
66 let resp = self
67 .config
68 .http_client()
69 .get(url)
70 .send()
71 .await
72 .map_err(SdkError::Http)?;
73 let status = resp.status();
74 let body = resp.text().await.map_err(SdkError::Http)?;
75 if !status.is_success() {
76 return Err(SdkError::Api { status, body });
77 }
78 serde_json::from_str(&body).map_err(|source| SdkError::Decode { source, body })
79 }
80
81 pub async fn delete_all_webhooks(&self) -> Result<(), SdkError> {
84 let url = self.config.webhooks().base_url.join("webhooks")?;
85 let resp = self
86 .config
87 .http_client()
88 .delete(url)
89 .send()
90 .await
91 .map_err(SdkError::Http)?;
92 let status = resp.status();
93 if !status.is_success() {
94 let body = resp.text().await.map_err(SdkError::Http)?;
95 return Err(SdkError::Api { status, body });
96 }
97 Ok(())
98 }
99
100 pub async fn get_webhook(&self, id: &str) -> Result<Webhook, SdkError> {
106 let url = self
107 .config
108 .webhooks()
109 .base_url
110 .join(&format!("webhooks/{id}"))?;
111 let resp = self
112 .config
113 .http_client()
114 .get(url)
115 .send()
116 .await
117 .map_err(SdkError::Http)?;
118 let status = resp.status();
119 let body = resp.text().await.map_err(SdkError::Http)?;
120 if !status.is_success() {
121 return Err(SdkError::Api { status, body });
122 }
123 serde_json::from_str(&body).map_err(|source| SdkError::Decode { source, body })
124 }
125
126 pub async fn update_webhook(
133 &self,
134 id: &str,
135 params: &UpdateWebhookParams,
136 ) -> Result<Webhook, SdkError> {
137 let url = self
138 .config
139 .webhooks()
140 .base_url
141 .join(&format!("webhooks/{id}"))?;
142 let resp = self
143 .config
144 .http_client()
145 .patch(url)
146 .json(params)
147 .send()
148 .await
149 .map_err(SdkError::Http)?;
150 let status = resp.status();
151 let body = resp.text().await.map_err(SdkError::Http)?;
152 if !status.is_success() {
153 return Err(SdkError::Api { status, body });
154 }
155 serde_json::from_str(&body).map_err(|source| SdkError::Decode { source, body })
156 }
157
158 pub async fn delete_webhook(&self, id: &str) -> Result<(), SdkError> {
160 let url = self
161 .config
162 .webhooks()
163 .base_url
164 .join(&format!("webhooks/{id}"))?;
165 let resp = self
166 .config
167 .http_client()
168 .delete(url)
169 .send()
170 .await
171 .map_err(SdkError::Http)?;
172 let status = resp.status();
173 if !status.is_success() {
174 let body = resp.text().await.map_err(SdkError::Http)?;
175 return Err(SdkError::Api { status, body });
176 }
177 Ok(())
178 }
179
180 pub async fn pause_webhook(&self, id: &str) -> Result<(), SdkError> {
182 let url = self
183 .config
184 .webhooks()
185 .base_url
186 .join(&format!("webhooks/{id}/pause"))?;
187 let resp = self
188 .config
189 .http_client()
190 .post(url)
191 .send()
192 .await
193 .map_err(SdkError::Http)?;
194 let status = resp.status();
195 if !status.is_success() {
196 let body = resp.text().await.map_err(SdkError::Http)?;
197 return Err(SdkError::Api { status, body });
198 }
199 Ok(())
200 }
201
202 pub async fn activate_webhook(
207 &self,
208 id: &str,
209 params: &ActivateWebhookParams,
210 ) -> Result<(), SdkError> {
211 let url = self
212 .config
213 .webhooks()
214 .base_url
215 .join(&format!("webhooks/{id}/activate"))?;
216 let resp = self
217 .config
218 .http_client()
219 .post(url)
220 .json(params)
221 .send()
222 .await
223 .map_err(SdkError::Http)?;
224 let status = resp.status();
225 if !status.is_success() {
226 let body = resp.text().await.map_err(SdkError::Http)?;
227 return Err(SdkError::Api { status, body });
228 }
229 Ok(())
230 }
231
232 pub async fn get_enabled_count(&self) -> Result<WebhookEnabledCountResponse, SdkError> {
235 let url = self
236 .config
237 .webhooks()
238 .base_url
239 .join("webhooks/enabled_count")?;
240 let resp = self
241 .config
242 .http_client()
243 .get(url)
244 .send()
245 .await
246 .map_err(SdkError::Http)?;
247 let status = resp.status();
248 let body = resp.text().await.map_err(SdkError::Http)?;
249 if !status.is_success() {
250 return Err(SdkError::Api { status, body });
251 }
252 serde_json::from_str(&body).map_err(|source| SdkError::Decode { source, body })
253 }
254
255 pub async fn create_webhook_from_template(
263 &self,
264 params: &CreateWebhookFromTemplateParams,
265 ) -> Result<Webhook, SdkError> {
266 let template_id = params.template_args.template_id.as_str();
267 let url = self
268 .config
269 .webhooks()
270 .base_url
271 .join(&format!("webhooks/template/{template_id}"))?;
272
273 #[allow(clippy::needless_borrows_for_generic_args)]
274 let mut body =
275 serde_json::to_value(¶ms).map_err(|e| SdkError::Config(e.to_string()))?;
276 let obj = body.as_object_mut().ok_or_else(|| {
277 SdkError::Config("failed to serialize request body as JSON object".into())
278 })?;
279 obj.insert(
280 "templateArgs".to_string(),
281 serde_json::from_str(¶ms.template_args.value)
282 .map_err(|e| SdkError::Config(e.to_string()))?,
283 );
284
285 let resp = self
286 .config
287 .http_client()
288 .post(url)
289 .json(&body)
290 .send()
291 .await
292 .map_err(SdkError::Http)?;
293 let status = resp.status();
294 let body = resp.text().await.map_err(SdkError::Http)?;
295 if !status.is_success() {
296 return Err(SdkError::Api { status, body });
297 }
298 serde_json::from_str(&body).map_err(|source| SdkError::Decode { source, body })
299 }
300
301 pub async fn update_webhook_template(
308 &self,
309 webhook_id: &str,
310 params: &UpdateWebhookTemplateParams,
311 ) -> Result<Webhook, SdkError> {
312 let template_id = params.template_args.template_id.as_str();
313 let url = self
314 .config
315 .webhooks()
316 .base_url
317 .join(&format!("webhooks/{webhook_id}/template/{template_id}"))?;
318
319 #[allow(clippy::needless_borrows_for_generic_args)]
320 let mut body =
321 serde_json::to_value(¶ms).map_err(|e| SdkError::Config(e.to_string()))?;
322 let obj = body.as_object_mut().ok_or_else(|| {
323 SdkError::Config("failed to serialize request body as JSON object".into())
324 })?;
325 obj.insert(
326 "templateArgs".to_string(),
327 serde_json::from_str(¶ms.template_args.value)
328 .map_err(|e| SdkError::Config(e.to_string()))?,
329 );
330
331 let resp = self
332 .config
333 .http_client()
334 .patch(url)
335 .json(&body)
336 .send()
337 .await
338 .map_err(SdkError::Http)?;
339 let status = resp.status();
340 let body = resp.text().await.map_err(SdkError::Http)?;
341 if !status.is_success() {
342 return Err(SdkError::Api { status, body });
343 }
344 serde_json::from_str(&body).map_err(|source| SdkError::Decode { source, body })
345 }
346}
347
348#[cfg(test)]
351#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
352mod tests {
353 use super::*;
354 use crate::{QuickNodeSdk, SdkFullConfig, WebhooksConfig};
355 use wiremock::matchers::{method, path, path_regex};
356 use wiremock::{Mock, MockServer, ResponseTemplate};
357
358 fn make_sdk(base_url: String) -> QuickNodeSdk {
359 QuickNodeSdk::new(&SdkFullConfig {
360 api_key: "test-key".to_string(),
361 http: None,
362 admin: None,
363 streams: None,
364 webhooks: Some(WebhooksConfig {
365 base_url: Some(base_url),
366 }),
367 kvstore: None,
368 })
369 .unwrap()
370 }
371
372 fn webhook_response_json() -> serde_json::Value {
373 serde_json::json!({
374 "id": "wh-1234-5678",
375 "name": "test-webhook",
376 "status": "active",
377 "network": "ethereum-mainnet",
378 "created_at": "2026-03-19T12:00:00Z",
379 "updated_at": "2026-03-19T12:00:00Z"
380 })
381 }
382
383 #[tokio::test]
384 async fn list_webhooks_success() {
385 let server = MockServer::start().await;
386 let response = serde_json::json!({
387 "data": [webhook_response_json()]
388 });
389 Mock::given(method("GET"))
390 .and(path("/webhooks"))
391 .respond_with(ResponseTemplate::new(200).set_body_json(response))
392 .mount(&server)
393 .await;
394 let sdk = make_sdk(format!("{}/", server.uri()));
395 let resp = sdk
396 .webhooks
397 .list_webhooks(&GetWebhooksParams::default())
398 .await
399 .unwrap();
400 assert_eq!(resp.data.len(), 1);
401 assert_eq!(resp.data[0].id, "wh-1234-5678");
402 }
403
404 #[tokio::test]
405 async fn list_webhooks_api_error() {
406 let server = MockServer::start().await;
407 Mock::given(method("GET"))
408 .and(path("/webhooks"))
409 .respond_with(ResponseTemplate::new(400).set_body_string("Bad Request"))
410 .mount(&server)
411 .await;
412 let sdk = make_sdk(format!("{}/", server.uri()));
413 let err = sdk
414 .webhooks
415 .list_webhooks(&GetWebhooksParams::default())
416 .await
417 .unwrap_err();
418 assert!(matches!(err, SdkError::Api { .. }));
419 }
420
421 #[tokio::test]
422 async fn list_webhooks_server_error() {
423 let server = MockServer::start().await;
424 Mock::given(method("GET"))
425 .and(path("/webhooks"))
426 .respond_with(ResponseTemplate::new(500).set_body_string("Internal Server Error"))
427 .mount(&server)
428 .await;
429 let sdk = make_sdk(format!("{}/", server.uri()));
430 let err = sdk
431 .webhooks
432 .list_webhooks(&GetWebhooksParams::default())
433 .await
434 .unwrap_err();
435 assert!(matches!(err, SdkError::Api { .. }));
436 }
437
438 #[tokio::test]
439 async fn get_webhook_success() {
440 let server = MockServer::start().await;
441 Mock::given(method("GET"))
442 .and(path("/webhooks/test-id"))
443 .respond_with(ResponseTemplate::new(200).set_body_json(webhook_response_json()))
444 .mount(&server)
445 .await;
446 let sdk = make_sdk(format!("{}/", server.uri()));
447 let resp = sdk.webhooks.get_webhook("test-id").await.unwrap();
448 assert_eq!(resp.id, "wh-1234-5678");
449 }
450
451 #[tokio::test]
452 async fn get_webhook_api_error() {
453 let server = MockServer::start().await;
454 Mock::given(method("GET"))
455 .and(path("/webhooks/test-id"))
456 .respond_with(ResponseTemplate::new(404).set_body_string("Not Found"))
457 .mount(&server)
458 .await;
459 let sdk = make_sdk(format!("{}/", server.uri()));
460 let err = sdk.webhooks.get_webhook("test-id").await.unwrap_err();
461 assert!(matches!(err, SdkError::Api { .. }));
462 }
463
464 #[tokio::test]
465 async fn update_webhook_success() {
466 let server = MockServer::start().await;
467 let mut updated = webhook_response_json();
468 updated["name"] = serde_json::json!("updated-name");
469 Mock::given(method("PATCH"))
470 .and(path("/webhooks/test-id"))
471 .respond_with(ResponseTemplate::new(200).set_body_json(updated))
472 .mount(&server)
473 .await;
474 let sdk = make_sdk(format!("{}/", server.uri()));
475 let params = UpdateWebhookParams {
476 name: Some("updated-name".to_string()),
477 ..Default::default()
478 };
479 let resp = sdk
480 .webhooks
481 .update_webhook("test-id", ¶ms)
482 .await
483 .unwrap();
484 assert_eq!(resp.name, "updated-name");
485 }
486
487 #[tokio::test]
488 async fn update_webhook_api_error() {
489 let server = MockServer::start().await;
490 Mock::given(method("PATCH"))
491 .and(path("/webhooks/test-id"))
492 .respond_with(ResponseTemplate::new(400).set_body_string("Bad Request"))
493 .mount(&server)
494 .await;
495 let sdk = make_sdk(format!("{}/", server.uri()));
496 let params = UpdateWebhookParams::default();
497 let err = sdk
498 .webhooks
499 .update_webhook("test-id", ¶ms)
500 .await
501 .unwrap_err();
502 assert!(matches!(err, SdkError::Api { .. }));
503 }
504
505 #[tokio::test]
506 async fn delete_webhook_success() {
507 let server = MockServer::start().await;
508 Mock::given(method("DELETE"))
509 .and(path("/webhooks/test-id"))
510 .respond_with(ResponseTemplate::new(200))
511 .mount(&server)
512 .await;
513 let sdk = make_sdk(format!("{}/", server.uri()));
514 sdk.webhooks.delete_webhook("test-id").await.unwrap();
515 }
516
517 #[tokio::test]
518 async fn delete_webhook_api_error() {
519 let server = MockServer::start().await;
520 Mock::given(method("DELETE"))
521 .and(path("/webhooks/test-id"))
522 .respond_with(ResponseTemplate::new(404).set_body_string("Not Found"))
523 .mount(&server)
524 .await;
525 let sdk = make_sdk(format!("{}/", server.uri()));
526 let err = sdk.webhooks.delete_webhook("test-id").await.unwrap_err();
527 assert!(matches!(err, SdkError::Api { .. }));
528 }
529
530 #[tokio::test]
531 async fn delete_all_webhooks_success() {
532 let server = MockServer::start().await;
533 Mock::given(method("DELETE"))
534 .and(path("/webhooks"))
535 .respond_with(ResponseTemplate::new(204))
536 .mount(&server)
537 .await;
538 let sdk = make_sdk(format!("{}/", server.uri()));
539 sdk.webhooks.delete_all_webhooks().await.unwrap();
540 }
541
542 #[tokio::test]
543 async fn delete_all_webhooks_api_error() {
544 let server = MockServer::start().await;
545 Mock::given(method("DELETE"))
546 .and(path("/webhooks"))
547 .respond_with(ResponseTemplate::new(400).set_body_string("Bad Request"))
548 .mount(&server)
549 .await;
550 let sdk = make_sdk(format!("{}/", server.uri()));
551 let err = sdk.webhooks.delete_all_webhooks().await.unwrap_err();
552 assert!(matches!(err, SdkError::Api { .. }));
553 }
554
555 #[tokio::test]
556 async fn pause_webhook_success() {
557 let server = MockServer::start().await;
558 Mock::given(method("POST"))
559 .and(path("/webhooks/test-id/pause"))
560 .respond_with(ResponseTemplate::new(201))
561 .mount(&server)
562 .await;
563 let sdk = make_sdk(format!("{}/", server.uri()));
564 sdk.webhooks.pause_webhook("test-id").await.unwrap();
565 }
566
567 #[tokio::test]
568 async fn pause_webhook_api_error() {
569 let server = MockServer::start().await;
570 Mock::given(method("POST"))
571 .and(path("/webhooks/test-id/pause"))
572 .respond_with(ResponseTemplate::new(404).set_body_string("Not Found"))
573 .mount(&server)
574 .await;
575 let sdk = make_sdk(format!("{}/", server.uri()));
576 let err = sdk.webhooks.pause_webhook("test-id").await.unwrap_err();
577 assert!(matches!(err, SdkError::Api { .. }));
578 }
579
580 #[tokio::test]
581 async fn activate_webhook_success() {
582 let server = MockServer::start().await;
583 Mock::given(method("POST"))
584 .and(path("/webhooks/test-id/activate"))
585 .respond_with(ResponseTemplate::new(201))
586 .mount(&server)
587 .await;
588 let sdk = make_sdk(format!("{}/", server.uri()));
589 let params = ActivateWebhookParams {
590 start_from: WebhookStartFrom::Latest,
591 };
592 sdk.webhooks
593 .activate_webhook("test-id", ¶ms)
594 .await
595 .unwrap();
596 }
597
598 #[tokio::test]
599 async fn activate_webhook_api_error() {
600 let server = MockServer::start().await;
601 Mock::given(method("POST"))
602 .and(path("/webhooks/test-id/activate"))
603 .respond_with(ResponseTemplate::new(400).set_body_string("Bad Request"))
604 .mount(&server)
605 .await;
606 let sdk = make_sdk(format!("{}/", server.uri()));
607 let params = ActivateWebhookParams {
608 start_from: WebhookStartFrom::Latest,
609 };
610 let err = sdk
611 .webhooks
612 .activate_webhook("test-id", ¶ms)
613 .await
614 .unwrap_err();
615 assert!(matches!(err, SdkError::Api { .. }));
616 }
617
618 #[tokio::test]
619 async fn get_enabled_count_success() {
620 let server = MockServer::start().await;
621 Mock::given(method("GET"))
622 .and(path("/webhooks/enabled_count"))
623 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"total": 5})))
624 .mount(&server)
625 .await;
626 let sdk = make_sdk(format!("{}/", server.uri()));
627 let resp = sdk.webhooks.get_enabled_count().await.unwrap();
628 assert_eq!(resp.total, 5);
629 }
630
631 #[tokio::test]
632 async fn get_enabled_count_api_error() {
633 let server = MockServer::start().await;
634 Mock::given(method("GET"))
635 .and(path("/webhooks/enabled_count"))
636 .respond_with(ResponseTemplate::new(400).set_body_string("Bad Request"))
637 .mount(&server)
638 .await;
639 let sdk = make_sdk(format!("{}/", server.uri()));
640 let err = sdk.webhooks.get_enabled_count().await.unwrap_err();
641 assert!(matches!(err, SdkError::Api { .. }));
642 }
643
644 #[tokio::test]
645 async fn create_webhook_from_template_success() {
646 let server = MockServer::start().await;
647 Mock::given(method("POST"))
648 .and(path_regex("/webhooks/template/evmWalletFilter"))
649 .respond_with(ResponseTemplate::new(201).set_body_json(webhook_response_json()))
650 .mount(&server)
651 .await;
652 let sdk = make_sdk(format!("{}/", server.uri()));
653 let template_args = TemplateArgs::evm_wallet_filter(&EvmWalletFilterTemplate {
654 wallets: vec!["0xabc".to_string()],
655 })
656 .unwrap();
657 let params = CreateWebhookFromTemplateParams {
658 name: "test-webhook".to_string(),
659 network: "ethereum-mainnet".to_string(),
660 notification_email: None,
661 destination_attributes: WebhookDestinationAttributes {
662 url: "https://example.com/hook".to_string(),
663 security_token: None,
664 compression: None,
665 },
666 template_args,
667 };
668 let resp = sdk
669 .webhooks
670 .create_webhook_from_template(¶ms)
671 .await
672 .unwrap();
673 assert_eq!(resp.id, "wh-1234-5678");
674 }
675
676 #[tokio::test]
677 async fn create_webhook_from_template_api_error() {
678 let server = MockServer::start().await;
679 Mock::given(method("POST"))
680 .and(path_regex("/webhooks/template/evmWalletFilter"))
681 .respond_with(ResponseTemplate::new(400).set_body_string("Bad Request"))
682 .mount(&server)
683 .await;
684 let sdk = make_sdk(format!("{}/", server.uri()));
685 let template_args = TemplateArgs::evm_wallet_filter(&EvmWalletFilterTemplate {
686 wallets: vec!["0xabc".to_string()],
687 })
688 .unwrap();
689 let params = CreateWebhookFromTemplateParams {
690 name: "test-webhook".to_string(),
691 network: "ethereum-mainnet".to_string(),
692 notification_email: None,
693 destination_attributes: WebhookDestinationAttributes {
694 url: "https://example.com/hook".to_string(),
695 security_token: None,
696 compression: None,
697 },
698 template_args,
699 };
700 let err = sdk
701 .webhooks
702 .create_webhook_from_template(¶ms)
703 .await
704 .unwrap_err();
705 assert!(matches!(err, SdkError::Api { .. }));
706 }
707
708 #[tokio::test]
709 async fn update_webhook_template_success() {
710 let server = MockServer::start().await;
711 Mock::given(method("PATCH"))
712 .and(path_regex("/webhooks/test-id/template/evmWalletFilter"))
713 .respond_with(ResponseTemplate::new(200).set_body_json(webhook_response_json()))
714 .mount(&server)
715 .await;
716 let sdk = make_sdk(format!("{}/", server.uri()));
717 let template_args = TemplateArgs::evm_wallet_filter(&EvmWalletFilterTemplate {
718 wallets: vec!["0xabc".to_string()],
719 })
720 .unwrap();
721 let params = UpdateWebhookTemplateParams {
722 name: None,
723 notification_email: None,
724 destination_attributes: None,
725 template_args,
726 };
727 let resp = sdk
728 .webhooks
729 .update_webhook_template("test-id", ¶ms)
730 .await
731 .unwrap();
732 assert_eq!(resp.id, "wh-1234-5678");
733 }
734
735 #[tokio::test]
736 async fn update_webhook_template_api_error() {
737 let server = MockServer::start().await;
738 Mock::given(method("PATCH"))
739 .and(path_regex("/webhooks/test-id/template/evmWalletFilter"))
740 .respond_with(ResponseTemplate::new(400).set_body_string("Bad Request"))
741 .mount(&server)
742 .await;
743 let sdk = make_sdk(format!("{}/", server.uri()));
744 let template_args = TemplateArgs::evm_wallet_filter(&EvmWalletFilterTemplate {
745 wallets: vec!["0xabc".to_string()],
746 })
747 .unwrap();
748 let params = UpdateWebhookTemplateParams {
749 name: None,
750 notification_email: None,
751 destination_attributes: None,
752 template_args,
753 };
754 let err = sdk
755 .webhooks
756 .update_webhook_template("test-id", ¶ms)
757 .await
758 .unwrap_err();
759 assert!(matches!(err, SdkError::Api { .. }));
760 }
761}