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

> ## Agent Instructions
> Document only operations in the linked OpenAPI contract: the source-data ingestion operations and the source-scoped outbound webhook management operations.
> Treat the generated OpenAPI document as authoritative for request fields. Never invent compatibility fields, alternate endpoints, or additional source models.
> Preserve explicit nulls, decimal strings, timestamps, source identifiers, immutable version identifiers, and document-version links exactly as defined.

# Set up outbound webhooks

> Configure, verify, test, and operate signed KYB lifecycle webhooks for an ingestion source.

Karsa webhooks notify your backend when KYB processing changes after an API request has already
returned. Webhooks are configured for an ingestion source, not for each Customer, and are optional
for synchronous customer and business-onboarding requests.

This guide walks through the complete integration:

1. Build an HTTPS receiver that preserves the raw request body.
2. Add the endpoint and choose its event subscriptions.
3. Install the Karsa-generated Ed25519 public key in your receiver.
4. Complete Karsa's endpoint-verification challenge.
5. Send a test event and inspect its delivery history.
6. Enable production event processing and handle retries safely.

## Before you begin

You need:

* A source API credential or access to **Settings → Integrations** in Karsa.
* A publicly reachable HTTPS endpoint for production.
* A receiver that can read the unmodified request bytes and return a `2xx` quickly.
* A secure location for the Ed25519 public key and endpoint metadata.

Source API keys can manage endpoints belonging to their source. Machine OIDC credentials must
include `karsa:webhooks:manage` in both the issued token and the tenant service-account grant.

<Note>
  You can create multiple destinations for one ingestion source and select subscriptions separately
  for each destination.
</Note>

## Events available in version 1

| Event | When it is sent |
| - | - |
| `kyb.evaluation.queued` | A KYB evaluation has been accepted for processing. |
| `kyb.evaluation.completed` | KYB processing completed with a recorded outcome. |
| `kyb.evaluation.failed` | KYB processing ended with a bounded failure code. |
| `webhook.test` | You requested a test delivery from Karsa. |

KYB payloads contain source identity, customer subject ID, evaluation ID, workflow-run ID, status,
and a bounded outcome or failure code. `data.kybRun` repeats the run as
[Trigger a KYB run](/guides/kyb-runs) documents it: its public status, customer IDs, result summary,
and failure code. They exclude customer profiles, documents, raw provider
evidence, model output, credentials, and signing secrets.

## 1. Obtain the verification public key

Karsa generates a dedicated Ed25519 signing-key pair when you create an endpoint. Karsa retains
the private key encrypted in its backend and never accepts, returns, or exposes private signing-key
material through the API or web app.

Copy the public key, SHA-256 fingerprint, and key version from the created endpoint into the
configuration used by your receiving service. The public key is verification material, not a
shared secret. Compare its fingerprint when moving it between environments.

<Note>
  You do not generate or upload a key pair. Possession of Karsa's public key lets your backend
  verify signatures but cannot be used to create valid Karsa signatures.
</Note>

## 2. Implement the receiver

Create a `POST` route that:

1. Reads the request as raw bytes.
2. Extracts the Karsa signature headers.
3. Selects the public key identified by `Karsa-Webhook-Key-Version`.
4. Verifies the Ed25519 signature before parsing JSON.
5. Returns the verification challenge when the event type is
   `webhook.endpoint.verification`.
6. Stores the immutable event ID as a deduplication key.
7. Queues business processing and returns a `2xx` promptly.

### Express example

