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
python
import threading from functools import wraps
A thread-safe debounce decorator in Python
decorators
debounce
threading
Advanced
6 steps
go
package middleware import ( "log"
Per-IP rate limiting middleware in Gin
rate-limiting
middleware
concurrency
Intermediate
8 steps
java
@Component public class InventoryReconciliationJob { private static final Logger log = LoggerFactory.getLogger(InventoryReconciliationJob.class);
Distributed scheduled jobs with Spring locks
distributed-locking
scheduling
concurrency
Advanced
8 steps
rust
use bytes::{Buf, BufMut, BytesMut}; use tokio_util::codec::{Decoder, Encoder}; const MAX_FRAME: usize = 8 * 1024 * 1024;
A length-prefixed frame codec in Tokio
framing
codecs
buffers
Intermediate
8 steps
rust
use axum::{ body::{Body, Bytes}, extract::Request, http::StatusCode,
Logging request bodies in Axum middleware
middleware
streaming-body
logging
Intermediate
8 steps
rust
use std::time::Duration; use axum::{ body::Body,
Turning Axum timeouts into 504 JSON
middleware
timeouts
error-handling
Intermediate
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.