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

# Webhooks

> Create webhook endpoints, receive activity events, and verify 0xkey webhook signatures.

export const H3Bordered = ({text}) => <h3 id={text.toLowerCase()} className="border-b pb-2.5 border-gray-100 dark:border-gray-800">
    {text}
  </h3>;

Webhooks let your backend receive asynchronous 0xkey activity events. In the current release, webhook endpoints are organization-scoped and can subscribe to activity updates.

<Note>
  **Current scope.** 0xkey currently supports the `ACTIVITY_UPDATES` subscription. This emits activity lifecycle events such as `activity.completed` and `activity.failed`. Balance and transaction subscriptions are not yet enabled.
</Note>

## Create an endpoint

You can create and manage webhook endpoints from the Dashboard under **Webhooks**.

For API-based setup, submit a `ACTIVITY_TYPE_CREATE_WEBHOOK_ENDPOINT` activity:

```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
curl --request POST \
  --url https://api.0xkey.io/public/v1/submit/create_webhook_endpoint \
  --header 'Accept: application/json' \
  --header 'Content-Type: application/json' \
  --header 'X-Stamp: <your-api-or-webauthn-stamp>' \
  --data '{
    "type": "ACTIVITY_TYPE_CREATE_WEBHOOK_ENDPOINT",
    "timestampMs": "1781250000000",
    "organizationId": "<organization-id>",
    "parameters": {
      "url": "https://example.com/webhooks/0xkey",
      "name": "Production activity webhook",
      "subscriptions": [
        {
          "eventType": "ACTIVITY_UPDATES",
          "filtersJson": "",
          "isActive": true
        }
      ]
    }
  }'
```

Production webhook URLs must use `https://`. Local development accepts `http://localhost` and `http://127.0.0.1`.

## Update or delete an endpoint

Update supports the endpoint URL, display name, and active status:

```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
curl --request POST \
  --url https://api.0xkey.io/public/v1/submit/update_webhook_endpoint \
  --header 'Accept: application/json' \
  --header 'Content-Type: application/json' \
  --header 'X-Stamp: <your-api-or-webauthn-stamp>' \
  --data '{
    "type": "ACTIVITY_TYPE_UPDATE_WEBHOOK_ENDPOINT",
    "timestampMs": "1781250000000",
    "organizationId": "<organization-id>",
    "parameters": {
      "endpointId": "<endpoint-id>",
      "url": "https://example.com/webhooks/0xkey",
      "name": "Production activity webhook",
      "isActive": true
    }
  }'
```

<Warning>
  Updating subscriptions is not supported yet. To change subscriptions, delete and recreate the endpoint.
</Warning>

Delete removes the 0xkey endpoint and synchronizes the deletion to the delivery backend:

```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
curl --request POST \
  --url https://api.0xkey.io/public/v1/submit/delete_webhook_endpoint \
  --header 'Accept: application/json' \
  --header 'Content-Type: application/json' \
  --header 'X-Stamp: <your-api-or-webauthn-stamp>' \
  --data '{
    "type": "ACTIVITY_TYPE_DELETE_WEBHOOK_ENDPOINT",
    "timestampMs": "1781250000000",
    "organizationId": "<organization-id>",
    "parameters": {
      "endpointId": "<endpoint-id>"
    }
  }'
```

## Delivery payload

