> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sturdytechnologies.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook Security

> Verify and secure your webhook endpoints

## Webhook Signatures

Every webhook request includes a signature in the `X-Sturdy-Signature` header. This signature allows you to verify that the webhook was sent by Sturdy Technologies and hasn't been tampered with.

## How Signatures Work

We generate signatures using HMAC-SHA256 with your webhook secret key. The signature is calculated directly from the raw request body.

## Verification Steps

### 1. Extract the Signature

Get the signature from the request header:

```javascript theme={null}
const signature = request.headers['x-sturdy-signature'];
```

### 2. Calculate Expected Signature

```javascript theme={null}
const crypto = require('crypto');

const expectedSignature = crypto
  .createHmac('sha256', webhookSecret)
  .update(requestBody)
  .digest('hex');
```

### 3. Compare Signatures

```javascript theme={null}
const isValid = crypto.timingSafeEqual(
  Buffer.from(signature),
  Buffer.from(expectedSignature)
);

if (!isValid) {
  throw new Error('Invalid signature');
}
```

## Complete Example (Node.js)

```javascript theme={null}
const express = require('express');
const crypto = require('crypto');

const app = express();

// Raw body needed for signature verification
app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-sturdy-signature'];
  const webhookSecret = process.env.WEBHOOK_SECRET;

  // Verify signature
  const expectedSignature = crypto
    .createHmac('sha256', webhookSecret)
    .update(req.body)
    .digest('hex');

  const isValid = crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  );

  if (!isValid) {
    return res.status(401).send('Invalid signature');
  }

  // Process webhook
  const event = JSON.parse(req.body);
  console.log('Received event:', event.event_type);

  res.status(200).send('OK');
});
```

## Preventing Replay Attacks

Use the `event_id` fields in the webhook payload to prevent replay attacks:

```javascript theme={null}
const event = JSON.parse(req.body);

// Check if event was already processed
if (await isEventProcessed(event.event_id)) {
  return res.status(200).send('Already processed');
}

// Mark event as processed
await markEventAsProcessed(event.event_id);
```

## IP Allowlisting

For additional security, you can allowlist our webhook IP addresses:
203.0.113.10
203.0.113.11
203.0.113.12

<Note>
  Contact support for the current list of webhook IP addresses for production.
</Note>

## Additional Examples