```ts theme={null}
import { createPublicKey, timingSafeEqual, verify } from "node:crypto";
import express from "express";

const app = express();

// Load this from your secret/configuration store, not from source control.
const publicKeys = new Map([["1", process.env.KARSA_WEBHOOK_PUBLIC_KEY_V1!]]);

function requiredHeader(request: express.Request, name: string): string {
  const value = request.header(name);
  if (!value) throw new Error(`Missing ${name}`);
  return value;
}

function sameText(left: string, right: string): boolean {
  const leftBytes = Buffer.from(left, "utf8");
  const rightBytes = Buffer.from(right, "utf8");
  return leftBytes.length === rightBytes.length && timingSafeEqual(leftBytes, rightBytes);
}

app.post(
  "/webhooks/karsa",
  express.raw({ type: "application/json", limit: "256kb" }),
  async (request, response) => {
    try {
      const rawBody = request.body as Buffer;
      const eventId = requiredHeader(request, "Karsa-Webhook-Id");
      const eventType = requiredHeader(request, "Karsa-Webhook-Type");
      const timestamp = requiredHeader(request, "Karsa-Webhook-Timestamp");
      const version = requiredHeader(request, "Karsa-Webhook-Version");
      const keyVersion = requiredHeader(request, "Karsa-Webhook-Key-Version");
      const signatureHeader = requiredHeader(request, "Karsa-Webhook-Signature");

      if (version !== "1") {
        return response.status(400).json({ error: "Unsupported webhook version" });
      }

      const publicKeyPem = publicKeys.get(keyVersion);
      const encodedSignature = signatureHeader.match(/^v1=([A-Za-z0-9_-]+)$/u)?.[1];
      if (!publicKeyPem || !encodedSignature) {
        return response.status(401).json({ error: "Unknown key or signature version" });
      }

      // Karsa signs the exact UTF-8 bytes: timestamp + "." + raw request body.
      const signedBytes = Buffer.concat([Buffer.from(`${timestamp}.`, "utf8"), rawBody]);
      const valid = verify(
        null,
        signedBytes,
        createPublicKey(publicKeyPem),
        Buffer.from(encodedSignature, "base64url"),
      );
      if (!valid) {
        return response.status(401).json({ error: "Invalid signature" });
      }

      const event = JSON.parse(rawBody.toString("utf8")) as {
        id: string;
        type: string;
        challenge?: string;
      };

      if (!sameText(event.id, eventId) || !sameText(event.type, eventType)) {
        return response.status(400).json({ error: "Header and body mismatch" });
      }

      if (event.type === "webhook.endpoint.verification") {
        return response.status(200).json({ challenge: event.challenge });
      }

      // This operation inserts event.id behind a unique constraint, queues
      // new events, and treats a duplicate ID as already accepted.
      await acceptWebhookEventOnce(event);
      return response.status(204).send();
    } catch {
      return response.status(400).json({ error: "Invalid webhook request" });
    }
  },
);
```

Replace `acceptWebhookEventOnce` with your durable inbox or queue operation. Do not acknowledge a
new event until it has been durably accepted, and return a `2xx` for an event ID already accepted.

<Warning>
  JSON middleware must not run before signature verification. Parsing and reserializing the payload
  changes its bytes and invalidates the signature.
</Warning>

## 3. Add the endpoint in Karsa

In the web app:

1. Open **Settings → Integrations**.
2. Select the ingestion source that owns the endpoint.
3. In **Webhooks**, choose **Add endpoint**.
4. Enter a recognizable name and the receiver's HTTPS URL.
5. Select the KYB event types the destination should receive.
6. Save the endpoint.
7. Copy the generated public key into your receiver configuration.

The endpoint starts in `PENDING_VERIFICATION`. Karsa does not enqueue normal KYB deliveries for it
until verification succeeds.

Changing an active endpoint URL returns it to `PENDING_VERIFICATION`. Changing a disabled
endpoint URL keeps it `DISABLED`, clears its prior verification, and requires an explicit enable
followed by verification. Changing only its name or event subscriptions preserves its verification
state. Disabling an endpoint stops new events from being queued for that destination; already
queued deliveries continue using their pinned URL and signature.

### API alternative

You can create the same endpoint with a source-authenticated request:

```http theme={null}
POST /api/ingestion/webhook-endpoints
Authorization: Bearer <source-credential>
Content-Type: application/json
```

```json theme={null}
{
  "name": "Production KYB receiver",
  "url": "https://api.example.com/webhooks/karsa",
  "eventTypes": ["kyb.evaluation.queued", "kyb.evaluation.completed", "kyb.evaluation.failed"]
}
```

