Skip to content
PortunaPortunaPortunaDocsTestnet

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.

Terminal window
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 https and 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 PUT keeps the secret. To get a new secret, DELETE /v1/webhook and set it again: deleting drops the events not delivered yet.
  • GET /v1/webhook shows the current settings, DELETE /v1/webhook removes them.
TypeWhendata
sweep.sweptA sweep moved the funds.Sweep
sweep.failedA sweep reverted on chain or ran out of attempts.Sweep
sweep.rejectedA sweep was never sent.Sweep
revocation.revoked, revocation.failed, revocation.rejectedA revocation ended.Revocation
hot_wallet.active, hot_wallet.failedA hot wallet’s delegate was deployed, or was not.HotWallet
balance.lowYour gas wallet fell below a threshold.BalanceLow
webhook.testYou 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.

Each event is a POST with a JSON body and these headers:

HeaderValue
Portuna-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256>
Portuna-Eventthe event type, such as sweep.swept
Portuna-Deliverythe event id
Content-Typeapplication/json
  • At least once. An event can arrive more than once: handle each event id once.
  • 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.

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:

  1. read the raw body, before any JSON parsing: re-serialized JSON would not match;
  2. take t and v1 from the header; with several v1, the last one counts;
  3. compute the HMAC and compare it with v1 in constant time;
  4. check that t is 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)
})

Check your implementation with this delivery: with now = 1800000000, it must verify; with another secret or a changed body, it must not.

secretwhsec_test
body{"id":"e1","type":"sweep.swept"}
headert=1800000000,v1=b50785faa40c2334e54bc4a32003239e6e21eb93c63eb32d18598c18b0be5026
Terminal window
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 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.