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

# Signature Verification

> Verify that incoming webhook requests are genuinely from Starbridge using Ed25519 signatures.

Starbridge signs every webhook using **Ed25519** asymmetric signatures, following the [Standard Webhooks](https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md) specification.

## How it works

1. Starbridge constructs a message by concatenating the webhook ID, timestamp, and body with dots:
   ```text theme={null}
   {webhook-id}.{webhook-timestamp}.{body}
   ```
2. This message is signed with Starbridge's private key using Ed25519.
3. The signature is base64-encoded and sent in the `webhook-signature` header with a `v1a,` prefix.

## Verification steps

To verify a webhook:

1. **Extract the headers** — read `webhook-id`, `webhook-timestamp`, and `webhook-signature` from the request.
2. **Read the raw body** — use the raw request body. Do **not** parse and re-serialize the JSON, as even minor formatting changes will break verification.
3. **Reconstruct the signed message:**
   ```text theme={null}
   {webhook-id}.{webhook-timestamp}.{raw_body}
   ```
4. **Strip the `v1a,` prefix** from the `webhook-signature` header and base64-decode the remainder to get the signature bytes.
5. **Strip the `whpk_` prefix** from your public key and base64-decode the remainder to get the key bytes.
6. **Verify** the Ed25519 signature using your language's crypto library.

### Timestamp validation

To protect against replay attacks, check that the `webhook-timestamp` is recent (within 5 minutes of the current time). Reject requests with timestamps that are too old.

### Idempotency

Use the `webhook-id` header as an idempotency key. Store recently processed webhook IDs and skip any duplicates to avoid processing the same event twice.

## Code examples

All examples use the following test data:

| Parameter             | Value                                                                                          |
| --------------------- | ---------------------------------------------------------------------------------------------- |
| **Public key**        | `whpk_MCowBQYDK2VwAyEA3pXFbrxQWyCihnqd8eQ7B0CVyB9BXRMes8oluz6YkA8=`                            |
| **webhook-id**        | `msg_350d6ad7-13e1-47f8-8111-33f96313fabe`                                                     |
| **webhook-timestamp** | `1775589603`                                                                                   |
| **webhook-signature** | `v1a,oB5S13GytYJTAULdngi9wtz1YHs9hNppi75xw3W8VR69ypeI/hCwwlTWePup7J7ZXr4wdKPDdlWxS3o1YuxkAg==` |