0xkey sends a JSON POST body:

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "eventId": "976c99ff-2320-4f6b-9fb5-90cc706a8f49",
  "eventType": "activity.completed",
  "organizationId": "488dccf5-b0e5-46da-b830-5e86776bcf05",
  "activityId": "019eb30e-254f-7270-a177-8dca730bf487",
  "data": {
    "activityType": "ACTIVITY_TYPE_CREATE_WALLET",
    "result": {}
  },
  "timestamp": 1781250000000
}
```

`eventId` is stable across retries. Use it as your idempotency key.

## Delivery headers

Every signed webhook delivery includes these `X-0xkey-*` headers:

* `X-0xkey-Organization-Id`: the organization that owns the event.
* `X-0xkey-Event-Type`: the event type, for example `activity.completed`.
* `X-0xkey-Timestamp`: Unix timestamp in milliseconds for this delivery attempt.
* `X-0xkey-Signature-Version`: signature contract version. Current value: `v1`.
* `X-0xkey-Event-Id`: stable event ID, unchanged across retries.
* `X-0xkey-Signature-Key-Id`: `kid` for selecting the JWKS verification key.
* `X-0xkey-Signature-Algorithm`: current value: `ed25519`.
* `X-0xkey-Signature`: lowercase hex-encoded Ed25519 signature.

## Verify signatures

Fetch the public verification keys from:

```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
curl https://api.0xkey.io/public/v1/discovery/webhooks/jwks
```

Match `X-0xkey-Signature-Key-Id` to the JWK `kid`. If the `kid` is unknown, refetch JWKS once before rejecting the delivery.

The signed message is the UTF-8 prefix followed by the exact raw request body bytes:

```text theme={"theme":{"light":"github-light","dark":"github-dark"}}
{version}.{algorithm}.{key_id}.{timestamp_ms}.{event_id}.{raw_body}
```

Where:

* `version` is the `X-0xkey-Signature-Version` header (currently `v1`).
* `algorithm` is the `X-0xkey-Signature-Algorithm` header (currently `ed25519`).
* `key_id` is the `X-0xkey-Signature-Key-Id` header.
* `timestamp_ms` is the raw `X-0xkey-Timestamp` header string (Unix milliseconds).
* `event_id` is the `X-0xkey-Event-Id` header.
* `raw_body` is the exact UTF-8 request body bytes received by your server.

Example verification in TypeScript with `@0xkey-io/crypto`:

```ts theme={"theme":{"light":"github-light","dark":"github-dark"}}
import { verifyWebhookFromJWKS } from "@0xkey-io/crypto";

export async function handle0xkeyWebhook(req: Request) {
  const rawBody = await req.text();

  const result = await verifyWebhookFromJWKS({
    headers: req.headers,
    body: rawBody,
    jwksUrl: "https://api.0xkey.io/public/v1/discovery/webhooks/jwks",
    maxTimestampAgeMs: 300_000,
  });

  if (!result.ok) {
    throw new Error(`webhook verification failed: ${result.reason}`);
  }

  const payload = JSON.parse(rawBody);
  return payload;
}
```

To verify with a pinned public key instead of JWKS:

```ts theme={"theme":{"light":"github-light","dark":"github-dark"}}
import { verifyWebhook } from "@0xkey-io/crypto";

const result = verifyWebhook({
  headers: req.headers,
  body: rawBody,
  verificationKeys: [
    {
      keyId: process.env.ZEROXKEY_WEBHOOK_KEY_ID!,
      publicKey: process.env.ZEROXKEY_WEBHOOK_PUBLIC_KEY!, // JWKS `x` field (base64)
    },
  ],
  maxTimestampAgeMs: 300_000,
});
```

<Warning>
  Parse JSON only after signature verification. Verification must use the exact raw body bytes, not a re-serialized JSON object.
</Warning>

## Retries and idempotency

0xkey uses the delivery backend for retry and fanout. Your receiver should:

* Return any `2xx` status only after the event is durably accepted.
* Treat duplicate `eventId` values as successful no-ops.
* Keep processing fast; enqueue long-running work and respond quickly.
* Prefer HTTPS endpoints with stable DNS and TLS certificates.

## Related API reference

* [Create webhook endpoint](/api-reference/activities/create-webhook-endpoint)
* [Update webhook endpoint](/api-reference/activities/update-webhook-endpoint)
* [Delete webhook endpoint](/api-reference/activities/delete-webhook-endpoint)
* [List webhook endpoints](/api-reference/queries/list-webhook-endpoints)
* [Get webhook JWKS](/api-reference/queries/get-webhook-jwks)
