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_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.
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"
}
| 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.
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, new events are silently dropped (logged, counted onoyster_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
| 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.