Receive Email Webhooks in Rust

Complete guide to integrating JsonHook with Rust. Working code examples for webhook handling, signature verification, and payload parsing.

Table of Contents
  1. Quick Start: Rust Email Webhook
  2. Full Rust Implementation
  3. Parsing the Webhook Payload
  4. Verifying Webhook Signatures
  5. Error Handling Best Practices
  6. Rust Ecosystem Tips

Quick Start: Rust Email Webhook

JsonHook delivers every inbound email as a JSON POST request to your webhook endpoint. Setting up a Rust handler takes less than 5 minutes. Start by initializing your project:

cargo new webhook-app && cd webhook-app
# Add to Cargo.toml:
# axum = "0.7"
# tokio = { version = "1", features = ["full"] }
# serde = { version = "1", features = ["derive"] }
# serde_json = "1"
# hmac = "0.12"
# sha2 = "0.10"
# hex = "0.4"

Then create your webhook endpoint. The following example shows the minimal code needed to receive and acknowledge a JsonHook delivery:

use axum::{routing::post, Router, extract::Request, http::StatusCode};
use axum::body::to_bytes;
use serde_json::Value;

async fn webhook(req: Request) -> StatusCode {
    let body = to_bytes(req.into_body(), usize::MAX).await.unwrap();
    let payload: Value = serde_json::from_slice(&body).unwrap();
    let from = payload["email"]["from"].as_str().unwrap_or("");
    let subject = payload["email"]["subject"].as_str().unwrap_or("");
    println!("Email from: {} | Subject: {}", from, subject);
    StatusCode::OK
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/webhook", post(webhook));
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Point your JsonHook address webhook URL to this endpoint and you will start receiving parsed emails as JSON within seconds of the email arriving.

Full Rust Implementation

The quick start example above is enough to get started, but a production implementation should include signature verification, structured error handling, and proper HTTP response codes. The complete example below demonstrates all of these patterns together.

This implementation verifies the X-JsonHook-Signature header to confirm the request genuinely came from JsonHook, parses the full email payload, and returns the appropriate HTTP status codes to trigger or suppress retries.

use axum::{
    extract::Request,
    http::{HeaderMap, StatusCode},
    routing::post,
    Router,
};
use axum::body::to_bytes;
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use std::env;

type HmacSha256 = Hmac;

#[derive(Deserialize, Debug)]
struct Attachment {
    filename: String,
    #[serde(rename = "contentType")]
    content_type: String,
    size: u64,
}

#[derive(Deserialize, Debug)]
struct Email {
    from: String,
    to: Vec,
    subject: String,
    #[serde(rename = "textBody")]
    text_body: String,
    #[serde(rename = "htmlBody")]
    html_body: String,
    attachments: Vec,
}

#[derive(Deserialize, Debug)]
struct Payload {
    event: String,
    timestamp: String,
    address: String,
    email: Email,
}

fn verify_signature(body: &[u8], sig_header: &str, secret: &[u8]) -> bool {
    if sig_header.is_empty() { return false; }
    let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC init failed");
    mac.update(body);
    let computed = hex::encode(mac.finalize().into_bytes());
    // constant_time_eq prevents timing attacks
    constant_time_eq::constant_time_eq(computed.as_bytes(), sig_header.as_bytes())
}

async fn webhook(headers: HeaderMap, req: Request) -> StatusCode {
    let secret = env::var("JSONHOOK_WEBHOOK_SECRET").unwrap_or_default();
    let body = match to_bytes(req.into_body(), 10 * 1024 * 1024).await {
        Ok(b) => b,
        Err(_) => return StatusCode::INTERNAL_SERVER_ERROR,
    };

    let sig = headers
        .get("x-jsonhook-signature")
        .and_then(|v| v.to_str().ok())
        .unwrap_or("");

    if !verify_signature(&body, sig, secret.as_bytes()) {
        eprintln!("Invalid signature");
        return StatusCode::UNAUTHORIZED;
    }

    let payload: Payload = match serde_json::from_slice(&body) {
        Ok(p) => p,
        Err(_) => return StatusCode::BAD_REQUEST,
    };

    println!("[{}] Email at {} from {}", payload.timestamp, payload.address, payload.email.from);
    println!("Subject: {}", payload.email.subject);
    for att in &payload.email.attachments {
        println!("Attachment: {} ({}, {} bytes)", att.filename, att.content_type, att.size);
    }

    StatusCode::OK
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/webhook", post(webhook));
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    println!("Listening on :3000");
    axum::serve(listener, app).await.unwrap();
}

The webhook handler returns 200 immediately after queuing the email for processing. Avoid doing expensive work (database writes, API calls) synchronously inside the handler — process the payload in a background job to stay within JsonHook's 10-second response timeout.

Build Your Rust Email Integration

Free API key — start receiving webhooks in 5 minutes.

Get Free API Key

Parsing the Webhook Payload

Every JsonHook delivery is an HTTP POST with Content-Type: application/json. The payload follows a consistent schema regardless of the originating email client or provider:

use serde::Deserialize;

#[derive(Deserialize)]
struct Payload {
    event: String,      // "email.received"
    timestamp: String,  // "2026-03-15T12:34:56.789Z"
    address: String,    // "[email protected]"
    email: Email,
}

#[derive(Deserialize)]
struct Email {
    from: String,
    to: Vec,
    subject: String,
    #[serde(rename = "textBody")]
    text_body: String,
    #[serde(rename = "htmlBody")]
    html_body: String,
    attachments: Vec,
}

#[derive(Deserialize)]
struct Attachment {
    filename: String,
    #[serde(rename = "contentType")]
    content_type: String,
    size: u64,
}

// After reading body bytes and verifying signature:
let payload: Payload = serde_json::from_slice(&body)?;
println!("{} from {}", payload.email.subject, payload.email.from);

Key fields in the payload:

  • event — Always "email.received" for inbound email events
  • timestamp — ISO 8601 timestamp of when JsonHook received the email
  • address — The JsonHook inbound address that received the email (e.g., [email protected])
  • email.from — Sender address string, e.g., "Alice <[email protected]>"
  • email.to — Array of recipient address strings
  • email.subject — Email subject line
  • email.textBody — Plain text body of the email (may be empty if HTML-only)
  • email.htmlBody — HTML body of the email (may be empty if plain-text-only)
  • email.attachments — Array of attachment objects, each with filename, contentType, size, and contentId

Verifying Webhook Signatures

JsonHook signs every webhook delivery using HMAC-SHA256. The signature is included in the X-JsonHook-Signature request header as a hex digest. To verify it, compute the HMAC-SHA256 of the raw request body using your address's webhook secret and compare it to the header value.

Your webhook secret is returned when you create an inbound address via the API (POST /api/addresses). Store it as an environment variable — never hard-code it.

use hmac::{Hmac, Mac};
use sha2::Sha256;

type HmacSha256 = Hmac;

fn verify_jsonhook_signature(body: &[u8], sig_header: &str, secret: &[u8]) -> bool {
    if sig_header.is_empty() { return false; }
    let mut mac = HmacSha256::new_from_slice(secret)
        .expect("HMAC accepts keys of any length");
    mac.update(body);
    let computed = hex::encode(mac.finalize().into_bytes());
    // Use constant-time comparison to prevent timing attacks
    constant_time_eq::constant_time_eq(computed.as_bytes(), sig_header.as_bytes())
}

Always verify the signature before processing the payload. Return 401 for invalid signatures so that legitimate retries from JsonHook (which always include a valid signature) are distinguishable from spoofed requests.

Error Handling Best Practices

Reliable webhook handling requires careful attention to error responses. JsonHook uses your HTTP response code to decide whether to retry a delivery:

  • Return 200 quickly: Acknowledge receipt immediately and process asynchronously. JsonHook will retry any non-2xx response.
  • Return 400 for bad requests: If the payload fails your own validation (not signature — use 401 for that), return 400 to prevent retries of malformed deliveries.
  • Return 500 to trigger retries: If your downstream system is temporarily unavailable, returning 500 causes JsonHook to retry with exponential backoff (up to 5 attempts over ~1 hour).
  • Never return 200 before verifying the signature: Doing so silently accepts spoofed requests.

Rust ecosystem tips:

  • Use to_bytes(body, max_size) with a reasonable byte limit (e.g., 10 MB) to prevent memory exhaustion from oversized requests
  • Add the constant_time_eq crate for signature comparison — Rust's == on strings is not guaranteed constant-time
  • Use tokio::spawn to process the email payload asynchronously after returning the 200 status, keeping your handler latency minimal
  • Set tower_http::timeout::TimeoutLayer on your router to enforce a maximum request duration and return 408 on timeout

Rust Ecosystem Tips

The Rust ecosystem offers several libraries and patterns that pair well with JsonHook webhook handling. Here are general recommendations:

  • Use a well-maintained HTTP server library appropriate for your use case — the examples in this guide use the most common choice, but any library that gives you raw body access works.
  • Store your webhook secret in an environment variable and load it via your language's standard env access pattern — never commit secrets to version control.
  • Use your language's standard HMAC library rather than a third-party package — all languages featured in this guide have HMAC-SHA256 in their standard library.
  • Consider a structured logging library to capture the address, event, and timestamp fields from every webhook delivery for observability.
  • Test your handler locally using a tunneling tool like ngrok or a local webhook testing service before pointing your JsonHook address at a production URL.

Frequently Asked Questions

How do I receive JsonHook webhooks in Rust?
Create an HTTP endpoint in your Rust application that accepts POST requests with a JSON body. Register the endpoint URL in your JsonHook inbound address configuration. When email arrives at your JsonHook address, JsonHook will POST the parsed email as JSON to your endpoint. See the complete code example on this page for a production-ready implementation including signature verification.
Does JsonHook work with Rust?
Yes. JsonHook works with any HTTP server that can receive POST requests — Rust is fully supported. JsonHook delivers a standard application/json POST with an HMAC-SHA256 signature header. There is no SDK or library required; you use your language or framework's standard HTTP and crypto libraries.
How do I verify webhook signatures in Rust?
Read the raw request body bytes before any JSON parsing, then compute HMAC-SHA256 of those bytes using your webhook secret as the key. Compare the resulting hex digest to the value of the X-JsonHook-Signature header. Use a constant-time comparison function to prevent timing attacks. Return 401 if the signatures do not match. The full code example is shown in the "Verifying Webhook Signatures" section above.
What does the JsonHook payload look like in Rust?
The payload is a JSON object with an event string ("email.received"), a timestamp ISO string, an address string (the receiving JsonHook address), and an email object containing from, to, subject, textBody, htmlBody, and attachments. In Rust, parse it with your standard JSON library and access fields as you would any JSON object. See the "Parsing the Webhook Payload" section for a complete example.