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
| Attribute | Type | Default | Description |
|---|---|---|---|
path | "/path" | required | URL path for the webhook endpoint |
signature | WebhookSignature::* | none | Signature verification configuration |
allow_unsigned | flag | false | Accept requests without signature verification |
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 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
| Method | Return Type | Description |
|---|---|---|
ctx.db() | &PgPool | Database connection pool |
ctx.db_conn() | DbConn<'_> | Database connection as DbConn for shared helpers |
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 |
ctx.cancel_job(job_id, reason) | Result<bool> | Cancel a dispatched job |
ctx.env(key) | Option<String> | Environment variable |
ctx.env_or(key, default) | String | Environment 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) | T | Parse environment variable with default |
ctx.env_contains(key) | bool | Check if environment variable is set |
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 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:
- Attempt to claim the idempotency key atomically
- If already claimed and not expired, return immediately
- 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