Oyster posts a single webhook event: `account.funding_required`.
It tells the owning app that an account's Pearl-derived wallet cannot
cover the next `extend_storage_pool` PTB. Top up the wallet and the
next extension cycle succeeds.

This page covers the trigger condition, payload schema, retry
behavior, circuit-breaker semantics, and how to write a receiver.

## Overview

When the extension worker tries to extend an account's `StoragePool`
and Sui rejects the transaction with an insufficient-funds error,
Oyster POSTs a JSON event to the receiver URL configured for the
owning app. The receiver is expected to credit the wallet (or alert a
human to do so) and acknowledge with a `2xx` status.

Only `account.funding_required` is emitted currently. Future events
share the same envelope shape. Receivers should switch on the `type`
field rather than assuming a single schema.

## Trigger condition

The webhook fires when **all** of the following hold during an
extension cycle:

- The extension worker claims an account row whose
  `pool_end_epoch < current_epoch + POOL_EXTEND_LOOKAHEAD_EPOCHS`.
- The `extend_storage_pool` PTB submission fails with an error whose
  lowercased message contains `insufficientgas`,
  `insufficientcoinbalance`, or `insufficient` (case-insensitive
  substring match; see `is_insufficient_funds_error` in
  `crates/oyster/src/webhook.rs`).
- The owning app has a webhook receiver URL configured.

Any other class of failure (Sui RPC down, network timeout, or signing
error) is logged and metered but does **not** fire a webhook.

## Payload schema

The following JSON object is sent with every `account.funding_required` delivery.

```json
{
  "event_id": "8f2c5e1a-...-uuid-v4",
  "type": "account.funding_required",
  "account_id": "acc_...",
  "pearl_address": "0x...",
  "amount": {
    "wal_frost": "12345678900",
    "sui_mist": "100000000"
  },
  "timestamp": "2026-05-05T10:31:00Z"
}
```

| Field | Type | Description |
|-------|------|-------------|
| `event_id` | UUID v4 string | Stable id for this delivery; reused across all retry attempts. Receivers MUST dedupe by this. |
| `type` | string | Event type discriminator. Always `"account.funding_required"` for this event. |
| `account_id` | string | Oyster account whose pool needs extension funding. |
| `pearl_address` | string | Sui wallet address derived by Pearl for this account (the address that needs funding). |
| `amount.wal_frost` | decimal string | WAL required, in FROST units (1 WAL = 10⁹ FROST). |
| `amount.sui_mist` | decimal string | SUI required, in MIST units (1 SUI = 10⁹ MIST). |
| `timestamp` | ISO-8601 UTC string | When Oyster emitted the event. |

`amount.*` are **decimal strings**, not numbers, to avoid `u64`
precision loss in JSON. The SUI amount is currently a fixed
`100_000_000` MIST (≈0.1 SUI) buffer. Oyster does not dry-run gas.
The WAL amount is computed from the planned extension's encoded
capacity × `POOL_EXTEND_EPOCHS` × the Walrus per-unit storage price.

## Authentication

Every delivery is signed with a per-app Ed25519 keypair generated by
the server when the webhook URL is registered. Two headers carry the
signature:

| Header | Value |
|--------|-------|
| `X-Oyster-Signature` | `ed25519=<base64(64-byte signature)>` over the exact response body bytes. |
| `X-Oyster-Public-Key-Fingerprint` | Hex of the first 8 bytes of the public key. Lets receivers detect rotation before attempting verification. |

The public key is returned in the response from
`PUT /api/v1/admin/app/webhook` (or `GET /api/v1/admin/app`) as a
base64-encoded 32-byte string. Receivers MUST verify the signature
and reject deliveries whose fingerprint does not match the currently
configured public key.

## Retry policy

Oyster retries the same delivery up to `MAX_RETRIES = 3` times with
exponential backoff:

- Attempt 1: immediate.
- Attempt 2: after 100 ms.
- Attempt 3: after 200 ms.

(Backoff doubles each retry and is capped at 5 s, so the third sleep
would be 400 ms, well under the cap.)

Retry semantics by response:

| Outcome | Behavior |
|---------|----------|
| `2xx` | Recorded as success; circuit closes; no more attempts. |
| `4xx` | **Not retried.** Logged, counted as a failure, delivery dropped. |
| `5xx` | Retried up to 3 attempts total. |
| Connection / timeout error | Retried up to 3 attempts total. |
| All 3 attempts exhausted | Logged, counted as a failure, delivery dropped. |

