rust 101 lines · 3 tabs

Internally Tagged Enum JSON Serialization for a Rust Webhook API

Shared by codesnips Aug 2026
3 tabs
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CancelReason {
    pub reason: String,
    pub feedback: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum WebhookEvent {
    PaymentSucceeded {
        amount_cents: u64,
        currency: String,
    },
    PaymentFailed {
        amount_cents: u64,
        currency: String,
        decline_code: String,
    },
    SubscriptionCanceled {
        subscription_id: String,
        #[serde(flatten)]
        detail: CancelReason,
    },
}

impl WebhookEvent {
    pub fn discriminator(&self) -> &'static str {
        match self {
            WebhookEvent::PaymentSucceeded { .. } => "payment_succeeded",
            WebhookEvent::PaymentFailed { .. } => "payment_failed",
            WebhookEvent::SubscriptionCanceled { .. } => "subscription_canceled",
        }
    }
}
3 files · rust Explain with highlit

This snippet shows how to model a webhook payload as a Rust enum whose variants carry different data, then serialize and deserialize it as JSON using serde's internally tagged representation. The pattern is common in HTTP APIs where a single endpoint receives events of several shapes that share a discriminator field.

In the events.rs model, the WebhookEvent enum uses #[serde(tag = "type", rename_all = "snake_case")]. The tag attribute selects the internally tagged encoding: instead of wrapping each variant in a nested object, serde flattens the variant's fields alongside a type key. So PaymentSucceeded { amount_cents, currency } becomes {"type":"payment_succeeded","amount_cents":1200,"currency":"usd"}. This is the shape most JSON APIs (Stripe, GitHub) actually use, which is why it is preferred over serde's default externally tagged form. The rename_all on both the enum and individual variants keeps the JSON idiomatic without hand-writing every field name.

A subtlety worth noting: internally tagged enums require the variant data to serialize as a map, so newtype variants must wrap structs, not primitives, and untagged/flatten interactions can be fragile. The SubscriptionCanceled variant demonstrates carrying a nested struct via #[serde(flatten)] so its fields also appear at the top level rather than under a sub-key.

The dispatch.rs handler shows the consumer side. axum's Json<WebhookEvent> extractor runs serde deserialization automatically, so an unknown type value yields a 422 before any handler logic runs. The match over the decoded enum is exhaustive, meaning the compiler forces every event kind to be handled — a strong guarantee that raw JSON parsing cannot give.

The roundtrip_tests.rs tab locks the wire format down. serde_json::to_value and from_value verify that the discriminator appears exactly as type and that a full round trip preserves the value, guarding against accidental renames that would silently break API clients. Together the files demonstrate why pushing the tagging decision into the type system — rather than branching on strings — produces safer, self-documenting API code.


Related snips

Share this code

Here's the card — post it anywhere.

Internally Tagged Enum JSON Serialization for a Rust Webhook API — share card
Link copied