rust
61 lines · 8 steps
Idempotent webhook handling in Axum
A shared, time-bounded set of seen event IDs lets an Axum handler drop duplicate webhook deliveries safely across concurrent requests.
Explained by
highlit
1use axum::{
2 extract::State,
3 http::StatusCode,
4 Json,
5};
6use serde::Deserialize;
7use std::{
8 collections::HashMap,
9 sync::Arc,
10 time::{Duration, Instant},
11};
12use tokio::sync::Mutex;
13
14#[derive(Clone)]
15pub struct WebhookDedup {
16 seen: Arc<Mutex<HashMap<String, Instant>>>,
17 ttl: Duration,
18}
19
20impl WebhookDedup {
21 pub fn new(ttl: Duration) -> Self {
22 Self {
23 seen: Arc::new(Mutex::new(HashMap::new())),
24 ttl,
25 }
26 }
27
28 async fn check_and_mark(&self, event_id: &str) -> bool {
29 let now = Instant::now();
30 let mut seen = self.seen.lock().await;
31 seen.retain(|_, ts| now.duration_since(*ts) < self.ttl);
32 if seen.contains_key(event_id) {
33 return false;
34 }
35 seen.insert(event_id.to_owned(), now);
36 true
37 }
38}
39
40#[derive(Deserialize)]
41pub struct WebhookEvent {
42 id: String,
43 #[serde(rename = "type")]
44 kind: String,
45 payload: serde_json::Value,
46}
47
48pub async fn handle_webhook(
49 State(dedup): State<WebhookDedup>,
50 Json(event): Json<WebhookEvent>,
51) -> StatusCode {
52 if !dedup.check_and_mark(&event.id).await {
53 tracing::info!(event_id = %event.id, "skipping duplicate webhook delivery");
54 return StatusCode::OK;
55 }
56
57 tracing::info!(event_id = %event.id, kind = %event.kind, "processing webhook");
58 ProcessWebhook::enqueue(event.kind, event.payload).await;
59
60 StatusCode::ACCEPTED
61}
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1Webhook providers retry deliveries, so handlers must be idempotent to avoid processing the same event twice.
- 2Wrapping shared state in Arc<Mutex<...>> lets Axum clone it per request while serializing access to the map.
- 3Pruning expired entries on every check keeps the dedup set bounded without a separate cleanup task.
Related explainers
rust
use serde::Deserialize; #[derive(Debug, Deserialize)] #[serde(untagged)]
Parsing flexible JSON shapes with serde
deserialization
enums
json
Intermediate
6 steps
rust
use std::cmp::{Ordering, Reverse}; use std::collections::BinaryHeap; use std::fs::File; use std::io::{self, BufRead, BufReader, BufWriter, Lines, Write};
K-way merge of sorted logs in Rust
binary-heap
k-way-merge
streaming-io
Intermediate
8 steps
typescript
import { Inject, Injectable, Logger } from '@nestjs/common'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Cache } from 'cache-manager'; import { InjectRepository } from '@nestjs/typeorm';
A cache-aside country lookup in NestJS
cache-aside
dependency-injection
batch-lookup
Intermediate
8 steps
rust
use axum::{ extract::{Path, State}, response::sse::{Event, KeepAlive, Sse}, };
Streaming import progress with SSE in Axum
server-sent-events
streams
watch-channel
Advanced
7 steps
rust
use std::f64::consts::PI; #[derive(Debug, Clone, Copy)] pub struct LatLng {
Building geographic bounding boxes in Rust
geospatial
value-types
option
Intermediate
7 steps
go
func (w *Watcher) resetDebounce(d time.Duration) { if !w.timer.Stop() { select { case <-w.timer.C:
Debouncing a stream of events in Go
debounce
timers
channels
Advanced
7 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/idempotent-webhook-handling-in-axum-explained-rust-5272/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.