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

> ## Agent Instructions
> Internal links on these pages omit the .md extension. Append .md to a docs page URL, or send an Accept: text/markdown header, to get that page as markdown.

# Verifying requests

> Check that each outbound webhook really came from Base44: fetch your workspace's public keys, verify the Ed25519 signature and reject stale timestamps.

Every request Base44 sends is signed with your workspace's private Ed25519 key. Verify the signature before you trust a request, so a forged or replayed call is rejected.

***

## Signature headers

Each request carries 3 headers, following the [Standard Webhooks](https://www.standardwebhooks.com/) specification.

| Header | Value |
| - | - |
| `webhook-id` | The event's unique ID, the same as the body's `id`. Use it to [ignore duplicates](/developers/references/outbound-webhooks/get-started/delivery-and-retries#handling-duplicates). |
| `webhook-timestamp` | When this attempt was signed, in Unix seconds. |
| `webhook-signature` | One or more signatures, separated by spaces. Each is `v1a,` followed by a base64 Ed25519 signature. |

During a key rotation, `webhook-signature` carries 2 signatures, one per key. A request is valid when any one of them verifies.

***

## Getting your public keys

Your workspace's public keys are published at a URL anyone can read. Public keys can't sign anything, so they're safe to share.

```bash theme={null}
curl https://app.base44.com/api/v1/outbound-webhooks/workspaces/{workspace_id}/keys
```

The fields are described in [Get the public key set](/api-reference/outbound-webhooks/get-the-public-key-set).

```json theme={null}
{
  "signature_scheme": "v1a",
  "keys": [
    {
      "kid": "whk_1",
      "algorithm": "ed25519_v1",
      "public_key": "whpk_IpKdTqUwjquSGDx5Mra/Z2YNTYrirVIzTSEptxaAGQ0=",
      "valid_from": "2026-09-01T10:00:00.000Z",
      "valid_until": null
    }
  ],
  "next_cursor": null
}
```

A `public_key` is `whpk_` followed by the standard base64 (not base64url) of the 32 raw key bytes. The signature header carries no key ID, so try each key in the list.

Keys come newest first, a page at a time. When `next_cursor` isn't `null`, request the next page with `?cursor=<next_cursor>` until it is, and keep every key. A workspace holds one key, or two during a rotation, so the first page is usually the whole set.

<Tip>
  **Caching the keys:**

  * The response can be cached for 5 minutes. Refresh it once when no key verifies a request, before you reject it.
  * You can also copy the key from **See public keys** on your workspace's webhooks settings page and store it in your receiver. If you do, add the new key during a [rotation](#when-the-key-changes).
</Tip>

***

## Verifying a signature

Verification takes 3 checks, all on the raw request body, before you parse any JSON:

1. **Check the timestamp.** Reject a request whose `webhook-timestamp` is more than 5 minutes from your clock. This stops an old request from being replayed.
2. **Build the signed content.** Join `webhook-id`, `webhook-timestamp` and the raw body with a `.` between each: `{webhook-id}.{webhook-timestamp}.{raw body}`.
3. **Verify.** For each `v1a` entry in `webhook-signature`, verify its signature over the signed content with each of your public keys. Accept the request if any pair matches.

<Warning>
  Verify the exact bytes you received. Parsing the JSON and serializing it again changes the bytes, and the signature no longer matches.
</Warning>

Each example below is complete, needs no library beyond the standard one (or `cryptography` for Python), and returns `true` only for a genuine request.

<CodeGroup>
  ```js Node.js theme={null}
  import crypto from "node:crypto";

  // Ed25519 public keys are published as whpk_ + base64 of the 32 raw key bytes.
  // Node needs them wrapped in this fixed DER prefix.
  const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
  const TOLERANCE_SECONDS = 300;

  function toPublicKey(whpk) {
    const raw = Buffer.from(whpk.slice("whpk_".length), "base64");
    return crypto.createPublicKey({
      key: Buffer.concat([ED25519_SPKI_PREFIX, raw]),
      format: "der",
      type: "spki",
    });
  }

  // rawBody must be the exact bytes you received, before any JSON parsing.
  export function verifyWebhook(rawBody, headers, publicKeys) {
    const id = headers["webhook-id"];
    const timestamp = headers["webhook-timestamp"];
    const signatures = headers["webhook-signature"];
    if (!id || !timestamp || !signatures) return false;

    const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
    if (age > TOLERANCE_SECONDS) return false;

    const signed = Buffer.concat([Buffer.from(`${id}.${timestamp}.`), rawBody]);
    const keys = publicKeys.map(toPublicKey);

    return signatures.split(" ").some((entry) => {
      const [version, signature] = entry.split(",");
      if (version !== "v1a" || !signature) return false;
      const bytes = Buffer.from(signature, "base64");
      return keys.some((key) => crypto.verify(null, signed, key, bytes));
    });
  }
  ```

  ```python Python theme={null}
  import base64
  import time

  from cryptography.exceptions import InvalidSignature
  from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey

  TOLERANCE_SECONDS = 300


  def verify_webhook(raw_body: bytes, headers: dict, public_keys: list[str]) -> bool:
      """raw_body must be the exact bytes you received, before any JSON parsing."""
      message_id = headers.get("webhook-id")
      timestamp = headers.get("webhook-timestamp")
      signatures = headers.get("webhook-signature")
      if not (message_id and timestamp and signatures):
          return False

      if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
          return False

      signed = f"{message_id}.{timestamp}.".encode() + raw_body
      keys = [
          Ed25519PublicKey.from_public_bytes(base64.b64decode(key.removeprefix("whpk_")))
          for key in public_keys
      ]

      for entry in signatures.split(" "):
          version, _, signature = entry.partition(",")
          if version != "v1a" or not signature:
              continue
          for key in keys:
              try:
                  key.verify(base64.b64decode(signature), signed)
                  return True
              except InvalidSignature:
                  pass
      return False
  ```

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

  import (
  	"crypto/ed25519"
  	"encoding/base64"
  	"math"
  	"strconv"
  	"strings"
  	"time"
  )

  const toleranceSeconds = 300

  // VerifyWebhook checks a Base44 webhook. rawBody must be the exact bytes you
  // received, before any JSON parsing.
  func VerifyWebhook(rawBody []byte, id, timestamp, signatures string, publicKeys []string) bool {
  	if id == "" || timestamp == "" || signatures == "" {
  		return false
  	}
  	ts, err := strconv.ParseInt(timestamp, 10, 64)
  	if err != nil || math.Abs(float64(time.Now().Unix()-ts)) > toleranceSeconds {
  		return false
  	}

  	signed := append([]byte(id+"."+timestamp+"."), rawBody...)

  	var keys []ed25519.PublicKey
  	for _, k := range publicKeys {
  		raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(k, "whpk_"))
  		if err == nil && len(raw) == ed25519.PublicKeySize {
  			keys = append(keys, ed25519.PublicKey(raw))
  		}
  	}

  	for _, entry := range strings.Split(signatures, " ") {
  		version, signature, ok := strings.Cut(entry, ",")
  		if !ok || version != "v1a" {
  			continue
  		}
  		sig, err := base64.StdEncoding.DecodeString(signature)
  		if err != nil {
  			continue
  		}
  		for _, key := range keys {
  			if ed25519.Verify(key, signed, sig) {
  				return true
  			}
  		}
  	}
  	return false
  }
  ```
</CodeGroup>

***

## When the key changes

A workspace admin can rotate or revoke the signing key from **See public keys**. Your receiver keeps working through both if it reads the key URL above, or if you add the new key in time.

* **Rotation:** Base44 signs with both the old and the new key for 24 hours, and both appear in the key URL. If you store the key in your receiver, add the new one before the overlap ends. An admin can end the overlap early with **Finish rotation**.
* **Revocation:** Base44 stops signing with the revoked key on the next request. A receiver that reads the key URL picks up the replacement within 5 minutes. A receiver that stores only the revoked key rejects deliveries until you give it the new one, and those deliveries [retry](/developers/references/outbound-webhooks/get-started/delivery-and-retries) meanwhile.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.