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
| Attribute | Type | Default | Description |
|---|---|---|---|
path | "/path" | required | URL path for the webhook endpoint |
signature | WebhookSignature::* | none | Signature verification configuration |
idempotency | "source:key" | none | Idempotency 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")
| Algorithm | Prefix | Common Providers |
|---|---|---|
hmac_sha256 | sha256= | GitHub, Stripe, Shopify |
hmac_sha1 | sha1= | Legacy GitHub, older services |
hmac_sha512 | sha512= | 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
| Method | Return Type | Description |
|---|---|---|
ctx.db() | &PgPool | Database connection pool |
ctx.http() | &reqwest::Client | HTTP 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
| Field | Type | Description |
|---|---|---|
ctx.webhook_name | String | Name of the webhook function |
ctx.request_id | String | Unique request identifier |
ctx.idempotency_key | Option<String> | Extracted idempotency key |
WebhookResult Types
| Variant | Status | Response Body |
|---|---|---|
WebhookResult::Ok | 200 | {"status": "ok"} |
WebhookResult::Accepted | 202 | {"status": "accepted"} |
WebhookResult::Custom { status_code, body } | custom | custom 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:
- Check if idempotency key exists and is not expired
- If found, return cached response immediately
- 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