<CodeGroup>
  ```python Python theme={null}
  import hmac
  import hashlib

  def verify_webhook(request, webhook_secret):
  signature = request.headers.get('X-Sturdy-Signature')

      # Calculate expected signature
      expected_signature = hmac.new(
          webhook_secret.encode('utf-8'),
          request.body,
          hashlib.sha256
      ).hexdigest()

      # Compare signatures
      if not hmac.compare_digest(signature, expected_signature):
          raise ValueError('Invalid signature')

      return True

  ```

  ```php PHP theme={null}
  <?php
  function verifyWebhook($signature, $payload, $webhookSecret) {
      $expectedSignature = hash_hmac('sha256', $payload, $webhookSecret);

      if (!hash_equals($expectedSignature, $signature)) {
          throw new Exception('Invalid signature');
      }

      return true;
  }

  // Usage
  $signature = $_SERVER['HTTP_X_STURDY_SIGNATURE'];
  $payload = file_get_contents('php://input');
  $webhookSecret = getenv('WEBHOOK_SECRET');

  verifyWebhook($signature, $payload, $webhookSecret);
  ?>
  ```

  ```go Go theme={null}
  package main

  import (
      "crypto/hmac"
      "crypto/sha256"
      "encoding/hex"
      "errors"
  )

  func verifyWebhook(signature string, payload []byte, webhookSecret string) error {
      mac := hmac.New(sha256.New, []byte(webhookSecret))
      mac.Write(payload)
      expectedSignature := hex.EncodeToString(mac.Sum(nil))

      if !hmac.Equal([]byte(signature), []byte(expectedSignature)) {
          return errors.New("invalid signature")
      }

      return nil
  }
  ```

  ```java Java theme={null}
  import javax.crypto.Mac;
  import javax.crypto.spec.SecretKeySpec;
  import java.security.MessageDigest;
  import java.util.Arrays;

  public class WebhookVerifier {
      public static boolean verifyWebhook(String signature, String payload, String webhookSecret)
          throws Exception {

          Mac sha256Hmac = Mac.getInstance("HmacSHA256");
          SecretKeySpec secretKey = new SecretKeySpec(
              webhookSecret.getBytes("UTF-8"),
              "HmacSHA256"
          );
          sha256Hmac.init(secretKey);

          byte[] hash = sha256Hmac.doFinal(payload.getBytes("UTF-8"));
          String expectedSignature = bytesToHex(hash);

          return MessageDigest.isEqual(
              signature.getBytes(),
              expectedSignature.getBytes()
          );
      }

      private static String bytesToHex(byte[] bytes) {
          StringBuilder result = new StringBuilder();
          for (byte b : bytes) {
              result.append(String.format("%02x", b));
          }
          return result.toString();
      }
  }
  ```

  ```java Spring Boot theme={null}
  import org.springframework.web.bind.annotation.*;
  import javax.crypto.Mac;
  import javax.crypto.spec.SecretKeySpec;
  import java.security.MessageDigest;

  @RestController
  @RequestMapping("/webhooks")
  public class WebhookController {

      private final String webhookSecret = System.getenv("WEBHOOK_SECRET");

      @PostMapping
      public ResponseEntity<String> handleWebhook(
          @RequestHeader("X-Sturdy-Signature") String signature,
          @RequestBody String payload
      ) {
          try {
              if (!verifySignature(signature, payload)) {
                  return ResponseEntity.status(401).body("Invalid signature");
              }

              // Process webhook
              ObjectMapper mapper = new ObjectMapper();
              WebhookEvent event = mapper.readValue(payload, WebhookEvent.class);

              return ResponseEntity.ok("OK");
          } catch (Exception e) {
              return ResponseEntity.status(500).body("Error processing webhook");
          }
      }

      private boolean verifySignature(String signature, String payload) throws Exception {
          Mac sha256Hmac = Mac.getInstance("HmacSHA256");
          SecretKeySpec secretKey = new SecretKeySpec(
              webhookSecret.getBytes("UTF-8"),
              "HmacSHA256"
          );
          sha256Hmac.init(secretKey);

          byte[] hash = sha256Hmac.doFinal(payload.getBytes("UTF-8"));
          String expectedSignature = bytesToHex(hash);

          return MessageDigest.isEqual(
              signature.getBytes(),
              expectedSignature.getBytes()
          );
      }

      private String bytesToHex(byte[] bytes) {
          StringBuilder result = new StringBuilder();
          for (byte b : bytes) {
              result.append(String.format("%02x", b));
          }
          return result.toString();
      }
  }
  ```

  ```csharp C# theme={null}
  using System;
  using System.Security.Cryptography;
  using System.Text;

  public class WebhookVerifier
  {
      public static bool VerifyWebhook(string signature, string payload, string webhookSecret)
      {
          using (var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(webhookSecret)))
          {
              var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
              var expectedSignature = BitConverter.ToString(hash)
                  .Replace("-", "")
                  .ToLower();

              return CryptographicOperations.FixedTimeEquals(
                  Encoding.UTF8.GetBytes(signature),
                  Encoding.UTF8.GetBytes(expectedSignature)
              );
          }
      }
  }
  ```

  ```csharp ASP.NET Core theme={null}
  using Microsoft.AspNetCore.Mvc;
  using System.Security.Cryptography;
  using System.Text;
  using System.IO;

  [ApiController]
  [Route("webhooks")]
  public class WebhookController : ControllerBase
  {
      private readonly string _webhookSecret;

      public WebhookController(IConfiguration configuration)
      {
          _webhookSecret = configuration["WEBHOOK_SECRET"];
      }

      [HttpPost]
      public async Task<IActionResult> HandleWebhook()
      {
          Request.EnableBuffering();
          using var reader = new StreamReader(Request.Body, Encoding.UTF8, leaveOpen: true);
          var payload = await reader.ReadToEndAsync();
          Request.Body.Position = 0;

          var signature = Request.Headers["X-Sturdy-Signature"].ToString();

          if (!VerifySignature(signature, payload))
          {
              return Unauthorized("Invalid signature");
          }

          // Process webhook
          var webhookEvent = JsonSerializer.Deserialize<WebhookEvent>(payload);

          return Ok("OK");
      }

      private bool VerifySignature(string signature, string payload)
      {
          using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(_webhookSecret));
          var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
          var expectedSignature = BitConverter.ToString(hash)
              .Replace("-", "")
              .ToLower();

          return CryptographicOperations.FixedTimeEquals(
              Encoding.UTF8.GetBytes(signature),
              Encoding.UTF8.GetBytes(expectedSignature)
          );
      }
  }
  ```

  ```rust Rust theme={null}
  use hmac::{Hmac, Mac};
  use sha2::Sha256;
  use hex;

  type HmacSha256 = Hmac<Sha256>;

  pub fn verify_webhook(
      signature: &str,
      payload: &[u8],
      webhook_secret: &str,
  ) -> Result<bool, Box<dyn std::error::Error>> {
      let mut mac = HmacSha256::new_from_slice(webhook_secret.as_bytes())?;
      mac.update(payload);

      let result = mac.finalize();
      let expected_signature = hex::encode(result.into_bytes());

      // Constant-time comparison
      Ok(signature == expected_signature)
  }

  // Usage with Actix Web
  use actix_web::{post, web, HttpRequest, HttpResponse, Result};

  #[post("/webhooks")]
  async fn handle_webhook(
      req: HttpRequest,
      body: web::Bytes,
  ) -> Result<HttpResponse> {
      let signature = req
          .headers()
          .get("X-Sturdy-Signature")
          .and_then(|h| h.to_str().ok())
          .ok_or_else(|| actix_web::error::ErrorBadRequest("Missing signature"))?;

      let webhook_secret = std::env::var("WEBHOOK_SECRET")
          .expect("WEBHOOK_SECRET must be set");

      if !verify_webhook(signature, &body, &webhook_secret)
          .map_err(actix_web::error::ErrorInternalServerError)?
      {
          return Ok(HttpResponse::Unauthorized().body("Invalid signature"));
      }

      // Process webhook
      Ok(HttpResponse::Ok().body("OK"))
  }
  ```

  ```typescript TypeScript theme={null}
  import crypto from 'crypto';
  import express, { Request, Response } from 'express';

  function verifyWebhook(
    signature: string,
    payload: string | Buffer,
    webhookSecret: string
  ): boolean {
    const expectedSignature = crypto
      .createHmac('sha256', webhookSecret)
      .update(payload)
      .digest('hex');

    return crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expectedSignature)
    );
  }

  // Express middleware
  const app = express();

  app.post(
    '/webhooks',
    express.raw({ type: 'application/json' }),
    (req: Request, res: Response) => {
      const signature = req.headers['x-sturdy-signature'] as string;
      const webhookSecret = process.env.WEBHOOK_SECRET!;

      if (!verifyWebhook(signature, req.body, webhookSecret)) {
        return res.status(401).send('Invalid signature');
      }

      // Process webhook
      const event = JSON.parse(req.body.toString());
      console.log('Received event:', event.event_type);

      res.status(200).send('OK');
    }
  );
  ```

  ```ruby Ruby theme={null}
  require 'openssl'
  require 'sinatra'

  def verify_webhook(signature, payload, webhook_secret)
    expected_signature = OpenSSL::HMAC.hexdigest(
      OpenSSL::Digest.new('sha256'),
      webhook_secret,
      payload
    )

    Rack::Utils.secure_compare(signature, expected_signature)
  end

  # Sinatra endpoint
  post '/webhooks' do
    request.body.rewind
    payload = request.body.read
    signature = request.env['HTTP_X_STURDY_SIGNATURE']
    webhook_secret = ENV['WEBHOOK_SECRET']

    unless verify_webhook(signature, payload, webhook_secret)
      halt 401, 'Invalid signature'
    end

    # Process webhook
    event = JSON.parse(payload)
    puts "Received event: #{event['event_type']}"

    status 200
    body 'OK'
  end
  ```

  ```kotlin Kotlin theme={null}
  import javax.crypto.Mac
  import javax.crypto.spec.SecretKeySpec
  import java.security.MessageDigest

  object WebhookVerifier {
      fun verifyWebhook(signature: String, payload: String, webhookSecret: String): Boolean {
          val sha256Hmac = Mac.getInstance("HmacSHA256")
          val secretKey = SecretKeySpec(webhookSecret.toByteArray(Charsets.UTF_8), "HmacSHA256")
          sha256Hmac.init(secretKey)

          val hash = sha256Hmac.doFinal(payload.toByteArray(Charsets.UTF_8))
          val expectedSignature = hash.joinToString("") { "%02x".format(it) }

          return MessageDigest.isEqual(
              signature.toByteArray(),
              expectedSignature.toByteArray()
          )
      }
  }

  // Spring Boot usage
  @RestController
  @RequestMapping("/webhooks")
  class WebhookController {

      @Value("\${webhook.secret}")
      private lateinit var webhookSecret: String

      @PostMapping
      fun handleWebhook(
          @RequestHeader("X-Sturdy-Signature") signature: String,
          @RequestBody payload: String
      ): ResponseEntity<String> {
          if (!WebhookVerifier.verifyWebhook(signature, payload, webhookSecret)) {
              return ResponseEntity.status(401).body("Invalid signature")
          }

          // Process webhook
          val event = ObjectMapper().readValue(payload, WebhookEvent::class.java)

          return ResponseEntity.ok("OK")
      }
  }
  ```

  ```swift Swift theme={null}
  import Foundation
  import CryptoKit

  func verifyWebhook(signature: String, payload: Data, webhookSecret: String) -> Bool {
      guard let secretData = webhookSecret.data(using: .utf8) else {
          return false
      }

      let key = SymmetricKey(data: secretData)
      let hmac = HMAC<SHA256>.authenticationCode(for: payload, using: key)
      let expectedSignature = Data(hmac).map { String(format: "%02x", $0) }.joined()

      return signature == expectedSignature
  }

  // Vapor usage
  func routes(_ app: Application) throws {
      app.post("webhooks") { req -> HTTPStatus in
          guard let signature = req.headers.first(name: "X-Sturdy-Signature"),
                let webhookSecret = Environment.get("WEBHOOK_SECRET"),
                let body = req.body.data else {
              throw Abort(.badRequest)
          }

          guard verifyWebhook(signature: signature, payload: body, webhookSecret: webhookSecret) else {
              throw Abort(.unauthorized, reason: "Invalid signature")
          }

          // Process webhook
          let event = try req.content.decode(WebhookEvent.self)

          return .ok
      }
  }
  ```

  ```elixir Elixir theme={null}
  defmodule WebhookVerifier do
    def verify_webhook(signature, payload, webhook_secret) do
      expected_signature =
        :crypto.mac(:hmac, :sha256, webhook_secret, payload)
        |> Base.encode16(case: :lower)

      Plug.Crypto.secure_compare(signature, expected_signature)
    end
  end

  # Phoenix Controller
  defmodule MyAppWeb.WebhookController do
    use MyAppWeb, :controller

    def create(conn, _params) do
      signature = get_req_header(conn, "x-sturdy-signature") |> List.first()
      webhook_secret = System.get_env("WEBHOOK_SECRET")

      {:ok, body, _conn} = read_body(conn)

      if WebhookVerifier.verify_webhook(signature, body, webhook_secret) do
        # Process webhook
        event = Jason.decode!(body)

        send_resp(conn, 200, "OK")
      else
        send_resp(conn, 401, "Invalid signature")
      end
    end
  end
  ```
</CodeGroup>

## Security Checklist

<Steps>
  <Step title="Verify Signatures">
    Always verify the `X-Sturdy-Signature` header on every request
  </Step>

  <Step title="Use HTTPS">Only accept webhooks on HTTPS endpoints</Step>

  <Step title="Keep Secrets Safe">
    Store your webhook secret securely (environment variables, secret manager)
  </Step>

  <Step title="Implement Idempotency">
    Track processed `event_id` values to prevent duplicate processing
  </Step>
</Steps>