The response contains the generated public key, fingerprint, key version, and endpoint status. It
never contains private signing-key material.

## 4. Verify the endpoint

Choose **Verify endpoint** in the web app or call:

```http theme={null}
POST /api/ingestion/webhook-endpoints/{endpointId}/verify
Authorization: Bearer <source-credential>
```

Karsa sends a signed `webhook.endpoint.verification` request to the configured URL. After verifying
the signature, return a `2xx` JSON response containing the exact challenge from the body:

```json theme={null}
{
  "challenge": "the-exact-value-from-the-request"
}
```

Karsa activates the endpoint only if the challenge succeeds and the URL and active signing key did
not change while verification was in progress. Otherwise the endpoint stays unverified and the UI
shows the failure reason.

A disabled endpoint cannot be verified. Enable it deliberately before verification; a verification
request never changes `DISABLED` back to `ACTIVE`. If its URL changed while disabled, enabling it
moves it to `PENDING_VERIFICATION` instead of activating it.

## 5. Send a test event

After verification, choose **Send test**. The test uses the normal event ledger, signing code,
durable delivery workflow, retry rules, and history—not a separate connectivity check.

Your receiver gets a `webhook.test` event. Confirm that:

* Signature verification succeeds against the advertised key version.
* The header event ID and event type match the JSON body.
* The event ID is stored as a deduplication key.
* Your receiver returns a `2xx` after durable acceptance.
* The Karsa delivery history shows `DELIVERED`.

If delivery fails, open the endpoint's recent attempts to see its HTTP status, bounded failure
explanation, duration, retry state, and next attempt. Payloads, private keys, and signatures are not
written to application logs.

## Request headers

| Header | Meaning |
| - | - |
| `Karsa-Webhook-Id` | Immutable event ID and deduplication key. |
| `Karsa-Webhook-Type` | Event type. Must match the body. |
| `Karsa-Webhook-Timestamp` | ISO 8601 signing timestamp, stable across retries. |
| `Karsa-Webhook-Version` | Envelope and signature version, currently `1`. |
| `Karsa-Webhook-Key-Version` | Public-key version used for this delivery. |
| `Karsa-Webhook-Signature` | `v1=` plus a base64url Ed25519 signature. |

## Event envelope

Every KYB delivery uses a stable version 1 envelope:

```json theme={null}
{
  "id": "7ca98328-1d7f-4ccb-8bd9-299373dbade2",
  "type": "kyb.evaluation.completed",
  "version": "1",
  "createdAt": "2026-08-27T13:22:16.155Z",
  "source": {
    "id": "source_record_id",
    "key": "source-stable-key"
  },
  "subject": {
    "type": "customer",
    "id": "customer_resource_id"
  },
  "data": {
    "evaluationId": "evaluation_record_id",
    "workflowRunId": "workflow_run_id",
    "status": "COMPLETED",
    "outcome": "NO_ATTENTION",
    "failureCode": null
  }
}
```

Use `id` for deduplication. Use `createdAt` and your own domain rules when reconciling state because
events for different customers and endpoints are not globally ordered.

## Delivery and retry behavior

Delivery is at least once. Your receiver can observe a duplicate if it accepted a request but Karsa
could not record that acceptance.

| Result | Karsa behavior |
| - | - |
| Any `2xx` | Marks the delivery successful. |
| `408`, `429`, or `5xx` | Retries with exponential backoff and jitter. |
| Timeout, DNS, or other network failure | Retries with exponential backoff and jitter. |
| Other `4xx` | Dead-letters without automatic retry. |
| Redirect | Rejected; Karsa does not follow redirects. |
| Unsafe destination after DNS revalidation | Dead-letters without sending the request. |

Automatic retries continue for up to 24 hours by default. The event ID, raw body, signing
timestamp, key version, and signature remain byte-for-byte identical across automatic and manual
retries. Searchable history is available for 30 days.

Because the timestamp is intentionally stable across retries, use the immutable event ID—not a
short arrival-time cutoff—as the primary replay defense. You can enforce a timestamp policy that
matches your integration, but it must allow Karsa's documented retry window or valid retries will
be rejected.

