Wallet activity — address activity stream
Every transaction that touches an address on your watchlist, as one event per (network, transaction, address): who sent what, which tokens and native coin moved, whether it reverted and why, and how deep the block was when we sent it. The match is on the address taking part, not on the protocol, so a new router, claim contract or bridge shows up without any change on our side. Delivered through the same Wallet Watch channels: WebSocket, webhook or REST cursor.
https://triport.io/v1/wallet/watch- Network
- Ethereum (1), Base (8453), BNB Smart Chain (56), Robinhood Chain (4663); Polygon (137) is scanned too, see Coverage
- Scope
- — (tier-gated, see below)
- Auth
- x-token: $TRIPORT_API_KEY (or Authorization: Bearer); the key must belong to an account (dashboard key), not a static token
- Rate limit
- Business and Enterprise, same as Wallet Watch: REST 30 rps (Business) / 150 rps (Enterprise) in the wallet_identity category; stream limits in Limits
wallet_activity is an event type of Wallet Watch. Add it
to a subscription together with a watchlist, and the scanner reports every
transaction in which a watched address takes part:
- the address sent the transaction or was its target (
tx_from,tx_to), including zero-value calls such as approve, swap, claim, stake, and failed transactions; - native coin moved in or out, both as the transaction value and, where
tracing is available, inside the call tree (
native_internal_in/out); - ERC-20, ERC-721 and ERC-1155 tokens moved in or out, even when the address is neither the sender nor the target of the transaction;
- the address appears in any log, as an indexed topic or as a 32-byte word of log data (a claim, a vault deposit, a bridge recipient);
- the address sent a bridge deposit or received a bridge fill.
The scanner already reads every block with all receipts. Level A matching (transaction fields and logs) needs no extra upstream calls. Only addresses on some watchlist ever become events, so the stream holds your addresses, not the whole chain.
One event per (network, transaction, watched address). An approve plus a
swap plus an ETH refund in one transaction is one event with three
movements and a combined matched_by. If two of your addresses take part in
the same transaction, you get two events with the same tx.hash; group them
by hash if you need a per-transaction view. A subscription only sees events of
its own watchlist.
Protocol-agnostic. Triport does not name actions ("swap", "claim",
"stake") and does not decode protocol calldata. You get tx.method_id, the
exact token movements and the raw facts; decoding is up to you.
Parameters
Subscription body
wallet_activity uses the regular Wallet Watch body
(full field list). Fields that matter here:
eventsstring[]requiredwallet_activity. It can be combined with the other Wallet Watch types.chainsstring[]requiredeth, base, bsc, robinhood, polygon monitored on the serving PoP. Business allows all 5 in one subscription.watchliststring[]optional0x + 40 hex, any case; stored lowercase). An empty watchlist → 400 watchlist_required. Up to 10 000 (Business) / 100 000 (Enterprise). Change it later with /watchlist without re-creating the subscription.min_confirmationsintegeroptional0 (default) = deliver at scanner depth. Range 0 to the smallest reorg window of the subscription's networks (12 when eth is included, else 64). Applies to every event type of the subscription except the system events reorg_revert and gap, which are never held. See Ordering. Until the deployment's database carries this option, a value above 0 is refused with 503 option_unavailable — never silently ignored.deliveryobjectrequired{"type":"ws"}, {"type":"webhook","url":"https://…","secret":"…"} or {"type":"pull"}.filter, enrich, min_amount_usd—optionalwallet_activity in v1: you chose the addresses. A subscription whose only event type is wallet_activity and that has a non-empty filter → 400 filter_not_supported. With mixed types the filter applies to the other types only.Watchlist managementobjectStream parametersobjectiddelivery.type = ws).cursorcursor of the last event you processed. Takes precedence over after_seq / after.after_seqafter, limitafter is deprecated (PoP-local seq); limit 1..1000, default 100.Response
POST /v1/wallet/watch returns the subscription (201); see
Wallet Watch. GET /v1/wallet/watch now also
tells you which limits apply to the key:
limits_source is tier (limits of the key's plan), override (limits raised
for your account by support) or default (no plan on the request context;
Business limits apply).
typestringwallet_activity.event_idstringwa_ + the first 32 hex characters of sha256("<chain_id>|<block_hash>|<tx_hash>|<address>"). Same on every PoP and on every redelivery. It changes only if a reorg moves the transaction into another block.seqintegercursor to resume.cursorstringc1.…), bound to this subscription. Pass the last one you processed as ?cursor=.chain, chain_idstring, integereth 1, base 8453, bsc 56, robinhood 4663, polygon 137.blockobjectnumber, hash, timestamp (block time, UTC).tx.hash, tx.index, tx.from, tx.nonce, tx.type—tx.tostring | nullnull for a contract creation.tx.created_contractstringtx.valuestringtx.method_idstring | nullnull without calldata.tx.gas_used, tx.effective_gas_price, tx.feestringfee = gas_used × effective_gas_price (+ l1_fee). Also reported for reverted transactions.tx.l1_feestringstatusstringsuccess, reverted, or unknown when the receipt could not be read (then coverage.logs is unavailable).errorobject | nullnull unless reverted. See Status, confirmation and error.confirmationobjectaddressstringmatched_bystring[]movementsobject[]address in this transaction. Can be empty (an approve, a failed call).bridgeobjectcoverageobjectlogs (full | unavailable) and internal_native (traced | matched_tx_only | matched_tx_skipped | unavailable).logsarrayinclude=logs; not emitted yet.Errors
| Code | Meaning | When it happens |
|---|---|---|
400 | watchlist_required | wallet_activity with an empty watchlist (create, PATCH, or a watchlist remove that would empty it). |
400 | filter_not_supported | Only wallet_activity selected and a non-empty filter. |
400 | invalid_min_confirmations | Outside 0 to the smallest reorg window of the subscription's networks. |
400 | too_many_chains / too_many_watchlist | Over the tier limit. |
400 | invalid_body / too_many_addresses / conflicting_address / invalid_watchlist | Watchlist call: empty or malformed body, more than 10 000 addresses, the same address in add and remove, or a bad address. |
400 | invalid_cursor / invalid_limit / invalid_after / invalid_after_seq | Malformed stream or page parameter; a cursor longer than 4096 characters is invalid_cursor. The WebSocket answers these before the upgrade. |
400 | cursor_subscription_mismatch | The cursor was issued for another subscription. |
401 | account_required | The key is not backed by an account. |
403 | tier_insufficient / subscription_limit | Tier below Business, or the subscription cap is reached. |
404 | not_found | No such subscription for this account. |
410 | cursor_expired | The REST cursor is older than the retained window; body has oldest_cursor and recoverable: false. |
503 | capacity_exceeded | The total watched-address cap would be exceeded (checked across both PoPs under a shared lock). Retry later or contact support. |
503 | busy | Another watched-address change is being applied; retry. |
503 | events_unavailable | The event store is not enabled on this PoP (…/events). |
503 | option_unavailable | min_confirmations > 0 on a deployment that does not support it yet (the option is refused, never silently dropped). |
503 | coverage_unavailable | /coverage is not available on this PoP. |
405 | method_not_allowed | Unsupported method on a /v1/wallet/watch path. |
WebSocket close codes: 4410 after cursor_expired, 4029 when the client
buffer is full.
All error bodies use the shared envelope (error, message, code). See
errors.md.
Examples
Add and remove addresses
curl -X POST "https://triport.io/v1/wallet/watch/$SUB_ID/watchlist" \
-H "Authorization: Bearer $TRIPORT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"add": ["0x023ab8e20a4682d315daef4c91db96bd77934d66"], "remove": ["0x133243d447026345c2b368d7ffe435dbe3c566eb"]}'
# {"added":1,"removed":1,"total":2}Read the whole watchlist
cursor=""
while :; do
page=$(curl -s "https://triport.io/v1/wallet/watch/$SUB_ID/watchlist?limit=5000&cursor=$cursor" \
-H "Authorization: Bearer $TRIPORT_API_KEY")
echo "$page" | jq -r '.watchlist[]'
[ "$(echo "$page" | jq -r .has_more)" = "true" ] || break
cursor=$(echo "$page" | jq -r .next_cursor)
doneConsume the WebSocket with cursor resume and dedupe (Node)
import WebSocket from "ws";
let cursor = loadCursor(); // your persisted last cursor, or ""
const seen = new Set(); // event_ids seen in the last ≥ 5 minutes
function connect() {
const q = new URLSearchParams({ id: process.env.SUB_ID });
if (cursor) q.set("cursor", cursor);
const ws = new WebSocket(`wss://triport.io/ws/wallet-watch?${q}`, {
headers: { Authorization: `Bearer ${process.env.TRIPORT_API_KEY}` },
});
ws.on("message", (raw) => {
const m = JSON.parse(raw);
if (m.type === "resume") return; // m.mode: none | seq | blocks
if (m.type === "cursor_expired") { // followed by close 4410
console.warn("events lost before", m.oldest_cursor);
cursor = m.oldest_cursor;
return;
}
if (!seen.has(m.event_id)) {
seen.add(m.event_id);
if (m.type === "reorg_revert") m.removed_event_ids.forEach(dropEvent);
else if (m.type === "gap") markUnverified(m.chain, m.from_block, m.to_block, m.reason);
else handleActivity(m); // wallet_activity
}
cursor = m.cursor;
saveCursor(cursor);
});
ws.on("close", () => setTimeout(connect, 1000));
}
connect();Webhook receiver with signature check and dedupe (Node)
import { createHmac, timingSafeEqual } from "node:crypto";
export function onWebhook(rawBody, headers, secret, store) {
const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
const got = headers["x-triport-signature"] ?? "";
if (got.length !== expected.length || !timingSafeEqual(Buffer.from(got), Buffer.from(expected))) return 401;
const id = headers["x-triport-event-id"];
if (store.has(id)) return 200; // at-least-once: already processed
store.add(id, JSON.parse(rawBody));
store.saveCursor(headers["x-triport-cursor"]);
return 200;
}Catch up over REST after downtime (Python)
import os, requests
API = f"https://triport.io/v1/wallet/watch/{os.environ['SUB_ID']}/events"
H = {"Authorization": f"Bearer {os.environ['TRIPORT_API_KEY']}"}
cursor, seen = load_cursor(), set()
while True:
r = requests.get(API, params={"cursor": cursor, "limit": 1000}, headers=H, timeout=30)
if r.status_code == 410: # cursor_expired: the gap cannot be recovered
cursor = r.json()["oldest_cursor"]
continue
page = r.json()
for ev in page["events"]:
if ev["event_id"] not in seen:
seen.add(ev["event_id"])
handle(ev)
cursor = page["next_cursor"]
if not page["has_more"]:
break
save_cursor(cursor)Check coverage before relying on internal transfers
curl -s "https://triport.io/v1/wallet/watch/coverage" -H "Authorization: Bearer $TRIPORT_API_KEY" \
| jq '.chains[] | {chain, internal_native: .levels.internal_native, lag: .scanner.lag}'Notes
- Deduplicate by
event_id, always, keeping ids for at least 5 minutes. Redelivery happens on resume at another PoP, on webhook failover and on webhook retries. - Order is guaranteed within one network, not across networks.
- Two addresses, one transaction: two events with the same
tx.hash. - Amounts are exact decimal strings in the smallest unit; do not parse them as floating point.
- Retention and PoPs: events are stored per PoP for 72 hours. Subscriptions and watchlists are shared by both PoPs; a watchlist change reaches the other PoP within about 5 seconds.
- Related: Wallet Watch (other event types, delivery basics), AsyncAPI contract, REST contract, wallet identity.