Skip to main content

boxxkite_client/
webhooks.rs

1//! Outbound webhook subscriptions (`docs/WEBHOOKS-DESIGN.md`):
2//! `POST/GET/DELETE /v1/webhooks*` and `GET /v1/webhooks/{id}/deliveries`.
3//! Mirrors `sdk-python`'s `create_webhook`/`list_webhooks`/`delete_webhook`/
4//! `list_webhook_deliveries`.
5//!
6//! Push, out-of-process notifications on sandbox lifecycle events -- not
7//! the same thing as this crate's own `AuditSink`-style pull model (which
8//! doesn't exist here at all; that's a `src/boxxkite` embedding concept, not
9//! part of the hosted control-plane's HTTP surface this crate wraps).
10
11use reqwest::Method;
12use serde::{Deserialize, Serialize};
13
14use crate::client::Client;
15use crate::error::BoxxkiteError;
16
17/// Event types a webhook subscription can receive. See
18/// `docs/WEBHOOKS-DESIGN.md` for the full event catalog.
19///
20/// `AuditLogEntry` added per GitHub issue #125 -- this enum had drifted out
21/// of sync with the control plane's own `WebhookEventType` literal
22/// (control-plane/src/control_plane/schemas.py).
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
24pub enum WebhookEventType {
25    #[serde(rename = "sandbox.created")]
26    SandboxCreated,
27    #[serde(rename = "sandbox.destroyed")]
28    SandboxDestroyed,
29    #[serde(rename = "audit_log.entry")]
30    AuditLogEntry,
31}
32
33/// Builder for `POST /v1/webhooks`'s optional fields.
34#[derive(Debug, Clone, Default, Serialize)]
35pub struct CreateWebhookOptions {
36    #[serde(skip_serializing_if = "Option::is_none")]
37    description: Option<String>,
38}
39
40impl CreateWebhookOptions {
41    pub fn new() -> Self {
42        Self::default()
43    }
44
45    /// Optional caller-supplied label for this subscription (e.g. `"Slack notifier"`).
46    pub fn description(mut self, description: impl Into<String>) -> Self {
47        self.description = Some(description.into());
48        self
49    }
50}
51
52/// A webhook subscription (`WebhookOut`/`WebhookCreatedResponse`). `secret`
53/// is only ever populated on [`Client::create_webhook`]'s response -- the
54/// raw signing secret, shown exactly once; it cannot be retrieved again.
55#[derive(Debug, Clone, Deserialize)]
56pub struct Webhook {
57    pub id: String,
58    pub url: String,
59    pub event_types: Vec<String>,
60    pub description: Option<String>,
61    pub is_active: bool,
62    #[serde(default)]
63    pub payload_format: String,
64    pub created_at: String,
65    pub last_triggered_at: Option<String>,
66    /// The raw signing secret. Only present on [`Client::create_webhook`]'s
67    /// response -- use it to verify the `X-Boxxkite-Webhook-Signature`
68    /// header on every delivery.
69    pub secret: Option<String>,
70}
71
72/// One delivery attempt for a webhook subscription
73/// (`GET /v1/webhooks/{id}/deliveries`'s `WebhookDeliveryOut`).
74#[derive(Debug, Clone, Deserialize)]
75pub struct WebhookDelivery {
76    pub id: String,
77    pub event_type: String,
78    /// `"pending"`, `"delivered"`, or `"failed"`.
79    pub status: String,
80    pub attempt_count: i64,
81    pub next_attempt_at: String,
82    pub last_attempt_at: Option<String>,
83    pub response_status_code: Option<i32>,
84    pub failure_reason: Option<String>,
85    pub created_at: String,
86    pub delivered_at: Option<String>,
87}
88
89/// Optional `list_webhook_deliveries` pagination parameters.
90#[derive(Debug, Clone, Default)]
91pub struct ListWebhookDeliveriesOptions {
92    limit: Option<u32>,
93    offset: Option<u32>,
94}
95
96impl ListWebhookDeliveriesOptions {
97    pub fn new() -> Self {
98        Self::default()
99    }
100
101    /// Maximum number of entries to return (server default 20, max 100).
102    pub fn limit(mut self, limit: u32) -> Self {
103        self.limit = Some(limit);
104        self
105    }
106
107    /// Number of entries to skip, newest-first.
108    pub fn offset(mut self, offset: u32) -> Self {
109        self.offset = Some(offset);
110        self
111    }
112}
113
114impl Client {
115    /// `POST /v1/webhooks` -- register a webhook subscription.
116    ///
117    /// `url` is the HTTPS (or HTTP, for local testing) URL the control
118    /// plane will POST events to; `event_types` must be non-empty.
119    pub async fn create_webhook(
120        &self,
121        url: &str,
122        event_types: &[WebhookEventType],
123        options: CreateWebhookOptions,
124    ) -> Result<Webhook, BoxxkiteError> {
125        #[derive(Serialize)]
126        struct Body<'a> {
127            url: &'a str,
128            event_types: &'a [WebhookEventType],
129            #[serde(skip_serializing_if = "Option::is_none")]
130            description: Option<&'a str>,
131        }
132        let body = Body {
133            url,
134            event_types,
135            description: options.description.as_deref(),
136        };
137        let builder = self.request(Method::POST, "/v1/webhooks").json(&body);
138        self.send(builder).await
139    }
140
141    /// `GET /v1/webhooks` -- webhook subscriptions for this account. The
142    /// signing secret is never returned here.
143    pub async fn list_webhooks(&self) -> Result<Vec<Webhook>, BoxxkiteError> {
144        let builder = self.request(Method::GET, "/v1/webhooks");
145        self.send_or_default(builder).await
146    }
147
148    /// `DELETE /v1/webhooks/{id}` -- delete a webhook subscription owned by
149    /// this account. 404s if already gone or never owned by this account.
150    pub async fn delete_webhook(&self, subscription_id: &str) -> Result<(), BoxxkiteError> {
151        let builder = self.request(Method::DELETE, &format!("/v1/webhooks/{subscription_id}"));
152        self.send_no_content(builder).await
153    }
154
155    /// `GET /v1/webhooks/{id}/deliveries` -- recent delivery attempts
156    /// (pending/delivered/failed) for this subscription, newest first.
157    pub async fn list_webhook_deliveries(
158        &self,
159        subscription_id: &str,
160        options: ListWebhookDeliveriesOptions,
161    ) -> Result<Vec<WebhookDelivery>, BoxxkiteError> {
162        let mut query = Vec::new();
163        if let Some(limit) = options.limit {
164            query.push(("limit", limit.to_string()));
165        }
166        if let Some(offset) = options.offset {
167            query.push(("offset", offset.to_string()));
168        }
169        let builder = self
170            .request(
171                Method::GET,
172                &format!("/v1/webhooks/{subscription_id}/deliveries"),
173            )
174            .query(&query);
175        self.send_or_default(builder).await
176    }
177}