Skip to main content

Incoming Webhooks

Receive events from external services with signature verification and exactly-once delivery.

The Code

use forge::prelude::*;

#[forge::webhook(
path = "/webhooks/github",
signature = WebhookSignature::hmac_sha256("X-Hub-Signature-256", "GITHUB_WEBHOOK_SECRET"),
idempotency = "header:X-GitHub-Delivery",
)]
pub async fn github_webhook(ctx: &WebhookContext, payload: Value) -> Result<WebhookResult> {
let event = ctx.header("X-GitHub-Event").unwrap_or("unknown");

ctx.dispatch_job("process_github_event", json!({
"event": event,
"payload": payload,
})).await?;

Ok(WebhookResult::Accepted)
}

What Happens

The macro generates an HTTP endpoint that:

  • Validates HMAC signatures against your secret
  • Extracts idempotency keys from headers or body
  • Skips duplicate deliveries using the inbox pattern
  • Returns immediately while jobs process in the background

Webhooks bypass JWT authentication. The signature is your authentication.

Attributes

AttributeTypeDefaultDescription
path"/path"requiredURL path for the webhook endpoint
signatureWebhookSignature::*noneSignature verification configuration
idempotency"source:key"noneIdempotency key extraction
timeout"duration""30s"Request timeout

Signature Configuration

WebhookSignature::hmac_sha256("X-Hub-Signature-256", "GITHUB_SECRET")
WebhookSignature::hmac_sha1("X-Hub-Signature", "LEGACY_SECRET")
WebhookSignature::hmac_sha512("X-Signature-512", "SECURE_SECRET")
AlgorithmPrefixCommon Providers
hmac_sha256sha256=GitHub, Stripe, Shopify
hmac_sha1sha1=Legacy GitHub, older services
hmac_sha512sha512=Custom implementations

First argument is the header name. Second is the environment variable containing your secret.

Idempotency Sources

// From header
idempotency = "header:X-Request-Id"

// From body using JSONPath
idempotency = "body:$.id"
idempotency = "body:$.data.delivery_id"

Patterns

Stripe Webhooks

Verify Stripe signatures and dispatch payment processing.

#[forge::webhook(
path = "/webhooks/stripe",
signature = WebhookSignature::hmac_sha256("Stripe-Signature", "STRIPE_WEBHOOK_SECRET"),
idempotency = "body:$.id",
)]
pub async fn stripe_webhook(ctx: &WebhookContext, payload: Value) -> Result<WebhookResult> {
let event_type = payload.get("type")
.and_then(|v| v.as_str())
.unwrap_or("unknown");

match event_type {
"payment_intent.succeeded" => {
ctx.dispatch_job("process_payment", payload.clone()).await?;
}
"customer.subscription.deleted" => {
ctx.dispatch_job("handle_cancellation", payload.clone()).await?;
}
_ => {
tracing::debug!(event_type, "Unhandled Stripe event");
}
}

Ok(WebhookResult::Accepted)
}

Webhook with Database Access

Store webhook events directly before dispatching.

#[forge::webhook(
path = "/webhooks/orders",
signature = WebhookSignature::hmac_sha256("X-Signature", "ORDER_WEBHOOK_SECRET"),
idempotency = "header:X-Idempotency-Key",
)]
pub async fn order_webhook(ctx: &WebhookContext, payload: Value) -> Result<WebhookResult> {
let idempotency_key = ctx.idempotency_key.clone().unwrap_or_default();

// Store the raw event
sqlx::query(
"INSERT INTO webhook_events (idempotency_key, source, payload, received_at)
VALUES ($1, 'orders', $2, NOW())"
)
.bind(&idempotency_key)
.bind(&payload)
.execute(ctx.db())
.await?;

// Process asynchronously
ctx.dispatch_job("process_order_event", payload.clone()).await?;

Ok(WebhookResult::Accepted)
}

Custom Response Codes

Return specific status codes for provider compatibility.

#[forge::webhook(
path = "/webhooks/custom",
signature = WebhookSignature::hmac_sha256("X-Signature", "WEBHOOK_SECRET"),
)]
pub async fn custom_webhook(ctx: &WebhookContext, payload: Value) -> Result<WebhookResult> {
// Validate payload structure
let action = payload.get("action").and_then(|v| v.as_str());

match action {
Some("ping") => Ok(WebhookResult::Ok),
Some("process") => {
ctx.dispatch_job("process_event", payload.clone()).await?;
Ok(WebhookResult::Accepted)
}
None => Ok(WebhookResult::Custom {
status_code: 400,
body: json!({"error": "Missing action field"}),
}),
_ => Ok(WebhookResult::Custom {
status_code: 422,
body: json!({"error": "Unknown action"}),
}),
}
}

Webhook Without Signature

For development or trusted networks. Prefer signed webhooks in production.

