use serde::Deserialize;
#[derive(Debug, Deserialize)]
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
pub enum WebhookEvent {
#[serde(rename = "payment.succeeded")]
PaymentSucceeded(PaymentSucceeded),
#[serde(rename = "subscription.canceled")]
SubscriptionCanceled(SubscriptionCanceled),
#[serde(other)]
Unknown,
}
#[derive(Debug, Deserialize)]
pub struct PaymentSucceeded {
pub id: String,
pub amount_cents: i64,
pub currency: String,
pub customer_id: String,
}
#[derive(Debug, Deserialize)]
pub struct SubscriptionCanceled {
pub subscription_id: String,
pub customer_id: String,
#[serde(default)]
pub reason: Option<String>,
}
use crate::events::{PaymentSucceeded, SubscriptionCanceled, WebhookEvent};
use anyhow::Result;
use std::sync::Arc;
#[derive(Clone)]
pub struct EventDispatcher {
pub db: Arc<Database>,
}
impl EventDispatcher {
pub async fn dispatch(&self, event: WebhookEvent) -> Result<()> {
match event {
WebhookEvent::PaymentSucceeded(p) => self.on_payment(p).await,
WebhookEvent::SubscriptionCanceled(s) => self.on_cancel(s).await,
WebhookEvent::Unknown => {
tracing::warn!("received unknown webhook event; acknowledging");
Ok(())
}
}
}
async fn on_payment(&self, p: PaymentSucceeded) -> Result<()> {
tracing::info!(id = %p.id, "crediting payment");
self.db.mark_paid(&p.customer_id, p.amount_cents, &p.currency).await?;
Ok(())
}
async fn on_cancel(&self, s: SubscriptionCanceled) -> Result<()> {
tracing::info!(sub = %s.subscription_id, reason = ?s.reason, "canceling");
self.db.deactivate_subscription(&s.subscription_id).await?;
Ok(())
}
}
use crate::dispatch::EventDispatcher;
use crate::events::WebhookEvent;
use axum::{
body::Bytes,
extract::State,
http::{HeaderMap, StatusCode},
};
pub async fn webhook(
State(dispatcher): State<EventDispatcher>,
headers: HeaderMap,
body: Bytes,
) -> StatusCode {
let signature = match headers.get("x-webhook-signature").and_then(|v| v.to_str().ok()) {
Some(sig) => sig,
None => return StatusCode::UNAUTHORIZED,
};
if !verify_signature(&body, signature) {
return StatusCode::UNAUTHORIZED;
}
let event: WebhookEvent = match serde_json::from_slice(&body) {
Ok(event) => event,
Err(err) => {
tracing::warn!(%err, "failed to parse webhook payload");
return StatusCode::BAD_REQUEST;
}
};
match dispatcher.dispatch(event).await {
Ok(()) => StatusCode::OK,
Err(err) => {
tracing::error!(%err, "handler failed; provider will retry");
StatusCode::INTERNAL_SERVER_ERROR
}
}
}
fn verify_signature(body: &[u8], signature: &str) -> bool {
use hmac::{Hmac, Mac};
use sha2::Sha256;
let secret = std::env::var("WEBHOOK_SECRET").unwrap_or_default();
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("valid key");
mac.update(body);
let expected = hex::encode(mac.finalize().into_bytes());
constant_time_eq(expected.as_bytes(), signature.as_bytes())
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
This snippet shows how a webhook receiver in Rust decodes an untyped HTTP body into a strongly typed enum and routes each event to a dedicated handler. The core idea is to lean on Serde's internally tagged enum representation so that the type field in the payload selects the correct variant automatically, turning a runtime string match into a compile-time-exhaustive match.
In events.rs, the WebhookEvent enum uses #[serde(tag = "type", content = "data")] with rename_all so a body like {"type": "payment.succeeded", "data": {...}} deserializes straight into the matching variant. Each variant carries its own payload struct (PaymentSucceeded, SubscriptionCanceled, Unknown), which keeps the parsing logic honest: the data shape is validated against the type tag rather than being a loose serde_json::Value. The #[serde(other)] variant Unknown is important — it prevents a brand-new event type from failing deserialization outright, so the endpoint degrades gracefully instead of returning a hard error when a provider adds new events.
In dispatch.rs, EventDispatcher owns the typed handlers and exposes dispatch, which pattern-matches on the enum. Because the match is exhaustive over WebhookEvent, adding a new event variant forces the compiler to point at every place that must be updated, which is the main safety win of this approach over hand-written string routing. The handlers are async, and the Unknown arm simply logs and returns Ok(()) so unrecognized events are acknowledged rather than retried forever.
In handler.rs, the Axum route reads the raw body first, verifies the signature over those exact bytes with verify_signature, and only then calls serde_json::from_slice. Verifying before parsing matters: signatures are computed over the raw payload, so deserializing first would risk mismatches from reformatting. A malformed body returns 400, a bad signature returns 401, and a handler failure returns 500 so the provider retries. This layering — verify, deserialize, dispatch — is the standard shape for a reliable, typed webhook endpoint, trading a little boilerplate per event for strong guarantees and easy extension.
Related snips
timestamp = request.headers.fetch('X-Signature-Timestamp')
signature = request.headers.fetch('X-Signature')
payload = request.raw_post
data = "#{timestamp}.#{payload}"
expected = OpenSSL::HMAC.hexdigest('SHA256', ENV.fetch('WEBHOOK_SECRET'), data)
HMAC signed API requests for webhook and partner integrity
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
// Creating a Promise
const myPromise = new Promise((resolve, reject) => {
const success = true;
setTimeout(() => {
if (success) {
Promises and async/await patterns for asynchronous JavaScript
export type Settled<R> =
| { status: 'fulfilled'; value: R }
| { status: 'rejected'; reason: unknown };
export interface ConcurrencyOptions {
limit: number;
Simple concurrency limiter for batch operations
package deps
import (
"net"
"net/http"
"time"
HTTP client tuned for production: timeouts, transport, and connection reuse
event_id = request.headers.fetch('X-Event-Id')
timestamp = request.headers.fetch('X-Signature-Timestamp').to_i
raise ActionController::BadRequest, 'stale request' if Time.now.to_i - timestamp > 300
raise ActionController::BadRequest, 'replay detected' if WebhookEvent.exists?(external_id: event_id)
Secure webhook endpoint design with replay protection
Share this code
Here's the card — post it anywhere.