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        })
350        .unwrap()
351    }
352
353    fn webhook_response_json() -> serde_json::Value {
354        serde_json::json!({
355            "id": "wh-1234-5678",
356            "name": "test-webhook",
357            "status": "active",
358            "network": "ethereum-mainnet",
359            "created_at": "2026-03-19T12:00:00Z",
360            "updated_at": "2026-03-19T12:00:00Z"
361        })
362    }
363
364    #[tokio::test]
365    async fn list_webhooks_success() {
366        let server = MockServer::start().await;
367        let response = serde_json::json!({
368            "data": [webhook_response_json()],
369            "pageInfo": {
370                "limit": 20,
371                "offset": 0,
372                "total": 1,
373            }
374        });
375        Mock::given(method("GET"))
376            .and(path("/webhooks"))
377            .respond_with(ResponseTemplate::new(200).set_body_json(response))
378            .mount(&server)
379            .await;
380        let sdk = make_sdk(format!("{}/", server.uri()));
381        let resp = sdk
382            .webhooks
383            .list_webhooks(&GetWebhooksParams::default())
384            .await
385            .unwrap();
386        assert_eq!(resp.data.len(), 1);
387        assert_eq!(resp.data[0].id, "wh-1234-5678");
388        assert_eq!(resp.page_info.limit, 20);
389        assert_eq!(resp.page_info.offset, 0);
390        assert_eq!(resp.page_info.total, 1);
391    }
392
393    #[tokio::test]
394    async fn list_webhooks_api_error() {
395        let server = MockServer::start().await;
396        Mock::given(method("GET"))
397            .and(path("/webhooks"))
398            .respond_with(ResponseTemplate::new(400).set_body_string("Bad Request"))
399            .mount(&server)
400            .await;
401        let sdk = make_sdk(format!("{}/", server.uri()));
402        let err = sdk
403            .webhooks
404            .list_webhooks(&GetWebhooksParams::default())
405            .await
406            .unwrap_err();
407        assert!(matches!(err, SdkError::Api { .. }));
408    }
409
410    #[tokio::test]
411    async fn list_webhooks_server_error() {
412        let server = MockServer::start().await;
413        Mock::given(method("GET"))
414            .and(path("/webhooks"))
415            .respond_with(ResponseTemplate::new(500).set_body_string("Internal Server Error"))
416            .mount(&server)
417            .await;
418        let sdk = make_sdk(format!("{}/", server.uri()));
419        let err = sdk
420            .webhooks
421            .list_webhooks(&GetWebhooksParams::default())
422            .await
423            .unwrap_err();
424        assert!(matches!(err, SdkError::Api { .. }));
425    }
426
427    #[tokio::test]
428    async fn get_webhook_success() {
429        let server = MockServer::start().await;
430        Mock::given(method("GET"))
431            .and(path("/webhooks/test-id"))
432            .respond_with(ResponseTemplate::new(200).set_body_json(webhook_response_json()))
433            .mount(&server)
434            .await;
435        let sdk = make_sdk(format!("{}/", server.uri()));
436        let resp = sdk.webhooks.get_webhook("test-id").await.unwrap();
437        assert_eq!(resp.id, "wh-1234-5678");
438    }
439
440    #[tokio::test]
441    async fn get_webhook_api_error() {
442        let server = MockServer::start().await;
443        Mock::given(method("GET"))
444            .and(path("/webhooks/test-id"))
445            .respond_with(ResponseTemplate::new(404).set_body_string("Not Found"))
446            .mount(&server)
447            .await;
448        let sdk = make_sdk(format!("{}/", server.uri()));
449        let err = sdk.webhooks.get_webhook("test-id").await.unwrap_err();
450        assert!(matches!(err, SdkError::Api { .. }));
451    }
452
453    #[tokio::test]
454    async fn update_webhook_success() {
455        let server = MockServer::start().await;
456        let mut updated = webhook_response_json();
457        updated["name"] = serde_json::json!("updated-name");
458        Mock::given(method("PATCH"))
459            .and(path("/webhooks/test-id"))
460            .respond_with(ResponseTemplate::new(200).set_body_json(updated))
461            .mount(&server)
462            .await;
463        let sdk = make_sdk(format!("{}/", server.uri()));
464        let params = UpdateWebhookParams {
465            name: Some("updated-name".to_string()),
466            ..Default::default()
467        };
468        let resp = sdk
469            .webhooks
470            .update_webhook("test-id", &params)
471            .await
472            .unwrap();
473        assert_eq!(resp.name, "updated-name");
474    }
475
476    #[tokio::test]
477    async fn update_webhook_api_error() {
478        let server = MockServer::start().await;
479        Mock::given(method("PATCH"))
480            .and(path("/webhooks/test-id"))
481            .respond_with(ResponseTemplate::new(400).set_body_string("Bad Request"))
482            .mount(&server)
483            .await;
484        let sdk = make_sdk(format!("{}/", server.uri()));
485        let params = UpdateWebhookParams::default();
486        let err = sdk
487            .webhooks
488            .update_webhook("test-id", &params)
489            .await
490            .unwrap_err();
491        assert!(matches!(err, SdkError::Api { .. }));
492    }
493
494    #[tokio::test]
495    async fn delete_webhook_success() {
496        let server = MockServer::start().await;
497        Mock::given(method("DELETE"))
498            .and(path("/webhooks/test-id"))
499            .respond_with(ResponseTemplate::new(200))
500            .mount(&server)
501            .await;
502        let sdk = make_sdk(format!("{}/", server.uri()));
503        sdk.webhooks.delete_webhook("test-id").await.unwrap();
504    }
505
506    #[tokio::test]
507    async fn delete_webhook_api_error() {
508        let server = MockServer::start().await;
509        Mock::given(method("DELETE"))
510            .and(path("/webhooks/test-id"))
511            .respond_with(ResponseTemplate::new(404).set_body_string("Not Found"))
512            .mount(&server)
513            .await;
514        let sdk = make_sdk(format!("{}/", server.uri()));
515        let err = sdk.webhooks.delete_webhook("test-id").await.unwrap_err();
516        assert!(matches!(err, SdkError::Api { .. }));
517    }
518
519    #[tokio::test]
520    async fn delete_all_webhooks_success() {
521        let server = MockServer::start().await;
522        Mock::given(method("DELETE"))
523            .and(path("/webhooks"))
524            .respond_with(ResponseTemplate::new(204))
525            .mount(&server)
526            .await;
527        let sdk = make_sdk(format!("{}/", server.uri()));
528        sdk.webhooks.delete_all_webhooks().await.unwrap();
529    }
530
531    #[tokio::test]
532    async fn delete_all_webhooks_api_error() {
533        let server = MockServer::start().await;
534        Mock::given(method("DELETE"))
535            .and(path("/webhooks"))
536            .respond_with(ResponseTemplate::new(400).set_body_string("Bad Request"))
537            .mount(&server)
538            .await;
539        let sdk = make_sdk(format!("{}/", server.uri()));
540        let err = sdk.webhooks.delete_all_webhooks().await.unwrap_err();
541        assert!(matches!(err, SdkError::Api { .. }));
542    }
543
544    #[tokio::test]
545    async fn pause_webhook_success() {
546        let server = MockServer::start().await;
547        Mock::given(method("POST"))
548            .and(path("/webhooks/test-id/pause"))
549            .respond_with(ResponseTemplate::new(201))
550            .mount(&server)
551            .await;
552        let sdk = make_sdk(format!("{}/", server.uri()));
553        sdk.webhooks.pause_webhook("test-id").await.unwrap();
554    }
555
556    #[tokio::test]
557    async fn pause_webhook_api_error() {
558        let server = MockServer::start().await;
559        Mock::given(method("POST"))
560            .and(path("/webhooks/test-id/pause"))
561            .respond_with(ResponseTemplate::new(404).set_body_string("Not Found"))
562            .mount(&server)
563            .await;
564        let sdk = make_sdk(format!("{}/", server.uri()));
565        let err = sdk.webhooks.pause_webhook("test-id").await.unwrap_err();
566        assert!(matches!(err, SdkError::Api { .. }));
567    }
568
569    #[tokio::test]
570    async fn activate_webhook_success() {
571        let server = MockServer::start().await;
572        Mock::given(method("POST"))
573            .and(path("/webhooks/test-id/activate"))
574            .respond_with(ResponseTemplate::new(201))
575            .mount(&server)
576            .await;
577        let sdk = make_sdk(format!("{}/", server.uri()));
578        let params = ActivateWebhookParams {
579            start_from: WebhookStartFrom::Latest,
580        };
581        sdk.webhooks
582            .activate_webhook("test-id", &params)
583            .await
584            .unwrap();
585    }
586
587    #[tokio::test]
588    async fn activate_webhook_api_error() {
589        let server = MockServer::start().await;
590        Mock::given(method("POST"))
591            .and(path("/webhooks/test-id/activate"))
592            .respond_with(ResponseTemplate::new(400).set_body_string("Bad Request"))
593            .mount(&server)
594            .await;
595        let sdk = make_sdk(format!("{}/", server.uri()));
596        let params = ActivateWebhookParams {
597            start_from: WebhookStartFrom::Latest,
598        };
599        let err = sdk
600            .webhooks
601            .activate_webhook("test-id", &params)
602            .await
603            .unwrap_err();
604        assert!(matches!(err, SdkError::Api { .. }));
605    }
606
607    #[tokio::test]
608    async fn get_enabled_count_success() {
609        let server = MockServer::start().await;
610        Mock::given(method("GET"))
611            .and(path("/webhooks/enabled_count"))
612            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"total": 5})))
613            .mount(&server)
614            .await;
615        let sdk = make_sdk(format!("{}/", server.uri()));
616        let resp = sdk.webhooks.get_enabled_count().await.unwrap();
617        assert_eq!(resp.total, 5);
618    }
619
620    #[tokio::test]
621    async fn get_enabled_count_api_error() {
622        let server = MockServer::start().await;
623        Mock::given(method("GET"))
624            .and(path("/webhooks/enabled_count"))
625            .respond_with(ResponseTemplate::new(400).set_body_string("Bad Request"))
626            .mount(&server)
627            .await;
628        let sdk = make_sdk(format!("{}/", server.uri()));
629        let err = sdk.webhooks.get_enabled_count().await.unwrap_err();
630        assert!(matches!(err, SdkError::Api { .. }));
631    }
632
633    #[tokio::test]
634    async fn create_webhook_from_template_success() {
635        let server = MockServer::start().await;
636        Mock::given(method("POST"))
637            .and(path_regex("/webhooks/template/evmWalletFilter"))
638            .respond_with(ResponseTemplate::new(201).set_body_json(webhook_response_json()))
639            .mount(&server)
640            .await;
641        let sdk = make_sdk(format!("{}/", server.uri()));
642        let template_args =
643            TemplateArgs::EvmWalletFilter(EvmWalletFilterInput::Inline(EvmWalletFilterTemplate {
644                wallets: vec!["0xabc".to_string()],
645            }));
646        let params = CreateWebhookFromTemplateParams {
647            name: "test-webhook".to_string(),
648            network: "ethereum-mainnet".to_string(),
649            notification_email: None,
650            destination_attributes: WebhookDestinationAttributes {
651                url: "https://example.com/hook".to_string(),
652                security_token: None,
653                compression: "none".to_string(),
654            },
655            template_args,
656        };
657        let resp = sdk
658            .webhooks
659            .create_webhook_from_template(&params)
660            .await
661            .unwrap();
662        assert_eq!(resp.id, "wh-1234-5678");
663    }
664
665    #[tokio::test]
666    async fn create_webhook_from_template_api_error() {
667        let server = MockServer::start().await;
668        Mock::given(method("POST"))
669            .and(path_regex("/webhooks/template/evmWalletFilter"))
670            .respond_with(ResponseTemplate::new(400).set_body_string("Bad Request"))
671            .mount(&server)
672            .await;
673        let sdk = make_sdk(format!("{}/", server.uri()));
674        let template_args =
675            TemplateArgs::EvmWalletFilter(EvmWalletFilterInput::Inline(EvmWalletFilterTemplate {
676                wallets: vec!["0xabc".to_string()],
677            }));
678        let params = CreateWebhookFromTemplateParams {
679            name: "test-webhook".to_string(),
680            network: "ethereum-mainnet".to_string(),
681            notification_email: None,
682            destination_attributes: WebhookDestinationAttributes {
683                url: "https://example.com/hook".to_string(),
684                security_token: None,
685                compression: "none".to_string(),
686            },
687            template_args,
688        };
689        let err = sdk
690            .webhooks
691            .create_webhook_from_template(&params)
692            .await
693            .unwrap_err();
694        assert!(matches!(err, SdkError::Api { .. }));
695    }
696
697    #[tokio::test]
698    async fn update_webhook_template_success() {
699        let server = MockServer::start().await;
700        Mock::given(method("PATCH"))
701            .and(path_regex("/webhooks/test-id/template/evmWalletFilter"))
702            .respond_with(ResponseTemplate::new(200).set_body_json(webhook_response_json()))
703            .mount(&server)
704            .await;
705        let sdk = make_sdk(format!("{}/", server.uri()));
706        let template_args =
707            TemplateArgs::EvmWalletFilter(EvmWalletFilterInput::Inline(EvmWalletFilterTemplate {
708                wallets: vec!["0xabc".to_string()],
709            }));
710        let params = UpdateWebhookTemplateParams {
711            name: None,
712            notification_email: None,
713            destination_attributes: None,
714            template_args,
715        };
716        let resp = sdk
717            .webhooks
718            .update_webhook_template("test-id", &params)
719            .await
720            .unwrap();
721        assert_eq!(resp.id, "wh-1234-5678");
722    }
723
724    // Wire-inspection regression: confirm that `name` reaches the wire when
725    // supplied so any future serde rename/drop of the field fails loudly.
726    #[tokio::test]
727    async fn update_webhook_template_wire_body_includes_name() {
728        use wiremock::matchers::body_partial_json;
729        let server = MockServer::start().await;
730        Mock::given(method("PATCH"))
731            .and(path_regex("/webhooks/test-id/template/evmWalletFilter"))
732            .and(body_partial_json(serde_json::json!({"name": "new-name"})))
733            .respond_with(ResponseTemplate::new(200).set_body_json(webhook_response_json()))
734            .mount(&server)
735            .await;
736        let sdk = make_sdk(format!("{}/", server.uri()));
737        let template_args =
738            TemplateArgs::EvmWalletFilter(EvmWalletFilterInput::Inline(EvmWalletFilterTemplate {
739                wallets: vec!["0xabc".to_string()],
740            }));
741        let params = UpdateWebhookTemplateParams {
742            name: Some("new-name".to_string()),
743            notification_email: None,
744            destination_attributes: None,
745            template_args,
746        };
747        sdk.webhooks
748            .update_webhook_template("test-id", &params)
749            .await
750            .unwrap();
751    }
752
753    // Wire-inspection regression: the API expects `eventHashes` (camelCase)
754    // and returns 500 if it sees `event_hashes`. Confirm the field reaches the
755    // wire under the camelCase key.
756    #[tokio::test]
757    async fn create_webhook_from_template_wire_body_uses_camelcase_event_hashes() {
758        use wiremock::matchers::body_partial_json;
759        let server = MockServer::start().await;
760        Mock::given(method("POST"))
761            .and(path_regex("/webhooks/template/evmContractEvents"))
762            .and(body_partial_json(serde_json::json!({
763                "templateArgs": {
764                    "eventHashes": ["0xabcd"],
765                }
766            })))
767            .respond_with(ResponseTemplate::new(201).set_body_json(webhook_response_json()))
768            .mount(&server)
769            .await;
770        let sdk = make_sdk(format!("{}/", server.uri()));
771        let template_args = TemplateArgs::EvmContractEvents(EvmContractEventsInput::Inline(
772            EvmContractEventsTemplate {
773                contracts: vec!["0xa0b8".to_string()],
774                event_hashes: vec!["0xabcd".to_string()],
775            },
776        ));
777        let params = CreateWebhookFromTemplateParams {
778            name: "test-webhook".to_string(),
779            network: "ethereum-mainnet".to_string(),
780            notification_email: None,
781            destination_attributes: WebhookDestinationAttributes {
782                url: "https://example.com/hook".to_string(),
783                security_token: None,
784                compression: "none".to_string(),
785            },
786            template_args,
787        };
788        sdk.webhooks
789            .create_webhook_from_template(&params)
790            .await
791            .unwrap();
792    }
793
794    #[tokio::test]
795    async fn update_webhook_template_api_error() {
796        let server = MockServer::start().await;
797        Mock::given(method("PATCH"))
798            .and(path_regex("/webhooks/test-id/template/evmWalletFilter"))
799            .respond_with(ResponseTemplate::new(400).set_body_string("Bad Request"))
800            .mount(&server)
801            .await;
802        let sdk = make_sdk(format!("{}/", server.uri()));
803        let template_args =
804            TemplateArgs::EvmWalletFilter(EvmWalletFilterInput::Inline(EvmWalletFilterTemplate {
805                wallets: vec!["0xabc".to_string()],
806            }));
807        let params = UpdateWebhookTemplateParams {
808            name: None,
809            notification_email: None,
810            destination_attributes: None,
811            template_args,
812        };
813        let err = sdk
814            .webhooks
815            .update_webhook_template("test-id", &params)
816            .await
817            .unwrap_err();
818        assert!(matches!(err, SdkError::Api { .. }));
819    }
820}