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",
}
}
}
use axum::{http::StatusCode, response::IntoResponse, Json};
use tracing::info;
use crate::events::WebhookEvent;
pub async fn handle_webhook(Json(event): Json<WebhookEvent>) -> impl IntoResponse {
info!(kind = event.discriminator(), "received webhook");
match event {
WebhookEvent::PaymentSucceeded { amount_cents, currency } => {
credit_ledger(amount_cents, ¤cy).await;
StatusCode::OK
}
WebhookEvent::PaymentFailed { decline_code, .. } => {
info!(%decline_code, "payment declined, scheduling retry");
enqueue_retry().await;
StatusCode::OK
}
WebhookEvent::SubscriptionCanceled { subscription_id, detail } => {
info!(%subscription_id, reason = %detail.reason, "subscription canceled");
StatusCode::OK
}
}
}
async fn credit_ledger(_amount_cents: u64, _currency: &str) {}
async fn enqueue_retry() {}
use crate::events::{CancelReason, WebhookEvent};
use serde_json::json;
#[test]
fn serializes_with_type_discriminator() {
let event = WebhookEvent::PaymentSucceeded {
amount_cents: 1200,
currency: "usd".into(),
};
let value = serde_json::to_value(&event).unwrap();
assert_eq!(
value,
json!({ "type": "payment_succeeded", "amount_cents": 1200, "currency": "usd" })
);
}
#[test]
fn flattens_nested_detail_fields() {
let event = WebhookEvent::SubscriptionCanceled {
subscription_id: "sub_42".into(),
detail: CancelReason { reason: "too_expensive".into(), feedback: None },
};
let value = serde_json::to_value(&event).unwrap();
assert_eq!(value["type"], "subscription_canceled");
assert_eq!(value["reason"], "too_expensive");
}
#[test]
fn roundtrips_through_json() {
let raw = json!({
"type": "payment_failed",
"amount_cents": 500,
"currency": "eur",
"decline_code": "insufficient_funds"
});
let decoded: WebhookEvent = serde_json::from_value(raw.clone()).unwrap();
assert_eq!(serde_json::to_value(&decoded).unwrap(), raw);
}
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
payload = {
sub: user.id,
iss: 'https://auth.example.com',
aud: 'codesnips-api',
exp: 15.minutes.from_now.to_i,
iat: Time.now.to_i,
JWT issuance and verification without common footguns
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
struct Config<'a> {
name: &'a str,
value: &'a str,
}
fn parse_config(line: &str) -> Config {
Lifetime annotations for flexible borrowing in structs
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
createdAt: String!
GraphQL API with Spring Boot
use crossbeam::channel::unbounded;
use std::thread;
fn main() {
let (tx, rx) = unbounded();
Crossbeam for advanced concurrent data structures
use tracing::{info, instrument};
#[instrument]
fn process_request(user_id: u64) {
info!(user_id, "Processing request");
// Work happens here
tracing for structured logging and distributed tracing
Share this code
Here's the card — post it anywhere.