# Webhook Authentication

import {
  WEBHOOK_PUBLIC_KEY,
  WEBHOOK_SANDBOX_PUBLIC_KEY,
} from "../../src/generated/webhook-keys";

Tesser signs every outgoing webhook request with an **Ed25519** asymmetric signature. You should verify this signature on your server before processing the payload.

## Signature Header

Each webhook request includes the following headers:

| Header | Description |
| --- | --- |
| `X-Tesser-Signature` | Base64-encoded Ed25519 signature of the request body |
| `Content-Type` | Always `application/json` |
| `User-Agent` | `Tesser-Webhooks/1.0` |

The signature is computed over the exact UTF-8 bytes of the JSON request body. Do not parse and re-serialize the body before verifying — use the raw bytes.

## Public Keys

Tesser uses **separate public keys for production and sandbox**. Pick the one that matches the environment the webhook is being delivered from, and reject signatures verified against the wrong key.

<table>
  <thead>
    <tr>
      <th>Environment</th>
      <th>Constant</th>
      <th>API host</th>
      <th>Value (SPKI DER, base64)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Production</td>
      <td><code>WEBHOOK_PUBLIC_KEY</code></td>
      <td><code>https://api.tesser.xyz</code></td>
      <td><CopyableKey value={WEBHOOK_PUBLIC_KEY} /></td>
    </tr>
    <tr>
      <td>Sandbox / Staging</td>
      <td><code>WEBHOOK_SANDBOX_PUBLIC_KEY</code></td>
      <td><code>https://sandbox.tesserx.co</code></td>
      <td><CopyableKey value={WEBHOOK_SANDBOX_PUBLIC_KEY} /></td>
    </tr>
  </tbody>
</table>

Both keys are provided in **SPKI DER** format, base64-encoded.

If you're using the TypeScript SDK, import the constants directly from `@tesser-payments/types`:

```ts
import {
  WEBHOOK_PUBLIC_KEY,
  WEBHOOK_SANDBOX_PUBLIC_KEY,
} from "@tesser-payments/types";
```

Otherwise (including from Kotlin/JVM, which has no constants package), copy the literal values from the table above.

## Verifying Signatures

Pass the public key for the environment the webhook is being delivered from: use `WEBHOOK_PUBLIC_KEY` for production webhooks and `WEBHOOK_SANDBOX_PUBLIC_KEY` for sandbox / staging webhooks. Verification uses each language's standard crypto library; the signer SDKs do not perform webhook verification.

<Tabs>
  <TabItem label="TypeScript">

```js
import { createPublicKey, verify } from "node:crypto";
import {
  WEBHOOK_PUBLIC_KEY,
  WEBHOOK_SANDBOX_PUBLIC_KEY,
} from "@tesser-payments/types";

function verifyWebhook(rawBody, signature, publicKey) {
  const publicKeyObj = createPublicKey({
    key: Buffer.from(publicKey, "base64"),
    type: "spki",
    format: "der",
  });
  return verify(
    null,
    Buffer.from(rawBody, "utf8"),
    publicKeyObj,
    Buffer.from(signature, "base64"),
  );
}

// In your webhook handler — pick the key for the environment you're
// receiving webhooks from (sandbox / staging shown here):
const signature = req.headers["x-tesser-signature"];
if (typeof signature !== "string" || signature.length === 0) {
  return res.status(401).json({ error: "Missing signature" });
}
const isValid = verifyWebhook(
  req.rawBody,
  signature,
  WEBHOOK_SANDBOX_PUBLIC_KEY,
);

if (!isValid) {
  return res.status(401).json({ error: "Invalid signature" });
}
```

  </TabItem>
  <TabItem label="Kotlin">

```kotlin
import java.security.GeneralSecurityException
import java.security.KeyFactory
import java.security.Signature
import java.security.spec.X509EncodedKeySpec
import java.util.Base64

// SPKI DER, base64-encoded; copy the literal values from the table above.
const val WEBHOOK_SANDBOX_PUBLIC_KEY = "MCowBQYDK2VwAyEA..."

// Ed25519 verification is built into the JVM (JDK 15+); no extra dependency.
// Malformed base64 or key bytes throw, so treat any failure as "not verified".
fun verifyWebhook(rawBody: ByteArray, signatureB64: String, publicKeyB64: String): Boolean =
  try {
    val keySpec = X509EncodedKeySpec(Base64.getDecoder().decode(publicKeyB64))
    val publicKey = KeyFactory.getInstance("Ed25519").generatePublic(keySpec)
    Signature.getInstance("Ed25519").run {
      initVerify(publicKey)
      update(rawBody)
      verify(Base64.getDecoder().decode(signatureB64))
    }
  } catch (e: IllegalArgumentException) {
    false // malformed base64
  } catch (e: GeneralSecurityException) {
    false // invalid key or signature
  }

// In your webhook handler, pick the key for the environment you're receiving
// webhooks from (sandbox / staging shown here). Verify the raw request bytes.
val signature = request.header("X-Tesser-Signature")
  ?: return respond(401, """{"error":"Missing signature"}""")

if (!verifyWebhook(rawBody, signature, WEBHOOK_SANDBOX_PUBLIC_KEY)) {
  return respond(401, """{"error":"Invalid signature"}""")
}
```

  </TabItem>
</Tabs>

## Important Notes

- Always verify using the **raw request body** bytes. Parsing the JSON and re-serializing may change whitespace or key order, which will invalidate the signature.
- If verification fails, respond with `401` and do not process the event.
- The public key may be rotated in the future. Key rotation will be announced in advance and communicated through the Tesser Dashboard.