<Tabs>
  <Tab title="Python">
    ```python theme={null}
    import base64
    from cryptography.hazmat.primitives.serialization import load_der_public_key

    def verify_webhook(public_key_str, webhook_id, webhook_timestamp, webhook_signature, body):
        """
        Verify a Starbridge webhook signature.

        Args:
            public_key_str: Your public key (e.g. "whpk_MCow...")
            webhook_id: Value of the webhook-id header
            webhook_timestamp: Value of the webhook-timestamp header
            webhook_signature: Value of the webhook-signature header
            body: Raw request body as bytes
        Returns:
            True if the signature is valid
        Raises:
            Exception if verification fails
        """
        # 1. Strip the whpk_ prefix and decode the public key
        raw_key = public_key_str.removeprefix("whpk_")
        key_bytes = base64.b64decode(raw_key)
        public_key = load_der_public_key(key_bytes)

        # 2. Strip the v1a, prefix and decode the signature
        if not webhook_signature.startswith("v1a,"):
            raise ValueError("Unsupported signature version")
        sig_bytes = base64.b64decode(webhook_signature[4:])

        # 3. Reconstruct the signed message
        message = f"{webhook_id}.{webhook_timestamp}.".encode() + body

        # 4. Verify
        public_key.verify(sig_bytes, message)  # Raises InvalidSignature if invalid
        return True
    ```

    **Flask example:**

    ```python theme={null}
    from flask import Flask, request

    STARBRIDGE_PUBLIC_KEY = "whpk_MCowBQYDK2VwAyEA3pXFbrxQWyCihnqd8eQ7B0CVyB9BXRMes8oluz6YkA8="

    app = Flask(__name__)

    @app.route("/webhook", methods=["POST"])
    def handle_webhook():
        try:
            verify_webhook(
                STARBRIDGE_PUBLIC_KEY,
                request.headers["webhook-id"],
                request.headers["webhook-timestamp"],
                request.headers["webhook-signature"],
                request.get_data(),  # Raw body bytes
            )
        except Exception:
            return "Invalid signature", 401

        payload = request.get_json()
        # Process the webhook...
        return "OK", 200
    ```

    Install the dependency:

    ```bash theme={null}
    pip install cryptography
    ```
  </Tab>

  <Tab title="JavaScript">
    ```javascript theme={null}
    const crypto = require("crypto");

    function verifyWebhook(publicKeyStr, webhookId, webhookTimestamp, webhookSignature, rawBody) {
      // 1. Strip the whpk_ prefix and decode the public key
      const rawKey = publicKeyStr.replace(/^whpk_/, "");
      const keyBuffer = Buffer.from(rawKey, "base64");
      const publicKey = crypto.createPublicKey({
        key: keyBuffer,
        format: "der",
        type: "spki",
      });

      // 2. Strip the v1a, prefix and decode the signature
      if (!webhookSignature.startsWith("v1a,")) {
        throw new Error("Unsupported signature version");
      }
      const sigBuffer = Buffer.from(webhookSignature.slice(4), "base64");

      // 3. Reconstruct the signed message
      const message = Buffer.from(`${webhookId}.${webhookTimestamp}.${rawBody}`);

      // 4. Verify
      return crypto.verify(null, message, publicKey, sigBuffer);
    }
    ```

    **Express example:**

    ```javascript theme={null}
    const express = require("express");
    const app = express();

    const STARBRIDGE_PUBLIC_KEY =
      "whpk_MCowBQYDK2VwAyEA3pXFbrxQWyCihnqd8eQ7B0CVyB9BXRMes8oluz6YkA8=";

    // Important: use raw body parser so the body isn't re-serialized
    app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
      const isValid = verifyWebhook(
        STARBRIDGE_PUBLIC_KEY,
        req.headers["webhook-id"],
        req.headers["webhook-timestamp"],
        req.headers["webhook-signature"],
        req.body.toString()
      );

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

      const payload = JSON.parse(req.body);
      // Process the webhook...
      res.status(200).send("OK");
    });
    ```

    No additional dependencies needed — uses Node.js built-in `crypto` module (Node.js 16+).
  </Tab>

  <Tab title="Java">
    ```java theme={null}
    import java.security.KeyFactory;
    import java.security.PublicKey;
    import java.security.Signature;
    import java.security.spec.X509EncodedKeySpec;
    import java.util.Base64;

    public class WebhookVerifier {

        public static boolean verifyWebhook(
                String publicKeyStr,
                String webhookId,
                String webhookTimestamp,
                String webhookSignature,
                String rawBody
        ) throws Exception {
            // 1. Strip the whpk_ prefix and decode the public key
            String rawKey = publicKeyStr.replaceFirst("^whpk_", "");
            byte[] keyBytes = Base64.getDecoder().decode(rawKey);
            PublicKey publicKey = KeyFactory.getInstance("Ed25519")
                    .generatePublic(new X509EncodedKeySpec(keyBytes));

            // 2. Strip the v1a, prefix and decode the signature
            if (!webhookSignature.startsWith("v1a,")) {
                throw new IllegalArgumentException("Unsupported signature version");
            }
            byte[] sigBytes = Base64.getDecoder().decode(webhookSignature.substring(4));

            // 3. Reconstruct the signed message
            String message = webhookId + "." + webhookTimestamp + "." + rawBody;

            // 4. Verify
            Signature verifier = Signature.getInstance("Ed25519");
            verifier.initVerify(publicKey);
            verifier.update(message.getBytes());
            return verifier.verify(sigBytes);
        }
    }
    ```

    Requires Java 15+ (built-in Ed25519 support, no additional dependencies).
  </Tab>

  <Tab title="Go">
    ```go theme={null}
    package main

    import (
    	"crypto/ed25519"
    	"crypto/x509"
    	"encoding/base64"
    	"errors"
    	"fmt"
    	"strings"
    )

    func verifyWebhook(publicKeyStr, webhookId, webhookTimestamp, webhookSignature, rawBody string) (bool, error) {
    	// 1. Strip the whpk_ prefix and decode the public key
    	rawKey := strings.TrimPrefix(publicKeyStr, "whpk_")
    	keyBytes, err := base64.StdEncoding.DecodeString(rawKey)
    	if err != nil {
    		return false, fmt.Errorf("failed to decode public key: %w", err)
    	}
    	parsed, err := x509.ParsePKIXPublicKey(keyBytes)
    	if err != nil {
    		return false, fmt.Errorf("failed to parse public key: %w", err)
    	}
    	publicKey, ok := parsed.(ed25519.PublicKey)
    	if !ok {
    		return false, errors.New("key is not Ed25519")
    	}

    	// 2. Strip the v1a, prefix and decode the signature
    	if !strings.HasPrefix(webhookSignature, "v1a,") {
    		return false, errors.New("unsupported signature version")
    	}
    	sigBytes, err := base64.StdEncoding.DecodeString(webhookSignature[4:])
    	if err != nil {
    		return false, fmt.Errorf("failed to decode signature: %w", err)
    	}

    	// 3. Reconstruct the signed message
    	message := []byte(webhookId + "." + webhookTimestamp + "." + rawBody)

    	// 4. Verify
    	return ed25519.Verify(publicKey, message, sigBytes), nil
    }
    ```

    No additional dependencies needed — uses Go standard library.
  </Tab>

  <Tab title="Ruby">
    ```ruby theme={null}
    require "openssl"
    require "base64"

    def verify_webhook(public_key_str, webhook_id, webhook_timestamp, webhook_signature, raw_body)
      # 1. Strip the whpk_ prefix and decode the public key
      raw_key = public_key_str.sub(/\Awhpk_/, "")
      key_bytes = Base64.decode64(raw_key)
      public_key = OpenSSL::PKey.read(
        OpenSSL::ASN1.decode(key_bytes).to_der
      )

      # 2. Strip the v1a, prefix and decode the signature
      raise "Unsupported signature version" unless webhook_signature.start_with?("v1a,")
      sig_bytes = Base64.decode64(webhook_signature[4..])

      # 3. Reconstruct the signed message
      message = "#{webhook_id}.#{webhook_timestamp}.#{raw_body}"

      # 4. Verify
      public_key.verify(nil, sig_bytes, message)
    end
    ```

    Requires Ruby 3.0+ with OpenSSL 1.1.1+ (built-in Ed25519 support).
  </Tab>

  <Tab title="C# / .NET">
    ```csharp theme={null}
    using System;
    using System.Security.Cryptography;
    using System.Text;

    public static class WebhookVerifier
    {
        public static bool VerifyWebhook(
            string publicKeyStr,
            string webhookId,
            string webhookTimestamp,
            string webhookSignature,
            string rawBody)
        {
            // 1. Strip the whpk_ prefix and decode the public key
            var rawKey = publicKeyStr.StartsWith("whpk_") ? publicKeyStr[5..] : publicKeyStr;
            var keyBytes = Convert.FromBase64String(rawKey);
            using var ed25519 = new Ed25519();
            ed25519.ImportSubjectPublicKeyInfo(keyBytes, out _);

            // 2. Strip the v1a, prefix and decode the signature
            if (!webhookSignature.StartsWith("v1a,"))
                throw new ArgumentException("Unsupported signature version");
            var sigBytes = Convert.FromBase64String(webhookSignature[4..]);

            // 3. Reconstruct the signed message
            var message = Encoding.UTF8.GetBytes($"{webhookId}.{webhookTimestamp}.{rawBody}");

            // 4. Verify
            return ed25519.VerifyData(message, sigBytes);
        }
    }
    ```

    Requires .NET 9+ for built-in `Ed25519` class. For older .NET versions, use the [NSec.Cryptography](https://www.nuget.org/packages/NSec.Cryptography) or [BouncyCastle](https://www.nuget.org/packages/BouncyCastle.Cryptography) package.
  </Tab>
</Tabs>
