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.
Starbridge signs every webhook using Ed25519 asymmetric signatures, following the Standard Webhooks specification.
How it works
- Starbridge constructs a message by concatenating the webhook ID, timestamp, and body with dots:
{webhook-id}.{webhook-timestamp}.{body}
- This message is signed with Starbridge’s private key using Ed25519.
- The signature is base64-encoded and sent in the
webhook-signature header with a v1a, prefix.
Verification steps
To verify a webhook:
- Extract the headers — read
webhook-id, webhook-timestamp, and webhook-signature from the request.
- 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.
- Reconstruct the signed message:
{webhook-id}.{webhook-timestamp}.{raw_body}
- Strip the
v1a, prefix from the webhook-signature header and base64-decode the remainder to get the signature bytes.
- Strip the
whpk_ prefix from your public key and base64-decode the remainder to get the key bytes.
- 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== |
Python
JavaScript
Java
Go
Ruby
C# / .NET
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: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: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: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+).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).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.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).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 or BouncyCastle package.