Skip to content

Webhook Handler (AWS Lambda) ​

A webhook receiver deployed to AWS Lambda that verifies signatures from GitHub and Stripe, dispatches events, and integrates with the security block for JWT auth and auto-sanitization.

The Full Application ​

Create webhooks.tova:

tova
shared {
  type WebhookEvent {
    id: String
    source: String
    event_type: String
    timestamp: Int
  }

  type WebhookResult {
    accepted: Bool
    event_id: String
  }
}

security {
  auth jwt { secret: env("JWT_SECRET") }

  role Admin { can: [manage_webhooks, view_events] }
  role Service { can: [view_events] }

  protect "/api/admin/*" { require: Admin }
  protect "/api/events/*" { require: authenticated }

  sensitive WebhookEvent.source {
    visible_to: [Admin]
  }
}

edge {
  target: "lambda"

  env APP_ENV = "production"
  secret JWT_SECRET
  secret GITHUB_WEBHOOK_SECRET
  secret STRIPE_WEBHOOK_SECRET

  cors {
    origins: ["https://myapp.com"]
    methods: [GET, POST]
    headers: ["Content-Type", "Authorization"]
  }

  on_error fn(err, req) {
    print("Webhook error: {err.message}")
    { error: "Internal server error", request_id: crypto.randomUUID() }
  }

  // In-memory event log (in production, use DynamoDB or similar)
  events = []

  // ── Signature Verification ────────────────────────────────────

  async fn verify_github_signature(payload, signature) -> Bool {
    if signature == nil { false }
    else {
      encoder = TextEncoder.new()
      key_data = encoder.encode(GITHUB_WEBHOOK_SECRET)
      key = await crypto.subtle.importKey(
        "raw", key_data, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]
      )
      sig_data = await crypto.subtle.sign("HMAC", key, encoder.encode(payload))
      hex = Array.from(Uint8Array.new(sig_data))
        |> map(fn(b) b.toString(16).padStart(2, "0"))
        |> join("")
      expected = "sha256={hex}"
      expected == signature
    }
  }

  async fn verify_stripe_signature(payload, signature) -> Bool {
    if signature == nil { false }
    else {
      parts = signature.split(",")
      timestamp_part = parts |> find(fn(p) p.startsWith("t="))
      sig_part = parts |> find(fn(p) p.startsWith("v1="))
      if timestamp_part == nil || sig_part == nil { false }
      else {
        ts = timestamp_part.slice(2)
        signed_payload = "{ts}.{payload}"
        encoder = TextEncoder.new()
        key_data = encoder.encode(STRIPE_WEBHOOK_SECRET)
        key = await crypto.subtle.importKey(
          "raw", key_data, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]
        )
        sig_data = await crypto.subtle.sign("HMAC", key, encoder.encode(signed_payload))
        hex = Array.from(Uint8Array.new(sig_data))
          |> map(fn(b) b.toString(16).padStart(2, "0"))
          |> join("")
        expected_sig = sig_part.slice(3)
        hex == expected_sig
      }
    }
  }

  // ── Event Dispatch ────────────────────────────────────────────

  fn dispatch_event(source, event_type, payload) {
    event_id = crypto.randomUUID()
    event = WebhookEvent(event_id, source, event_type, Date.now())
    events.push(event)

    match source {
      "github" => match event_type {
        "push" => print("GitHub push to {payload.repository.full_name}")
        "pull_request" => print("GitHub PR #{payload.number}: {payload.action}")
        "issues" => print("GitHub issue #{payload.issue.number}: {payload.action}")
        _ => print("GitHub event: {event_type}")
      }
      "stripe" => match event_type {
        "payment_intent.succeeded" => print("Payment succeeded: {payload.data.object.id}")
        "invoice.paid" => print("Invoice paid: {payload.data.object.id}")
        _ => print("Stripe event: {event_type}")
      }
      _ => print("Unknown source: {source}")
    }

    event_id
  }

  // ── Routes ────────────────────────────────────────────────────

  // GitHub webhook endpoint (signature verified, no JWT)
  route POST "/webhooks/github" => async fn(req) {
    body = await req.text()
    signature = req.headers.get("X-Hub-Signature-256")
    event_type = req.headers.get("X-GitHub-Event") || "unknown"

    valid = await verify_github_signature(body, signature)
    if valid == false {
      Response.new(JSON.stringify({ error: "Invalid signature" }), { status: 401 })
    } else {
      payload = JSON.parse(body)
      event_id = await dispatch_event("github", event_type, payload)
      WebhookResult(true, event_id)
    }
  }

  // Stripe webhook endpoint (signature verified, no JWT)
  route POST "/webhooks/stripe" => async fn(req) {
    body = await req.text()
    signature = req.headers.get("Stripe-Signature")
    payload = JSON.parse(body)
    event_type = payload.type || "unknown"

    valid = await verify_stripe_signature(body, signature)
    if valid == false {
      Response.new(JSON.stringify({ error: "Invalid signature" }), { status: 401 })
    } else {
      event_id = await dispatch_event("stripe", event_type, payload)
      WebhookResult(true, event_id)
    }
  }

  // List recent events (requires authentication)
  route GET "/api/events/recent" => fn(req) {
    // Auto-sanitize strips `source` field for non-Admin users
    recent = events.slice(-50)
    { events: recent, total: len(events) }
  }

  // Admin: get event by ID
  route GET "/api/admin/events/:id" => fn(req, params) {
    event = events |> find(fn(e) e.id == params.id)
    if event == nil {
      Response.new(JSON.stringify({ error: "Event not found" }), { status: 404 })
    } else {
      { event: event }
    }
  }

  // Admin: clear event log
  route POST "/api/admin/events/clear" => fn(req) {
    count = len(events)
    events = []
    { cleared: count }
  }
}

