Webhooks
Webhooks tell your backend how each sweep, revocation and hot wallet deployment ended, and when your gas wallet
runs low. You can always read the same state with GET requests; webhooks spare you the polling.
Set up the webhook
Section titled “Set up the webhook”curl -X PUT "https://api-testnet.portuna.io/v1/webhook" \ -H "Authorization: Bearer $PORTUNA_API_KEY" \ -H "Content-Type: application/json" \ -d '{"url": "https://processing.example/hooks/sweeps", "low_balance": [{"chain_id": 11155111, "threshold": "20000000000000000"}]}'- The URL must use
httpsand resolve to a public address. Redirects are not followed. - The answer holds
secret,whsec_…, which signs every delivery. Keep it on your backend. - Changing the URL with another
PUTkeeps the secret. To get a new secret,DELETE /v1/webhookand set it again: deleting drops the events not delivered yet. GET /v1/webhookshows the current settings,DELETE /v1/webhookremoves them.
Events
Section titled “Events”| Type | When | data |
|---|---|---|
sweep.swept | A sweep moved the funds. | Sweep |
sweep.failed | A sweep reverted on chain or ran out of attempts. | Sweep |
sweep.rejected | A sweep was never sent. | Sweep |
revocation.revoked, revocation.failed, revocation.rejected | A revocation ended. | Revocation |
hot_wallet.active, hot_wallet.failed | A hot wallet’s delegate was deployed, or was not. | HotWallet |
balance.low | Your gas wallet fell below a threshold. | BalanceLow |
webhook.test | You asked for a test event. | WebhookTest |
The body is the event: {"id", "type", "created_at", "data"}, where data is the object as GET would show it.
New event types may appear: accept and ignore the ones you do not handle.
Delivery
Section titled “Delivery”Each event is a POST with a JSON body and these headers:
| Header | Value |
|---|---|
Portuna-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256> |
Portuna-Event | the event type, such as sweep.swept |
Portuna-Delivery | the event id |
Content-Type | application/json |
- At least once. An event can arrive more than once: handle each event
idonce. - Any order. Events can arrive out of order: rely on the status in
data, not on the order of events. - Retries. Any answer other than 2xx, a redirect included, and no answer within 10 seconds count as a failure.
The event is tried again after 10 s, 20 s, 40 s and so on, up to an hour between attempts, 12 attempts in all.
Then it is
failed. - Answer fast. Return 2xx as soon as the event is safely stored, and do the heavy work after.
GET /v1/webhook/deliveries lists your recent events with their delivery state: pending, delivered or
failed, the attempts, your endpoint’s last HTTP status and the next attempt. The console shows the same log.
Verify the signature
Section titled “Verify the signature”v1 is the HMAC-SHA256, in hex, of the string <t>.<body> with your webhook secret as the key, where <t> is the
t of the header and <body> the raw request body. To verify a delivery:
- read the raw body, before any JSON parsing: re-serialized JSON would not match;
- take
tandv1from the header; with severalv1, the last one counts; - compute the HMAC and compare it with
v1in constant time; - check that
tis within 5 minutes of your clock, either way, so that an old delivery cannot be replayed.
Each attempt is signed again with its own t.
With the Go SDK, ParseWebhook verifies and decodes:
http.HandleFunc("POST /hooks/sweeps", func(w http.ResponseWriter, r *http.Request) { // Verify the body exactly as it arrived, before parsing it. body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) if err != nil { http.Error(w, "cannot read the body", http.StatusBadRequest) return } event, err := portuna.ParseWebhook(secret, r.Header.Get(portuna.SignatureHeader), body) if err != nil { http.Error(w, "bad signature", http.StatusUnauthorized) return } // Delivery is at least once: an event may come again, with the same id. if handled(event.ID) { w.WriteHeader(http.StatusNoContent) return } switch event.Type { case portuna.EventSweepSwept, portuna.EventSweepFailed, portuna.EventSweepRejected: sweep, err := event.Sweep() if err != nil { http.Error(w, "bad event", http.StatusBadRequest) return } // Events may come out of order: the status in the data is the current one. log.Printf("sweep %s (%s): %s %s %s", sweep.ID, sweep.ExternalID, sweep.Status, sweep.SweptAmount, sweep.Reason) case portuna.EventBalanceLow: alert, err := event.BalanceLow() if err != nil { http.Error(w, "bad event", http.StatusBadRequest) return } log.Printf("top up the gas wallet on chain %d: %s wei left", alert.ChainID, alert.Balance) default: // Other and future event types: accept them. } markHandled(event.ID) w.WriteHeader(http.StatusNoContent)})With the TypeScript SDK on Node.js, parseWebhookSync from @portuna/sdk/node; elsewhere (edge runtimes,
Deno, Bun) the async parseWebhook from @portuna/sdk, on Web Crypto:
function handle(event: Event): void { switch (event.type) { case 'sweep.swept': case 'sweep.failed': case 'sweep.rejected': // Events may come out of order: the status in the data is the current one. console.log(`sweep ${event.data.id} (${event.data.external_id}): ${event.data.status}`) break case 'balance.low': console.log(`top up the gas wallet on chain ${event.data.chain_id}: ${event.data.balance} wei left`) break default: // Other and future event types: accept them. }}
createServer((req, res) => { const chunks: Buffer[] = [] req.on('data', (chunk: Buffer) => chunks.push(chunk)) req.on('end', () => { // Verify the body exactly as it arrived, before parsing it. const body = Buffer.concat(chunks) let event: Event try { event = parseWebhookSync(secret, String(req.headers[signatureHeader.toLowerCase()] ?? ''), body) } catch { res.writeHead(401).end() return } // Delivery is at least once: an event may come again, with the same id. if (!handled.has(event.id)) { handle(event) handled.add(event.id) } res.writeHead(204).end() })}).listen(8080)No SDK needed, only the standard library:
# Verifies the Portuna-Signature header of a webhook delivery, with the standard library only.# The docs tests run it against the signature vectors the SDKs and the service share.import hashlibimport hmacimport reimport time
TOLERANCE = 300 # seconds between the signature time and now, either way
class SignatureError(Exception): pass
def verify_webhook(secret: str, header: str, body: bytes, now: float | None = None, tolerance: int = TOLERANCE) -> None: """Raises SignatureError unless the header signs body with secret within tolerance of now.
body must be the request body exactly as received, before any JSON parsing. """ ts, sig = None, None for part in header.split(","): key, _, value = part.partition("=") if key == "t": ts = value elif key == "v1": sig = value if ts is None or not re.fullmatch(r"[+-]?[0-9]+", ts) or not sig: raise SignatureError("malformed signature header") if abs((time.time() if now is None else now) - int(ts)) > tolerance: raise SignatureError("signature timestamp out of tolerance") expected = hmac.new(secret.encode(), ts.encode() + b"." + body, hashlib.sha256).hexdigest() if not hmac.compare_digest(sig.encode(), expected.encode()): raise SignatureError("signature mismatch")In a Flask app:
@app.post("/hooks/sweeps")def sweeps_hook(): try: verify_webhook(SECRET, request.headers.get("Portuna-Signature", ""), request.get_data()) except SignatureError: abort(401) event = request.get_json() ... # handle event["type"] and event["data"], once per event["id"] return "", 204Check your implementation with this delivery: with now = 1800000000, it must verify; with another secret or a
changed body, it must not.
| secret | whsec_test |
| body | {"id":"e1","type":"sweep.swept"} |
| header | t=1800000000,v1=b50785faa40c2334e54bc4a32003239e6e21eb93c63eb32d18598c18b0be5026 |
Test your endpoint
Section titled “Test your endpoint”curl -X POST "https://api-testnet.portuna.io/v1/webhook/test" -H "Authorization: Bearer $PORTUNA_API_KEY"It queues a webhook.test event, {"message": "Test event: the webhook works."}, signed and delivered like any
other, and answers 202 with the event id. Without a webhook set, the answer is 404 not_found.
Low balance alerts
Section titled “Low balance alerts”low_balance in PUT /v1/webhook sets a threshold in wei for each chain you want an alert on. The gas wallet is
checked every minute. When it falls below the threshold, you get one balance.low event; the alert re-arms once the
balance is back above it. low in GET /v1/webhook shows whether a chain is below its threshold now. Chains
without a threshold get no alert.
Set the threshold to a day or two of your usual gas: if the gas wallet runs dry, sweeps wait and retry, and after their attempts run out they fail.