Skip to main content

quicknode_sdk/webhooks/
mod.rs

1pub mod webhook;
2
3pub use webhook::{
4    ActivateWebhookParams, BitcoinWalletFilterByListTemplate, BitcoinWalletFilterInput,
5    BitcoinWalletFilterTemplate, CreateWebhookFromTemplateParams, EvmAbiFilterByListTemplate,
6    EvmAbiFilterInput, EvmAbiFilterTemplate, EvmContractEventsByListTemplate,
7    EvmContractEventsInput, EvmContractEventsTemplate, EvmWalletFilterByListTemplate,
8    EvmWalletFilterInput, EvmWalletFilterTemplate, GetWebhooksParams,
9    HyperliquidWalletEventsFilterByListTemplate, HyperliquidWalletEventsFilterInput,
10    HyperliquidWalletEventsFilterTemplate, ListWebhooksResponse, SolanaWalletFilterByListTemplate,
11    SolanaWalletFilterInput, SolanaWalletFilterTemplate,
12    StellarWalletTransactionsFilterByListTemplate, StellarWalletTransactionsFilterInput,
13    StellarWalletTransactionsFilterTemplate, TemplateArgs, UpdateWebhookParams,
14    UpdateWebhookTemplateParams, Webhook, WebhookDestinationAttributes,
15    WebhookEnabledCountResponse, WebhookPageInfo, WebhookStartFrom, WebhookTemplateId,
16    XrplWalletFilterByListTemplate, XrplWalletFilterInput, XrplWalletFilterTemplate,
17};
18
19use crate::{config::WebhooksConfig, errors::SdkError, SdkConfig};
20
21const WEBHOOKS_BASE_URL: &str = "https://api.quicknode.com/webhooks/rest/v1/";
22
23pub(crate) struct ResolvedWebhooksConfig {
24    pub(crate) base_url: reqwest::Url,
25}
26
27impl ResolvedWebhooksConfig {
28    pub(crate) fn from_config(config: Option<&WebhooksConfig>) -> Result<Self, SdkError> {
29        let url_str = config
30            .and_then(|s| s.base_url.as_deref())
31            .unwrap_or(WEBHOOKS_BASE_URL);
32        let mut base_url = reqwest::Url::parse(url_str)?;
33        if !base_url.path().ends_with('/') {
34            base_url.set_path(&format!("{}/", base_url.path()));
35        }
36        Ok(Self { base_url })
37    }
38}
39
40// ── Client ─────────────────────────────────────────────────────────────────
41
42/// Client for the Quicknode Webhooks REST API. Create webhooks from filter
43/// templates, manage their lifecycle, and update their destinations.
44#[derive(Debug, Clone)]
45pub struct WebhooksApiClient {
46    config: SdkConfig,
47}
48
49impl WebhooksApiClient {
50    pub fn new(config: SdkConfig) -> Self {
51        Self { config }
52    }
53
54    /// Returns a paginated list of webhooks on the account. Each entry includes
55    /// the webhook's identifier, creation timestamp, name, network, notification
56    /// email, destination configuration (URL, security token, compression),
57    /// current status, and any associated template. The response also includes
58    /// a `pageInfo` object with the applied limit, offset, and total count.
59    pub async fn list_webhooks(
60        &self,
61        params: &GetWebhooksParams,
62    ) -> Result<ListWebhooksResponse, SdkError> {
63        let mut url = self.config.webhooks().base_url.join("webhooks")?;
64        {
65            let mut pairs = url.query_pairs_mut();
66            if let Some(v) = params.limit {
67                pairs.append_pair("limit", &v.to_string());
68            }
69            if let Some(v) = params.offset {
70                pairs.append_pair("offset", &v.to_string());
71            }
72        }
73        let resp = self
74            .config
75            .http_client()
76            .get(url)
77            .send()
78            .await
79            .map_err(SdkError::Http)?;
80        let status = resp.status();
81        let body = resp.text().await.map_err(SdkError::Http)?;
82        if !status.is_success() {
83            return Err(SdkError::Api { status, body });
84        }
85        serde_json::from_str(&body).map_err(|source| SdkError::Decode { source, body })
86    }
87
88    /// Removes every webhook on the account. Destructive and takes no
89    /// parameters.
90    pub async fn delete_all_webhooks(&self) -> Result<(), SdkError> {
91        let url = self.config.webhooks().base_url.join("webhooks")?;
92        let resp = self
93            .config
94            .http_client()
95            .delete(url)
96            .send()
97            .await
98            .map_err(SdkError::Http)?;
99        let status = resp.status();
100        if !status.is_success() {
101            let body = resp.text().await.map_err(SdkError::Http)?;
102            return Err(SdkError::Api { status, body });
103        }
104        Ok(())
105    }
106
107    /// Fetches a single webhook's full configuration and status by ID. Returns
108    /// creation timestamp, name, network, notification email, destination
109    /// configuration (URL, security token, compression), the sequence number
110    /// of the last successfully delivered block, the current status, and the
111    /// associated template with its arguments.
112    pub async fn get_webhook(&self, id: &str) -> Result<Webhook, SdkError> {
113        let url = self
114            .config
115            .webhooks()
116            .base_url
117            .join(&format!("webhooks/{id}"))?;
118        let resp = self
119            .config
120            .http_client()
121            .get(url)
122            .send()
123            .await
124            .map_err(SdkError::Http)?;
125        let status = resp.status();
126        let body = resp.text().await.map_err(SdkError::Http)?;
127        if !status.is_success() {
128            return Err(SdkError::Api { status, body });
129        }
130        serde_json::from_str(&body).map_err(|source| SdkError::Decode { source, body })
131    }
132
133    /// Modifies an existing webhook's configuration. Supports updating the
134    /// webhook's name, notification email, and destination attributes (URL,
135    /// security token, and compression — `none` or `gzip`). All fields are
136    /// optional, so partial updates are supported; if the security token is
137    /// omitted on update, one is generated automatically. Returns the
138    /// webhook's full updated configuration.
139    pub async fn update_webhook(
140        &self,
141        id: &str,
142        params: &UpdateWebhookParams,
143    ) -> Result<Webhook, SdkError> {
144        let url = self
145            .config
146            .webhooks()
147            .base_url
148            .join(&format!("webhooks/{id}"))?;
149        let resp = self
150            .config
151            .http_client()
152            .patch(url)
153            .json(params)
154            .send()
155            .await
156            .map_err(SdkError::Http)?;
157        let status = resp.status();
158        let body = resp.text().await.map_err(SdkError::Http)?;
159        if !status.is_success() {
160            return Err(SdkError::Api { status, body });
161        }
162        serde_json::from_str(&body).map_err(|source| SdkError::Decode { source, body })
163    }
164
165    /// Permanently removes a single webhook by ID.
166    pub async fn delete_webhook(&self, id: &str) -> Result<(), SdkError> {
167        let url = self
168            .config
169            .webhooks()
170            .base_url
171            .join(&format!("webhooks/{id}"))?;
172        let resp = self
173            .config
174            .http_client()
175            .delete(url)
176            .send()
177            .await
178            .map_err(SdkError::Http)?;
179        let status = resp.status();
180        if !status.is_success() {
181            let body = resp.text().await.map_err(SdkError::Http)?;
182            return Err(SdkError::Api { status, body });
183        }
184        Ok(())
185    }
186
187    /// Pauses a webhook by ID so it stops delivering events until reactivated.
188    pub async fn pause_webhook(&self, id: &str) -> Result<(), SdkError> {
189        let url = self
190            .config
191            .webhooks()
192            .base_url
193            .join(&format!("webhooks/{id}/pause"))?;
194        let resp = self
195            .config
196            .http_client()
197            .post(url)
198            .send()
199            .await
200            .map_err(SdkError::Http)?;
201        let status = resp.status();
202        if !status.is_success() {
203            let body = resp.text().await.map_err(SdkError::Http)?;
204            return Err(SdkError::Api { status, body });
205        }
206        Ok(())
207    }
208
209    /// Activates a previously created or paused webhook so it begins (or
210    /// resumes) delivering events. `start_from` determines where processing
211    /// resumes: `Latest` begins from the newest available block; other values
212    /// replay from an earlier point.
213    pub async fn activate_webhook(
214        &self,
215        id: &str,
216        params: &ActivateWebhookParams,
217    ) -> Result<(), SdkError> {
218        let url = self
219            .config
220            .webhooks()
221            .base_url
222            .join(&format!("webhooks/{id}/activate"))?;
223        let resp = self
224            .config
225            .http_client()
226            .post(url)
227            .json(params)
228            .send()
229            .await
230            .map_err(SdkError::Http)?;
231        let status = resp.status();
232        if !status.is_success() {
233            let body = resp.text().await.map_err(SdkError::Http)?;
234            return Err(SdkError::Api { status, body });
235        }
236        Ok(())
237    }
238
239    /// Returns the total number of enabled webhooks currently configured on
240    /// the account.
241    pub async fn get_enabled_count(&self) -> Result<WebhookEnabledCountResponse, SdkError> {
242        let url = self
243            .config
244            .webhooks()
245            .base_url
246            .join("webhooks/enabled_count")?;
247        let resp = self
248            .config
249            .http_client()
250            .get(url)
251            .send()
252            .await
253            .map_err(SdkError::Http)?;
254        let status = resp.status();
255        let body = resp.text().await.map_err(SdkError::Http)?;
256        if !status.is_success() {
257            return Err(SdkError::Api { status, body });
258        }
259        serde_json::from_str(&body).map_err(|source| SdkError::Decode { source, body })
260    }
261
262    /// Creates a new webhook from a predefined filter template. Requires a
263    /// descriptive name, a target blockchain network, and destination
264    /// attributes (URL, compression — `gzip` or `none`, and an optional
265    /// security token — auto-generated when omitted). `template_args` carries
266    /// template-specific configuration such as wallet addresses or contract
267    /// filters. An optional `notification_email` receives alerts if the
268    /// webhook terminates.
269    pub async fn create_webhook_from_template(
270        &self,
271        params: &CreateWebhookFromTemplateParams,
272    ) -> Result<Webhook, SdkError> {
273        let template_id = params.template_args.tag().as_str();
274        let url = self
275            .config
276            .webhooks()
277            .base_url
278            .join(&format!("webhooks/template/{template_id}"))?;
279        let resp = self
280            .config
281            .http_client()
282            .post(url)
283            .json(params)
284            .send()
285            .await
286            .map_err(SdkError::Http)?;
287        let status = resp.status();
288        let body = resp.text().await.map_err(SdkError::Http)?;
289        if !status.is_success() {
290            return Err(SdkError::Api { status, body });
291        }
292        serde_json::from_str(&body).map_err(|source| SdkError::Decode { source, body })
293    }
294
295    /// Updates an existing template-backed webhook, modifying its template
296    /// arguments and optionally its name, notification email, and destination
297    /// attributes (URL, security token, compression — `none` or `gzip`).
298    /// All optional fields support partial updates; a security token is
299    /// generated automatically if not provided. Templates cover EVM chains,
300    /// Solana, Bitcoin, XRPL, Hyperliquid, and Stellar.
301    pub async fn update_webhook_template(
302        &self,
303        webhook_id: &str,
304        params: &UpdateWebhookTemplateParams,
305    ) -> Result<Webhook, SdkError> {
306        let template_id = params.template_args.tag().as_str();
307        let url = self
308            .config
309            .webhooks()
310            .base_url
311            .join(&format!("webhooks/{webhook_id}/template/{template_id}"))?;
312        let resp = self
313            .config
314            .http_client()
315            .patch(url)
316            .json(params)
317            .send()
318            .await
319            .map_err(SdkError::Http)?;
320        let status = resp.status();
321        let body = resp.text().await.map_err(SdkError::Http)?;
322        if !status.is_success() {
323            return Err(SdkError::Api { status, body });
324        }
325        serde_json::from_str(&body).map_err(|source| SdkError::Decode { source, body })
326    }
327}
328
329// ── Tests ──────────────────────────────────────────────────────────────────
330
331#[cfg(test)]
332#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
333mod tests {
334    use super::*;
335    use crate::{QuicknodeSdk, SdkFullConfig, WebhooksConfig};
336    use wiremock::matchers::{method, path, path_regex};
337    use wiremock::{Mock, MockServer, ResponseTemplate};
338
339    fn make_sdk(base_url: String) -> QuicknodeSdk {
340        QuicknodeSdk::new(&SdkFullConfig {
341            api_key: "test-key".to_string(),
342            http: None,
343            admin: None,
344            streams: None,
345            webhooks: Some(WebhooksConfig {
346                base_url: Some(base_url),
347            }),
348            kvstore: None,
349            sql: None,
350        })
351        .unwrap()
352    }
353
354    fn webhook_response_json() -> serde_json::Value {
355        serde_json::json!({
356            "id": "wh-1234-5678",
357            "name": "test-webhook",
358            "status": "active",
359            "network": "ethereum-mainnet",
360            "created_at": "2026-03-19T12:00:00Z",
361            "updated_at": "2026-03-19T12:00:00Z"
362        })
363    }
364
365    #[tokio::test]
366    async fn list_webhooks_success() {
367        let server = MockServer::start().await;
368        let response = serde_json::json!({
369            "data": [webhook_response_json()],
370            "pageInfo": {
371                "limit": 20,
372                "offset": 0,
373                "total": 1,
374            }
375        });
376        Mock::given(method("GET"))
377            .and(path("/webhooks"))
378            .respond_with(ResponseTemplate::new(200).set_body_json(response))
379            .mount(&server)
380            .await;
381        let sdk = make_sdk(format!("{}/", server.uri()));
382        let resp = sdk
383            .webhooks
384            .list_webhooks(&GetWebhooksParams::default())
385            .await
386            .unwrap();
387        assert_eq!(resp.data.len(), 1);
388        assert_eq!(resp.data[0].id, "wh-1234-5678");
389        assert_eq!(resp.page_info.limit, 20);
390        assert_eq!(resp.page_info.offset, 0);
391        assert_eq!(resp.page_info.total, 1);
392    }
393
394    #[tokio::test]
395    async fn list_webhooks_api_error() {
396        let server = MockServer::start().await;
397        Mock::given(method("GET"))
398            .and(path("/webhooks"))
399            .respond_with(ResponseTemplate::new(400).set_body_string("Bad Request"))
400            .mount(&server)
401            .await;
402        let sdk = make_sdk(format!("{}/", server.uri()));
403        let err = sdk
404            .webhooks
405            .list_webhooks(&GetWebhooksParams::default())
406            .await
407            .unwrap_err();
408        assert!(matches!(err, SdkError::Api { .. }));
409    }
410
411    #[tokio::test]
412    async fn list_webhooks_server_error() {
413        let server = MockServer::start().await;
414        Mock::given(method("GET"))
415            .and(path("/webhooks"))
416            .respond_with(ResponseTemplate::new(500).set_body_string("Internal Server Error"))
417            .mount(&server)
418            .await;
419        let sdk = make_sdk(format!("{}/", server.uri()));
420        let err = sdk
421            .webhooks
422            .list_webhooks(&GetWebhooksParams::default())
423            .await
424            .unwrap_err();
425        assert!(matches!(err, SdkError::Api { .. }));
426    }
427
428    #[tokio::test]
429    async fn get_webhook_success() {
430        let server = MockServer::start().await;
431        Mock::given(method("GET"))
432            .and(path("/webhooks/test-id"))
433            .respond_with(ResponseTemplate::new(200).set_body_json(webhook_response_json()))
434            .mount(&server)
435            .await;
436        let sdk = make_sdk(format!("{}/", server.uri()));
437        let resp = sdk.webhooks.get_webhook("test-id").await.unwrap();
438        assert_eq!(resp.id, "wh-1234-5678");
439    }
440
441    #[tokio::test]
442    async fn get_webhook_api_error() {
443        let server = MockServer::start().await;
444        Mock::given(method("GET"))
445            .and(path("/webhooks/test-id"))
446            .respond_with(ResponseTemplate::new(404).set_body_string("Not Found"))
447            .mount(&server)
448            .await;
449        let sdk = make_sdk(format!("{}/", server.uri()));
450        let err = sdk.webhooks.get_webhook("test-id").await.unwrap_err();
451        assert!(matches!(err, SdkError::Api { .. }));
452    }
453
454    #[tokio::test]
455    async fn update_webhook_success() {
456        let server = MockServer::start().await;
457        let mut updated = webhook_response_json();
458        updated["name"] = serde_json::json!("updated-name");
459        Mock::given(method("PATCH"))
460            .and(path("/webhooks/test-id"))
461            .respond_with(ResponseTemplate::new(200).set_body_json(updated))
462            .mount(&server)
463            .await;
464        let sdk = make_sdk(format!("{}/", server.uri()));
465        let params = UpdateWebhookParams {
466            name: Some("updated-name".to_string()),
467            ..Default::default()
468        };
469        let resp = sdk
470            .webhooks
471            .update_webhook("test-id", &params)
472            .await
473            .unwrap();
474        assert_eq!(resp.name, "updated-name");
475    }
476
477    #[tokio::test]
478    async fn update_webhook_api_error() {
479        let server = MockServer::start().await;
480        Mock::given(method("PATCH"))
481            .and(path("/webhooks/test-id"))
482            .respond_with(ResponseTemplate::new(400).set_body_string("Bad Request"))
483            .mount(&server)
484            .await;
485        let sdk = make_sdk(format!("{}/", server.uri()));
486        let params = UpdateWebhookParams::default();
487        let err = sdk
488            .webhooks
489            .update_webhook("test-id", &params)
490            .await
491            .unwrap_err();
492        assert!(matches!(err, SdkError::Api { .. }));
493    }
494
495    #[tokio::test]
496    async fn delete_webhook_success() {
497        let server = MockServer::start().await;
498        Mock::given(method("DELETE"))
499            .and(path("/webhooks/test-id"))
500            .respond_with(ResponseTemplate::new(200))
501            .mount(&server)
502            .await;
503        let sdk = make_sdk(format!("{}/", server.uri()));
504        sdk.webhooks.delete_webhook("test-id").await.unwrap();
505    }
506
507    #[tokio::test]
508    async fn delete_webhook_api_error() {
509        let server = MockServer::start().await;
510        Mock::given(method("DELETE"))
511            .and(path("/webhooks/test-id"))
512            .respond_with(ResponseTemplate::new(404).set_body_string("Not Found"))
513            .mount(&server)
514            .await;
515        let sdk = make_sdk(format!("{}/", server.uri()));
516        let err = sdk.webhooks.delete_webhook("test-id").await.unwrap_err();
517        assert!(matches!(err, SdkError::Api { .. }));
518    }
519
520    #[tokio::test]
521    async fn delete_all_webhooks_success() {
522        let server = MockServer::start().await;
523        Mock::given(method("DELETE"))
524            .and(path("/webhooks"))
525            .respond_with(ResponseTemplate::new(204))
526            .mount(&server)
527            .await;
528        let sdk = make_sdk(format!("{}/", server.uri()));
529        sdk.webhooks.delete_all_webhooks().await.unwrap();
530    }
531
532    #[tokio::test]
533    async fn delete_all_webhooks_api_error() {
534        let server = MockServer::start().await;
535        Mock::given(method("DELETE"))
536            .and(path("/webhooks"))
537            .respond_with(ResponseTemplate::new(400).set_body_string("Bad Request"))
538            .mount(&server)
539            .await;
540        let sdk = make_sdk(format!("{}/", server.uri()));
541        let err = sdk.webhooks.delete_all_webhooks().await.unwrap_err();
542        assert!(matches!(err, SdkError::Api { .. }));
543    }
544
545    #[tokio::test]
546    async fn pause_webhook_success() {
547        let server = MockServer::start().await;
548        Mock::given(method("POST"))
549            .and(path("/webhooks/test-id/pause"))
550            .respond_with(ResponseTemplate::new(201))
551            .mount(&server)
552            .await;
553        let sdk = make_sdk(format!("{}/", server.uri()));
554        sdk.webhooks.pause_webhook("test-id").await.unwrap();
555    }
556
557    #[tokio::test]
558    async fn pause_webhook_api_error() {
559        let server = MockServer::start().await;
560        Mock::given(method("POST"))
561            .and(path("/webhooks/test-id/pause"))
562            .respond_with(ResponseTemplate::new(404).set_body_string("Not Found"))
563            .mount(&server)
564            .await;
565        let sdk = make_sdk(format!("{}/", server.uri()));
566        let err = sdk.webhooks.pause_webhook("test-id").await.unwrap_err();
567        assert!(matches!(err, SdkError::Api { .. }));
568    }
569
570    #[tokio::test]
571    async fn activate_webhook_success() {
572        let server = MockServer::start().await;
573        Mock::given(method("POST"))
574            .and(path("/webhooks/test-id/activate"))
575            .respond_with(ResponseTemplate::new(201))
576            .mount(&server)
577            .await;
578        let sdk = make_sdk(format!("{}/", server.uri()));
579        let params = ActivateWebhookParams {
580            start_from: WebhookStartFrom::Latest,
581        };
582        sdk.webhooks
583            .activate_webhook("test-id", &params)
584            .await
585            .unwrap();
586    }
587
588    #[tokio::test]
589    async fn activate_webhook_api_error() {
590        let server = MockServer::start().await;
591        Mock::given(method("POST"))
592            .and(path("/webhooks/test-id/activate"))
593            .respond_with(ResponseTemplate::new(400).set_body_string("Bad Request"))
594            .mount(&server)
595            .await;
596        let sdk = make_sdk(format!("{}/", server.uri()));
597        let params = ActivateWebhookParams {
598            start_from: WebhookStartFrom::Latest,
599        };
600        let err = sdk
601            .webhooks
602            .activate_webhook("test-id", &params)
603            .await
604            .unwrap_err();
605        assert!(matches!(err, SdkError::Api { .. }));
606    }
607
608    #[tokio::test]
609    async fn get_enabled_count_success() {
610        let server = MockServer::start().await;
611        Mock::given(method("GET"))
612            .and(path("/webhooks/enabled_count"))
613            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"total": 5})))
614            .mount(&server)
615            .await;
616        let sdk = make_sdk(format!("{}/", server.uri()));
617        let resp = sdk.webhooks.get_enabled_count().await.unwrap();
618        assert_eq!(resp.total, 5);
619    }
620
621    #[tokio::test]
622    async fn get_enabled_count_api_error() {
623        let server = MockServer::start().await;
624        Mock::given(method("GET"))
625            .and(path("/webhooks/enabled_count"))
626            .respond_with(ResponseTemplate::new(400).set_body_string("Bad Request"))
627            .mount(&server)
628            .await;
629        let sdk = make_sdk(format!("{}/", server.uri()));
630        let err = sdk.webhooks.get_enabled_count().await.unwrap_err();
631        assert!(matches!(err, SdkError::Api { .. }));
632    }
633
634    #[tokio::test]
635    async fn create_webhook_from_template_success() {
636        let server = MockServer::start().await;
637        Mock::given(method("POST"))
638            .and(path_regex("/webhooks/template/evmWalletFilter"))
639            .respond_with(ResponseTemplate::new(201).set_body_json(webhook_response_json()))
640            .mount(&server)
641            .await;
642        let sdk = make_sdk(format!("{}/", server.uri()));
643        let template_args =
644            TemplateArgs::EvmWalletFilter(EvmWalletFilterInput::Inline(EvmWalletFilterTemplate {
645                wallets: vec!["0xabc".to_string()],
646            }));
647        let params = CreateWebhookFromTemplateParams {
648            name: "test-webhook".to_string(),
649            network: "ethereum-mainnet".to_string(),
650            notification_email: None,
651            destination_attributes: WebhookDestinationAttributes {
652                url: "https://example.com/hook".to_string(),
653                security_token: None,
654                compression: "none".to_string(),
655            },
656            template_args,
657        };
658        let resp = sdk
659            .webhooks
660            .create_webhook_from_template(&params)
661            .await
662            .unwrap();
663        assert_eq!(resp.id, "wh-1234-5678");
664    }
665
666    #[tokio::test]
667    async fn create_webhook_from_template_api_error() {
668        let server = MockServer::start().await;
669        Mock::given(method("POST"))
670            .and(path_regex("/webhooks/template/evmWalletFilter"))
671            .respond_with(ResponseTemplate::new(400).set_body_string("Bad Request"))
672            .mount(&server)
673            .await;
674        let sdk = make_sdk(format!("{}/", server.uri()));
675        let template_args =
676            TemplateArgs::EvmWalletFilter(EvmWalletFilterInput::Inline(EvmWalletFilterTemplate {
677                wallets: vec!["0xabc".to_string()],
678            }));
679        let params = CreateWebhookFromTemplateParams {
680            name: "test-webhook".to_string(),
681            network: "ethereum-mainnet".to_string(),
682            notification_email: None,
683            destination_attributes: WebhookDestinationAttributes {
684                url: "https://example.com/hook".to_string(),
685                security_token: None,
686                compression: "none".to_string(),
687            },
688            template_args,
689        };
690        let err = sdk
691            .webhooks
692            .create_webhook_from_template(&params)
693            .await
694            .unwrap_err();
695        assert!(matches!(err, SdkError::Api { .. }));
696    }
697
698    #[tokio::test]
699    async fn update_webhook_template_success() {
700        let server = MockServer::start().await;
701        Mock::given(method("PATCH"))
702            .and(path_regex("/webhooks/test-id/template/evmWalletFilter"))
703            .respond_with(ResponseTemplate::new(200).set_body_json(webhook_response_json()))
704            .mount(&server)
705            .await;
706        let sdk = make_sdk(format!("{}/", server.uri()));
707        let template_args =
708            TemplateArgs::EvmWalletFilter(EvmWalletFilterInput::Inline(EvmWalletFilterTemplate {
709                wallets: vec!["0xabc".to_string()],
710            }));
711        let params = UpdateWebhookTemplateParams {
712            name: None,
713            notification_email: None,
714            destination_attributes: None,
715            template_args,
716        };
717        let resp = sdk
718            .webhooks
719            .update_webhook_template("test-id", &params)
720            .await
721            .unwrap();
722        assert_eq!(resp.id, "wh-1234-5678");
723    }
724
725    // Wire-inspection regression: confirm that `name` reaches the wire when
726    // supplied so any future serde rename/drop of the field fails loudly.
727    #[tokio::test]
728    async fn update_webhook_template_wire_body_includes_name() {
729        use wiremock::matchers::body_partial_json;
730        let server = MockServer::start().await;
731        Mock::given(method("PATCH"))
732            .and(path_regex("/webhooks/test-id/template/evmWalletFilter"))
733            .and(body_partial_json(serde_json::json!({"name": "new-name"})))
734            .respond_with(ResponseTemplate::new(200).set_body_json(webhook_response_json()))
735            .mount(&server)
736            .await;
737        let sdk = make_sdk(format!("{}/", server.uri()));
738        let template_args =
739            TemplateArgs::EvmWalletFilter(EvmWalletFilterInput::Inline(EvmWalletFilterTemplate {
740                wallets: vec!["0xabc".to_string()],
741            }));
742        let params = UpdateWebhookTemplateParams {
743            name: Some("new-name".to_string()),
744            notification_email: None,
745            destination_attributes: None,
746            template_args,
747        };
748        sdk.webhooks
749            .update_webhook_template("test-id", &params)
750            .await
751            .unwrap();
752    }
753
754    // Wire-inspection regression: the API expects `eventHashes` (camelCase)
755    // and returns 500 if it sees `event_hashes`. Confirm the field reaches the
756    // wire under the camelCase key.
757    #[tokio::test]
758    async fn create_webhook_from_template_wire_body_uses_camelcase_event_hashes() {
759        use wiremock::matchers::body_partial_json;
760        let server = MockServer::start().await;
761        Mock::given(method("POST"))
762            .and(path_regex("/webhooks/template/evmContractEvents"))
763            .and(body_partial_json(serde_json::json!({
764                "templateArgs": {
765                    "eventHashes": ["0xabcd"],
766                }
767            })))
768            .respond_with(ResponseTemplate::new(201).set_body_json(webhook_response_json()))
769            .mount(&server)
770            .await;
771        let sdk = make_sdk(format!("{}/", server.uri()));
772        let template_args = TemplateArgs::EvmContractEvents(EvmContractEventsInput::Inline(
773            EvmContractEventsTemplate {
774                contracts: vec!["0xa0b8".to_string()],
775                event_hashes: vec!["0xabcd".to_string()],
776            },
777        ));
778        let params = CreateWebhookFromTemplateParams {
779            name: "test-webhook".to_string(),
780            network: "ethereum-mainnet".to_string(),
781            notification_email: None,
782            destination_attributes: WebhookDestinationAttributes {
783                url: "https://example.com/hook".to_string(),
784                security_token: None,
785                compression: "none".to_string(),
786            },
787            template_args,
788        };
789        sdk.webhooks
790            .create_webhook_from_template(&params)
791            .await
792            .unwrap();
793    }
794
795    #[tokio::test]
796    async fn update_webhook_template_api_error() {
797        let server = MockServer::start().await;
798        Mock::given(method("PATCH"))
799            .and(path_regex("/webhooks/test-id/template/evmWalletFilter"))
800            .respond_with(ResponseTemplate::new(400).set_body_string("Bad Request"))
801            .mount(&server)
802            .await;
803        let sdk = make_sdk(format!("{}/", server.uri()));
804        let template_args =
805            TemplateArgs::EvmWalletFilter(EvmWalletFilterInput::Inline(EvmWalletFilterTemplate {
806                wallets: vec!["0xabc".to_string()],
807            }));
808        let params = UpdateWebhookTemplateParams {
809            name: None,
810            notification_email: None,
811            destination_attributes: None,
812            template_args,
813        };
814        let err = sdk
815            .webhooks
816            .update_webhook_template("test-id", &params)
817            .await
818            .unwrap_err();
819        assert!(matches!(err, SdkError::Api { .. }));
820    }
821}