#[forge::webhook(
path = "/webhooks/internal",
idempotency = "body:$.request_id",
)]
pub async fn internal_webhook(ctx: &WebhookContext, payload: Value) -> Result<WebhookResult> {
ctx.dispatch_job("internal_event", payload.clone()).await?;
Ok(WebhookResult::Accepted)
}

Header Inspection

Access any request header for routing or logging.

#[forge::webhook(
path = "/webhooks/events",
signature = WebhookSignature::hmac_sha256("X-Signature", "SECRET"),
)]
pub async fn events_webhook(ctx: &WebhookContext, payload: Value) -> Result<WebhookResult> {
let event_type = ctx.header("X-Event-Type");
let source = ctx.header("X-Source");
let timestamp = ctx.header("X-Timestamp");

tracing::info!(
event_type = ?event_type,
source = ?source,
timestamp = ?timestamp,
"Webhook received"
);

ctx.dispatch_job("process_event", json!({
"type": event_type,
"source": source,
"payload": payload,
})).await?;

Ok(WebhookResult::Accepted)
}

Context Methods

MethodReturn TypeDescription
ctx.db()&PgPoolDatabase connection pool
ctx.http()&reqwest::ClientHTTP client for external calls
ctx.header(name)Option<&str>Get header value (case-insensitive)
ctx.headers()&HashMap<String, String>All request headers
ctx.dispatch_job(name, args)Result<Uuid>Dispatch background job

Context Fields

FieldTypeDescription
ctx.webhook_nameStringName of the webhook function
ctx.request_idStringUnique request identifier
ctx.idempotency_keyOption<String>Extracted idempotency key

WebhookResult Types

VariantStatusResponse Body
WebhookResult::Ok200{"status": "ok"}
WebhookResult::Accepted202{"status": "accepted"}
WebhookResult::Custom { status_code, body }customcustom JSON

Use Accepted when dispatching jobs for async processing. Use Ok when processing completes synchronously.

Under the Hood

Inbox Pattern for Exactly-Once Delivery

Forge stores idempotency keys before processing:

INSERT INTO forge_webhook_events (idempotency_key, webhook_name, processed_at, expires_at)
VALUES ($1, $2, NOW(), $3)
ON CONFLICT (idempotency_key) DO NOTHING

When the same webhook arrives again:

  1. Check if idempotency key exists and is not expired
  2. If found, return cached response immediately
  3. If not found, process and record the key

The unique constraint guarantees exactly-once processing. No distributed locks, no external services.

TTL-Based Cleanup

Idempotency records expire after 24 hours by default. Forge periodically purges expired records to prevent unbounded table growth.

Constant-Time Signature Verification

HMAC verification uses timing-safe comparison to prevent timing attacks. The comparison takes the same time regardless of where the signatures differ.

// Constant-time comparison under the hood
mac.verify_slice(&expected).is_ok()

JSONPath Extraction

Body-based idempotency uses dot notation to traverse JSON:

// "body:$.data.id" extracts from:
{"data": {"id": "abc-123"}}

// Returns "abc-123"

Supports strings, numbers, and nested objects. Missing paths result in no idempotency key (each request treated as unique).

Testing

Use TestWebhookContext to test webhooks in isolation.

#[cfg(test)]
mod tests {
use super::*;
use forge::testing::*;

#[tokio::test]
async fn test_github_webhook_dispatches_job() {
let ctx = TestWebhookContext::builder("github_webhook")
.with_header("X-GitHub-Event", "push")
.with_idempotency_key("delivery-123")
.build();

let payload = json!({
"ref": "refs/heads/main",
"commits": []
});

let result = github_webhook(&ctx, payload).await.unwrap();

assert!(matches!(result, WebhookResult::Accepted));
ctx.job_dispatch().assert_dispatched("process_github_event");
}

#[tokio::test]
async fn test_webhook_with_mocked_http() {
let ctx = TestWebhookContext::builder("test_webhook")
.mock_http_json("https://api.example.com/*", json!({"ok": true}))
.build();

// Call webhook that makes external request...

ctx.http().assert_called("https://api.example.com/*");
}

#[test]
fn test_header_access() {
let ctx = TestWebhookContext::builder("test")
.with_header("X-Custom-Header", "custom-value")
.with_header("Content-Type", "application/json")
.build();

assert_eq!(ctx.header("X-Custom-Header"), Some("custom-value"));
assert_eq!(ctx.header("x-custom-header"), Some("custom-value")); // case-insensitive
}
}

Test context builder methods:

  • .with_header(name, value) - Add request header
  • .with_headers(map) - Add multiple headers
  • .with_idempotency_key(key) - Set idempotency key
  • .with_pool(pool) - Use real database
  • .mock_http(pattern, handler) - Mock HTTP responses
  • .mock_http_json(pattern, value) - Mock JSON response
  • .with_env(key, value) - Mock environment variable