Running It ​

Build and deploy to AWS Lambda:

bash
tova build webhooks.tova
cp .tova-out/webhooks.edge.js index.mjs

Package and deploy with SAM:

bash
sam init  # If you don't have a template.yaml yet
sam deploy --guided

Or use the AWS CDK, Serverless Framework, or manually upload to Lambda with an API Gateway trigger.

Set environment variables in Lambda:

bash
aws lambda update-function-configuration \
  --function-name my-webhooks \
  --environment "Variables={JWT_SECRET=...,GITHUB_WEBHOOK_SECRET=...,STRIPE_WEBHOOK_SECRET=...}"

Test the webhooks locally with SAM:

bash
sam local start-api

# Send a GitHub webhook
curl -X POST http://localhost:3000/webhooks/github \
  -H "Content-Type: application/json" \
  -H "X-GitHub-Event: push" \
  -H "X-Hub-Signature-256: sha256=..." \
  -d '{"repository": {"full_name": "org/repo"}, "ref": "refs/heads/main"}'

What This Demonstrates ​

Lambda Target ​

Setting target: "lambda" generates export const handler for API Gateway integration. The compiler creates a request adapter that converts Lambda's event format (with event.rawPath, event.headers, event.body) into a standard Request object, so route handlers work the same as on other targets.

Webhook Signature Verification ​

Both GitHub and Stripe webhooks use HMAC-SHA256 signatures. The verification functions use the Web Crypto API (crypto.subtle.importKey and crypto.subtle.sign) which is available on all edge runtimes, including Lambda@Edge and standard Lambda with Node.js 18+.

GitHub sends X-Hub-Signature-256: sha256=<hex>. Stripe sends Stripe-Signature: t=<timestamp>,v1=<hex> with a timestamp-prefixed payload. Each gets its own verification function.

Security on Lambda ​

The security {} block generates JWT authentication that works on Lambda. The compiler adds __authenticate(request) using Web Crypto API, __checkProtection(pathname, user) for route-level access control, and __autoSanitize(result, user) for response filtering.

Webhook endpoints (/webhooks/*) are not protected by JWT -- they use their own signature verification. The /api/events/* and /api/admin/* endpoints require JWT tokens.

Auto-Sanitization ​

The sensitive WebhookEvent.source { visible_to: [Admin] } rule means that when non-Admin users query /api/events/recent, the source field is stripped from each event in the response. Admin users see the full data. The compiler generates this filtering automatically.

Lambda Event Adapter ​

The generated code adapts Lambda's API Gateway event format into a standard Request. The adapter:

  • Creates Request from event.rawPath + event.headers + event.body
  • Adds a headers.get() method for case-insensitive header access
  • Extracts path parameters from the URL pattern matching
  • Converts the handler's Response back to Lambda's expected format

Key Patterns ​

  • Webhook endpoints skip JWT -- they use HMAC signature verification instead
  • Web Crypto API works across all edge targets for cryptographic operations
  • Pattern matching for dispatch -- nested match routes events by source and type
  • Pipe expressions simplify data transforms (events |> find(fn(e) ...))
  • Security block integrates automatically with the Lambda target

What's Next ​

Released under the MIT License.