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