Skip to main content

Incoming Webhooks

Handle incoming webhook events with signature verification and idempotency controls.

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 do not use JWT authentication; the signature serves as the authentication check.

Attributes

AttributeTypeDefaultDescription
path"/path"requiredURL path for the webhook endpoint
signatureWebhookSignature::*noneSignature verification configuration
allow_unsignedflagfalseAccept requests without signature verification
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 Verification

For development or trusted networks. Requires allow_unsigned to opt in. Prefer signed webhooks in production.

#[forge::webhook(
path = "/webhooks/internal",
allow_unsigned,
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.db_conn()DbConn<'_>Database connection as DbConn for shared helpers
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
ctx.cancel_job(job_id, reason)Result<bool>Cancel a dispatched job
ctx.env(key)Option<String>Environment variable
ctx.env_or(key, default)StringEnvironment variable with default
ctx.env_require(key)Result<String>Required environment variable
ctx.env_parse::<T>(key)Result<T>Parse environment variable
ctx.env_parse_or(key, default)TParse environment variable with default
ctx.env_contains(key)boolCheck if environment variable is set

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 atomically claims idempotency keys before processing:

INSERT INTO forge_webhook_events (idempotency_key, webhook_name, processed_at, expires_at)
VALUES ($1, $2, NOW(), $3)
ON CONFLICT (webhook_name, idempotency_key) DO UPDATE
SET processed_at = EXCLUDED.processed_at, expires_at = EXCLUDED.expires_at
WHERE forge_webhook_events.expires_at < NOW()

When the same webhook arrives again:

  1. Attempt to claim the idempotency key atomically
  2. If already claimed and not expired, return immediately
  3. If the handler fails or times out, the key is released so retries can proceed

Idempotency keys are scoped per webhook. The same key can appear in different webhooks without conflict. The composite primary key (webhook_name, idempotency_key) guarantees exactly-once processing per webhook without distributed locks or external coordination 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