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.

POSThttps://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[]required
Include wallet_activity. It can be combined with the other Wallet Watch types.
chainsstring[]required
Any of eth, base, bsc, robinhood, polygon monitored on the serving PoP. Business allows all 5 in one subscription.
watchliststring[]optional
EVM addresses (0x + 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_confirmationsintegeroptional
Hold each event until the network head is at least N blocks past the event's block. 0 (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—optional
Not applied to wallet_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 managementobject
Stream parametersobject
id
Subscription id (required, delivery.type = ws).
cursor
The cursor of the last event you processed. Takes precedence over after_seq / after.
after_seq
Deprecated: PoP-local seq. Still accepted.
after, limit
after 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).

typestring
wallet_activity.
event_idstring
Stable id for deduplication: wa_ + 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.
seqinteger
Position in the PoP that sent the event. Local to that PoP; use cursor to resume.
cursorstring
Opaque resume position (c1.…), bound to this subscription. Pass the last one you processed as ?cursor=.
chain, chain_idstring, integer
eth 1, base 8453, bsc 56, robinhood 4663, polygon 137.
blockobject
number, hash, timestamp (block time, UTC).
tx.hash, tx.index, tx.from, tx.nonce, tx.type—
The outer transaction.
tx.tostring | null
null for a contract creation.
tx.created_contractstring
Address of the created contract (creation transactions only).
tx.valuestring
Wei, decimal string.
tx.method_idstring | null
First 4 bytes of calldata; null without calldata.
tx.gas_used, tx.effective_gas_price, tx.feestring
Wei, decimal strings. fee = gas_used × effective_gas_price (+ l1_fee). Also reported for reverted transactions.
tx.l1_feestring
L1 data fee on Base (OP stack); absent elsewhere.
statusstring
Execution result from the receipt: success, reverted, or unknown when the receipt could not be read (then coverage.logs is unavailable).
errorobject | null
null unless reverted. See Status, confirmation and error.
confirmationobject
depth, reorg_window, state. See Status, confirmation and error.
addressstring
The watched address this event is about (lowercase).
matched_bystring[]
Why the address matched, in the fixed order of the table below, without duplicates.
movementsobject[]
Value that moved to or from address in this transaction. Can be empty (an approve, a failed call).
bridgeobject
Present when the transaction is a known bridge deposit or fill. See Bridges.
coverageobject
What was checked for this event: logs (full | unavailable) and internal_native (traced | matched_tx_only | matched_tx_skipped | unavailable).
logsarray
Reserved for an opt-in include=logs; not emitted yet.

Errors

CodeMeaningWhen it happens
400watchlist_requiredwallet_activity with an empty watchlist (create, PATCH, or a watchlist remove that would empty it).
400filter_not_supportedOnly wallet_activity selected and a non-empty filter.
400invalid_min_confirmationsOutside 0 to the smallest reorg window of the subscription's networks.
400too_many_chains / too_many_watchlistOver the tier limit.
400invalid_body / too_many_addresses / conflicting_address / invalid_watchlistWatchlist call: empty or malformed body, more than 10 000 addresses, the same address in add and remove, or a bad address.
400invalid_cursor / invalid_limit / invalid_after / invalid_after_seqMalformed stream or page parameter; a cursor longer than 4096 characters is invalid_cursor. The WebSocket answers these before the upgrade.
400cursor_subscription_mismatchThe cursor was issued for another subscription.
401account_requiredThe key is not backed by an account.
403tier_insufficient / subscription_limitTier below Business, or the subscription cap is reached.
404not_foundNo such subscription for this account.
410cursor_expiredThe REST cursor is older than the retained window; body has oldest_cursor and recoverable: false.
503capacity_exceededThe total watched-address cap would be exceeded (checked across both PoPs under a shared lock). Retry later or contact support.
503busyAnother watched-address change is being applied; retry.
503events_unavailableThe event store is not enabled on this PoP (…/events).
503option_unavailablemin_confirmations > 0 on a deployment that does not support it yet (the option is refused, never silently dropped).
503coverage_unavailable/coverage is not available on this PoP.
405method_not_allowedUnsupported 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)
done

Consume 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.