Webhooks

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.

{
  "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"
}
FieldTypeDescription
event_idUUID v4 stringStable id for this delivery; reused across all retry attempts. Receivers MUST dedupe by this.
typestringEvent type discriminator. Always "account.funding_required" for this event.
account_idstringOyster account whose pool needs extension funding.
pearl_addressstringSui wallet address derived by Pearl for this account (the address that needs funding).
amount.wal_frostdecimal stringWAL required, in FROST units (1 WAL = 10⁹ FROST).
amount.sui_mistdecimal stringSUI required, in MIST units (1 SUI = 10⁹ MIST).
timestampISO-8601 UTC stringWhen 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:

HeaderValue
X-Oyster-Signatureed25519=<base64(64-byte signature)> over the exact response body bytes.
X-Oyster-Public-Key-FingerprintHex 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:

OutcomeBehavior
2xxRecorded as success; circuit closes; no more attempts.
4xxNot retried. Logged, counted as a failure, delivery dropped.
5xxRetried up to 3 attempts total.
Connection / timeout errorRetried up to 3 attempts total.
All 3 attempts exhaustedLogged, counted as a failure, delivery dropped.

The same delivery might re-emerge later. See 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:

# 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.

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)

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)

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

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

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

SituationServer-side responseOyster behavior
Receiver returned 2xxsuccessdone; circuit resets
Receiver returned 4xxclient errornot retried; logged, counted as failure
Receiver returned 5xxserver errorretried (up to 3 attempts total)
Receiver-side processing failedreturn 5xxOyster retries this delivery
Connection refused / timeoutnetwork failureretried (up to 3 attempts total)
Circuit opennoneevent silently dropped, not queued

Metrics

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

MetricDescription
oyster_webhook_attempts_totalTotal webhook delivery attempts (one per delivery, not per retry).
oyster_webhook_successes_totalDeliveries that received 2xx within the retry budget.
oyster_webhook_failures_totalDeliveries that exhausted retries or hit a non-retryable 4xx.
oyster_webhook_circuit_open_totalNumber 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.