Skip to main content

quicknode_sdk/webhooks/
mod.rs

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