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_poolPTB submission fails with an error whose lowercased message containsinsufficientgas,insufficientcoinbalance, orinsufficient(case-insensitive substring match; seeis_insufficient_funds_errorincrates/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"
}
| 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.
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:
| 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 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:
| 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 or 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.
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
5consecutive failed deliveries. - Stays open for
60seconds. While open, the circuit silently drops new events (logs them, counts them onoyster_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
| 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 or 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.
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.