Skip to main content

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.

The webhook has a defined trigger condition, payload schema, retry behavior, and circuit-breaker semantics. You write a receiver to handle it.

How the webhook works​

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 should credit the wallet (or alert a human to do so) and acknowledge with a 2xx status.

Oyster currently emits only account.funding_required. Future events share the same envelope shape. Receivers should switch on the type field rather than assuming a single schema.

When does Oyster send the webhook?​

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.

Oyster logs and meters any other class of failure (Sui RPC down, network timeout, or signing error) but does not fire a webhook.

Payload schema​

Oyster sends the following JSON object 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. Oyster computes the WAL amount from the planned extension's encoded capacity × POOL_EXTEND_EPOCHS × the Walrus per-unit storage price.

Authentication​

The server signs every delivery with a per-app Ed25519 key pair that it generates when you register the webhook URL. 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 Admin API response from PUT /api/v1/admin/app/webhook (or GET /api/v1/admin/app) returns the public key 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.

How does Oyster retry failed deliveries?​

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 after each failed attempt, capped at 5 s. See Retry backoff timing.

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 or 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, the circuit silently drops new events (logs them, counts them on oyster_webhook_circuit_open_total, but does not queue them for later delivery).
  • Half-open after the 60 s cooldown: the circuit allows the next event through as a probe. On success the circuit closes; on failure it re-arms for another 60 s.

Because the circuit does not queue dropped events, 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​

extension_task.rs generates event_id as a fresh UUID v4 once per delivery, and the webhook client reuses it across every retry attempt it 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.

How to set up a webhook receiver​

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 server discards the old public key and generates a fresh one, so you must update already-deployed receivers 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.

How to verify signatures​

Verify each delivery against the public key returned when you register the webhook. Compute the verification over the exact request body bytes. Do not re-serialize the JSON. The fingerprint header carries hex of the first 8 bytes of the public key, so compare it against the hex encoding of your configured key rather than against raw key bytes.

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

Retry backoff timing​

Oyster sleeps only between attempts. After a failed attempt, it waits the current delay and then doubles that delay for the next gap, capped at 5 s. It does not sleep after the final attempt. With 3 attempts, the full schedule is 100 ms before attempt 2 and 200 ms before attempt 3. The next doubled value (400 ms) never applies because no fourth attempt follows.

What happens when you rotate the webhook key?​

Rotation has no overlap window. Each PUT discards the prior key pair, so Oyster never signs with both the old and new keys. A receiver that still holds the old public key sees a fingerprint mismatch in X-Oyster-Public-Key-Fingerprint on every delivery after the call. Use that header to detect the rotation and install the new public key returned by the PUT response, or read the key back from GET /api/v1/admin/app.