The same delivery might re-emerge later. See [Idempotency](#idempotency).

## Circuit breaker

To prevent a misbehaving receiver from monopolizing the extension
worker, the webhook client wraps deliveries in a per-client circuit
breaker:

- **Closed** (normal): every event attempts delivery.
- **Opens** after `5` consecutive failed deliveries.
- **Stays open** for `60` seconds. While open, new events are
  **silently dropped** (logged, counted on
  `oyster_webhook_circuit_open_total`, but **not queued** for later
  delivery).
- **Half-open** after the 60 s cooldown: the next event is allowed
  through as a probe. On success the circuit closes; on failure it
  re-arms for another 60 s.

Because dropped events are not queued, recovery from a long receiver
outage relies on the next extension cycle re-claiming the account
once its `EXTENSION_CLAIM_COOLDOWN_SECS` elapses. In practice, if
your receiver is down, you miss notifications for the duration of the
outage, but a healthy receiver starts receiving events again on the
next cycle after recovery.

## Idempotency

`event_id` is a fresh UUID v4 generated **once per delivery** in
`extension_task.rs`, then reused across every retry attempt the
webhook client makes for that delivery. **Receivers MUST dedupe by
`event_id`.**

A separate delivery for the same account in a later cycle has a
**fresh** `event_id`, so dedup is per-delivery, not per-account. If
you want to suppress repeated notifications for the same underfunded
account, do so in your receiver based on `account_id` and your own
state.

## Setup

Webhook receiver URLs are self-service. Use the `oyster` CLI (or the
Admin API directly) with your app admin key:

```bash
# Register or rotate. Each call generates a fresh Ed25519 keypair;
# the response includes the new public key.
oyster app webhook set https://example.com/oyster/webhook

# Show the current URL and public key.
oyster app webhook show

# Stop deliveries.
oyster app webhook clear
```

`set` always rotates: the old public key is discarded and a fresh one
is generated, so already-deployed receivers must be updated with the
new key after each call. The corresponding HTTP endpoints are
`PUT /api/v1/admin/app/webhook`, `DELETE /api/v1/admin/app/webhook`,
and `GET /api/v1/admin/app`. See [Admin API](../json-api/admin.mdx).

## Verifying signatures

Verify each delivery against the public key returned at registration.
Compute the verification over the exact request body bytes. Do not
re-serialize the JSON.

### Node.js (tweetnacl)

```javascript
import nacl from "tweetnacl";

// Set this from the response body of `PUT /api/v1/admin/app/webhook`.
const PUBLIC_KEY = Buffer.from("<base64-public-key>", "base64");

function verifyOysterSignature(rawBody, headers) {
  const sig = headers["x-oyster-signature"] || "";
  const fp = headers["x-oyster-public-key-fingerprint"] || "";
  const expectedFp = PUBLIC_KEY.subarray(0, 8).toString("hex");
  if (fp !== expectedFp) return false;
  const sigPrefix = "ed25519=";
  if (!sig.startsWith(sigPrefix)) return false;
  const sigBytes = Buffer.from(sig.slice(sigPrefix.length), "base64");
  if (sigBytes.length !== 64) return false;
  return nacl.sign.detached.verify(rawBody, sigBytes, PUBLIC_KEY);
}
```

### Python (pynacl)

```python
import base64
import hmac
import nacl.signing
import nacl.exceptions

PUBLIC_KEY_B64 = "<base64-public-key>"  # from the PUT response
_PUBLIC_KEY_BYTES = base64.b64decode(PUBLIC_KEY_B64)
_VERIFY_KEY = nacl.signing.VerifyKey(_PUBLIC_KEY_BYTES)
_EXPECTED_FP = _PUBLIC_KEY_BYTES[:8].hex()

def verify_oyster_signature(raw_body: bytes, headers) -> bool:
    sig_header = headers.get("X-Oyster-Signature", "")
    fp_header = headers.get("X-Oyster-Public-Key-Fingerprint", "")
    if not hmac.compare_digest(fp_header, _EXPECTED_FP):
        return False
    if not sig_header.startswith("ed25519="):
        return False
    sig_bytes = base64.b64decode(sig_header[len("ed25519=") :])
    try:
        _VERIFY_KEY.verify(raw_body, sig_bytes)
        return True
    except nacl.exceptions.BadSignatureError:
        return False
```

## Receiver examples

Both examples show the minimum viable receiver: dedupe by `event_id`,
acknowledge promptly with `200`, and return `5xx` on processing
failure so Oyster retries.

### Node.js / Express

```javascript
import express from "express";
import nacl from "tweetnacl";

const PUBLIC_KEY = Buffer.from(process.env.OYSTER_WEBHOOK_PUBKEY, "base64");
const EXPECTED_FP = PUBLIC_KEY.subarray(0, 8).toString("hex");

const app = express();
// We need the raw body to verify the signature; parse JSON ourselves.
app.use(express.raw({ type: "application/json" }));

const seenEventIds = new Set();

app.post("/oyster/webhook", async (req, res) => {
  const sig = req.header("x-oyster-signature") || "";
  const fp = req.header("x-oyster-public-key-fingerprint") || "";
  if (
    fp.length !== EXPECTED_FP.length ||
    !nacl.verify(Buffer.from(fp), Buffer.from(EXPECTED_FP))
  ) {
    return res.status(401).send();
  }
  if (!sig.startsWith("ed25519=")) return res.status(401).send();
  const sigBytes = Buffer.from(sig.slice("ed25519=".length), "base64");
  if (sigBytes.length !== 64) return res.status(401).send();
  if (!nacl.sign.detached.verify(req.body, sigBytes, PUBLIC_KEY)) {
    return res.status(401).send();
  }

  const { event_id, type, account_id, pearl_address, amount } = JSON.parse(
    req.body.toString("utf8"),
  );

  if (seenEventIds.has(event_id)) {
    return res.status(200).send();
  }
  seenEventIds.add(event_id);

  if (type !== "account.funding_required") {
    return res.status(200).send();
  }

  try {
    await topUpWallet(pearl_address, amount.wal_frost, amount.sui_mist);
    return res.status(200).send();
  } catch (err) {
    console.error("top-up failed for", account_id, err);
    seenEventIds.delete(event_id);
    return res.status(503).send();
  }
});

app.listen(8080);
```

### Python / Flask

```python
import base64
import hmac
import json
import os
import nacl.signing
import nacl.exceptions
from flask import Flask, request

PUBLIC_KEY_BYTES = base64.b64decode(os.environ["OYSTER_WEBHOOK_PUBKEY"])
VERIFY_KEY = nacl.signing.VerifyKey(PUBLIC_KEY_BYTES)
EXPECTED_FP = PUBLIC_KEY_BYTES[:8].hex()

app = Flask(__name__)
seen_event_ids = set()

def _verify(raw_body: bytes) -> bool:
    fp = request.headers.get("X-Oyster-Public-Key-Fingerprint", "")
    if not hmac.compare_digest(fp, EXPECTED_FP):
        return False
    sig_header = request.headers.get("X-Oyster-Signature", "")
    if not sig_header.startswith("ed25519="):
        return False
    try:
        sig_bytes = base64.b64decode(sig_header[len("ed25519=") :])
        VERIFY_KEY.verify(raw_body, sig_bytes)
        return True
    except (ValueError, nacl.exceptions.BadSignatureError):
        return False

@app.post("/oyster/webhook")
def funding_required():
    raw = request.get_data(cache=False)
    if not _verify(raw):
        return "", 401
    payload = json.loads(raw)
    event_id = payload["event_id"]

    if event_id in seen_event_ids:
        return "", 200
    seen_event_ids.add(event_id)

    if payload["type"] != "account.funding_required":
        return "", 200

    try:
        top_up_wallet(
            payload["pearl_address"],
            int(payload["amount"]["wal_frost"]),
            int(payload["amount"]["sui_mist"]),
        )
        return "", 200
    except Exception:
        app.logger.exception("top-up failed for %s", payload["account_id"])
        seen_event_ids.discard(event_id)
        return "", 503
```

> In production, persist the dedup set (for example, Redis with a TTL of a
> few hours) so receiver restarts do not re-process events.

## Error semantics

| Situation | Server-side response | Oyster behavior |
|-----------|---------------------|-----------------|
| Receiver returned `2xx` | success | done; circuit resets |
| Receiver returned `4xx` | client error | **not retried**; logged, counted as failure |
| Receiver returned `5xx` | server error | retried (up to 3 attempts total) |
| Receiver-side processing failed | return `5xx` | Oyster retries this delivery |
| Connection refused / timeout | network failure | retried (up to 3 attempts total) |
| Circuit open | none | event silently dropped, not queued |

## Metrics

The Oyster server's Prometheus endpoint exposes four webhook
counters:

| Metric | Description |
|--------|-------------|
| `oyster_webhook_attempts_total` | Total webhook delivery attempts (one per delivery, not per retry). |
| `oyster_webhook_successes_total` | Deliveries that received `2xx` within the retry budget. |
| `oyster_webhook_failures_total` | Deliveries that exhausted retries or hit a non-retryable `4xx`. |
| `oyster_webhook_circuit_open_total` | Number of times the circuit breaker transitioned to open. |

Pair these with the extension worker counters
(`oyster_extension_pools_extended_total`,
`oyster_extension_errors_total{stage}`) to alert on chronic
under-funding without alerting on transient receiver failures.