Karsa re-resolves and validates DNS before every attempt, requires HTTPS in production, blocks URL
credentials and private, loopback, link-local, documentation, multicast, and cloud-metadata address
ranges, and caps response time and response-body size.

## Which endpoints receive an event

When a KYB lifecycle event (`kyb.evaluation.queued`, `kyb.evaluation.completed`, or
`kyb.evaluation.failed`) occurs, Karsa creates a delivery for every endpoint that is `ACTIVE` and
subscribed to that event type at that moment. Events are not queued for endpoints that are
`PENDING_VERIFICATION` or `DISABLED`, and they are not sent automatically when the endpoint is
verified or enabled. Finish verification before you trigger work whose webhooks you need, or send
the events it missed afterwards with
[Replay missed events](#replay-missed-events). A `webhook.test`
event is different: it is sent only to the endpoint you request it for, which must be `ACTIVE`.

A queued delivery keeps the destination URL, raw body, timestamp, and signature it was created
with, so deliveries already queued continue to retry the previous URL until they succeed or
dead-letter. Changing the URL of an `ACTIVE` endpoint moves it back to `PENDING_VERIFICATION`: the
new URL receives only events that occur after it is verified, and events in between are not sent to
either URL.

Treat webhooks as notifications and the API as the source of truth. After downtime, a pending or
disabled endpoint, or a URL change, list the events you missed with `GET /api/ingestion/webhook-events`
as described in [Catch up on missed events](#catch-up-on-missed-events). For a known run,
`GET /api/ingestion/kyb-runs/{id}` returns its current state.

## Catch up on missed events

Karsa records every KYB lifecycle event for your ingestion source when it occurs, whether or not an
endpoint received it. `GET /api/ingestion/webhook-events` lists those events for the calling source
credential, oldest first. Each item has the same fields and values as the envelope that was (or
would have been) delivered, including the same `id` as `Karsa-Webhook-Id`, so feed it through the
same inbox and deduplication as webhook deliveries. JSON key order can differ from the delivered
body, and items carry no signature: the authenticated API response is the proof of origin, so do
not run webhook signature verification on them.

```http theme={null}
GET /api/ingestion/webhook-events?since=2026-09-25T00:00:00Z&limit=100
Authorization: Bearer <source-credential>
```

* Pass `nextCursor` as `cursor` to read the next page. `nextCursor` is `null` on the last page.
* `type` filters by event type; repeat it or separate values with commas. `webhook.test` events are
  excluded unless you request them. Endpoint verification challenges are not recorded.
* Events are listed for 30 days, the same window as delivery history. Older events and a `since`
  before that window are not returned.
* To resume later, pass the `createdAt` of the last event you stored as `since`, starting a few
  minutes earlier: an event can become visible shortly after its `createdAt`. Skip IDs you already
  stored.

`GET /api/ingestion/webhook-events/{eventId}` returns one event, or `404` with
`WEBHOOK_EVENT_NOT_FOUND` when it does not belong to your source or is outside the window.

## Replay missed events

After an endpoint is verified or re-enabled, you can have Karsa deliver the events it missed instead
of reading them from the events API. Choose **Replay missed events** on the endpoint in the web app
(supervisors only), or call:

```http theme={null}
POST /api/ingestion/webhook-endpoints/{endpointId}/replay-missing
Authorization: Bearer <source-credential>
Content-Type: application/json

{ "since": "2026-09-27T12:00:00Z" }
```

```json theme={null}
{ "replayed": 3, "skippedAlreadyDelivered": 12, "capped": false }
```

* Karsa queues a delivery to this endpoint for every recorded event of your source that was created
  at or after `since`, has a type the endpoint subscribes to now, and was never queued for this
  endpoint. Events the endpoint already has a delivery for, in any state, are counted in
  `skippedAlreadyDelivered` and not sent again; use [manual retry](#replay-a-failed-delivery) for
  dead-lettered ones. `webhook.test` events are never replayed.
* The endpoint must be `ACTIVE`; otherwise the call returns `409` with `WEBHOOK_NOT_ACTIVE`.
* `since` is limited to the 30-day history window; an earlier value starts at the window.
* Each call queues at most 500 events, oldest first. When `capped` is `true`, call again with the
  same `since` to queue the rest. Repeating a call never queues an event twice.
* A replayed delivery has the original event ID in `Karsa-Webhook-Id` and the original envelope as
  its body, so your inbox deduplicates it like any retry. JSON key order can differ from the body
  other endpoints received.
* A replayed delivery is signed when it is queued, with the endpoint's current active key, and
  `Karsa-Webhook-Timestamp` is that time rather than the event's `createdAt`. A replay is a new
  delivery with its own full retry window, and within it every attempt keeps the same timestamp and
  signature, so a timestamp policy that allows the retry window also accepts replays of older
  events.
* OIDC service principals need the `karsa:webhooks:manage` scope. The call is audit-logged and
  shares your source's request rate limit.

## Duplicate handling and ordering

Apply a unique database constraint to the event ID. A safe receiver transaction is:

1. Verify the signature and header/body agreement.
2. Insert the event ID and raw payload into an inbox table with a unique constraint.
3. If the event ID already exists, return a `2xx` without applying it again.
4. Commit the inbox record.
5. Process the event asynchronously and idempotently.

Do not rely on delivery order across endpoints or customers. For multiple events affecting the same
subject, compare `createdAt` and the lifecycle state rather than assuming the last request received
is the newest state.

## Rotate signing keys

Choose **Rotate key** in the web app or use the rotate-key API. Karsa generates and retains the new
private key and publishes the new public key version. The new version becomes active immediately.
The previous public key remains in `OVERLAP` for at least the configured delivery retry window—24
hours by default and configurable up to 168 hours. The web app requests the full 168-hour overlap
so rotation remains safe for every supported retry configuration. API clients may choose a shorter
overlap only when it is at least the environment's configured retry window.

During overlap:

* Keep both public-key versions in your receiver's key map.
* Select the key using `Karsa-Webhook-Key-Version`.
* Do not replace the old key until its overlap status becomes `RETIRED`.
* Remember that previously queued deliveries remain pinned to their original key and signature.

## Replay a failed delivery

When a delivery reaches `DEAD_LETTER`, choose **Retry** in its delivery history. Manual retry does
not create a new event. It preserves the original event ID, raw body, timestamp, key version, and
signature, and creates another append-only delivery attempt.

Deleting an endpoint stops new events and marks pending deliveries as dead-lettered. The deleted
endpoint remains as a read-only archive in the endpoint list for the configured history-retention
period so analysts can still inspect its delivery and attempt history; deleted deliveries cannot be
retried.

## Troubleshooting

| Symptom | What to check |
| - | - |
| Verification remains pending | Return `2xx` JSON with the exact challenge after signature check. |
| Verification says the endpoint is disabled | Enable it explicitly, then verify it again. |
| `Invalid signature` | Verify raw bytes before JSON parsing and use base64url decoding. |
| `Unknown key or signature version` | Refresh public-key metadata and keep overlap keys available. |
| Events are delivered more than once | Add a unique constraint on `Karsa-Webhook-Id`. |
| Valid retries are rejected as stale | Allow the configured retry window: 24 hours by default, up to 168. |
| Endpoint receives no new events | Check status, source, and subscribed event types. |
| URL works in a browser but Karsa rejects | Check HTTPS, DNS results, redirects, credentials, and IP ranges. |
| Delivery is dead-lettered after `4xx` | Correct the receiver, then use manual retry. |

## Go-live checklist

* The production URL uses HTTPS and does not redirect.
* Raw-body signature verification runs before JSON parsing.
* Public keys are selected by key version and stored securely.
* Header event ID/type are compared with the body.
* Event IDs have a unique database constraint.
* Events are durably queued before returning `2xx`.
* Processing is asynchronous and idempotent.
* Both active and overlapping public keys are accepted during rotation.
* Delivery failures and dead-lettered events are monitored.
* A test event succeeds from Karsa and appears in delivery history.


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