Find the Whale Who Ghosted You: Building a Win-Back List From Idle On-Chain Balances (Arkham API)

Your churn dashboard tells you a user stopped showing up. It has no idea if they moved to a competitor or if their money is still sitting in your product, untouched, waiting for a reason to trade again. Those are two completely different situations that look identical in most analytics tools and Arkham tells them apart. Start the same way as any user pull, list who moved value with your product, but stretch the window out further than you normally would. Thirty days finds active users. A hundred and eighty days finds the ones who went quiet.
import requests
resp = requests.get(
"https://api.arkm.com/counterparties/entity/hyperliquid",
params={"timeLast": "180d", "limit": 200},
headers={"API-Key": "YOUR_API_KEY"},
)
long_window = resp.json()
Now for each wallet in that list, check what they currently hold.
resp = requests.get(
f"https://api.arkm.com/balances/address/{address}",
headers={"API-Key": "YOUR_API_KEY"},
)
balance = resp.json()
total_held = sum(balance["totalBalance"].values())
Filter for anything over a balance threshold that matters to your business, fifty thousand dollars, a hundred thousand, whatever counts as a real account for you. That is your candidate list. Now the important part, find out when each one actually last touched your product. Do not set a time window on this call, since a wallet that has gone quiet moved before whatever window you would set anyway.
resp = requests.get(
"https://api.arkm.com/transfers/unenriched",
params={"base": "hyperliquid", "counterparties": address, "limit": 1},
headers={"API-Key": "YOUR_API_KEY"},
)
last_transfer = resp.json()["transfers"][0]
last_seen = last_transfer["blockTimestamp"]
Check the direction of that last transfer too, not just the date. A wallet whose last move was a deposit is different from one whose last move was a withdrawal. One real example from this exact pattern showed a fund that moved $159M through Hyperliquid over 180 days, with a last transfer that was a $6M withdrawal three months prior, and $74M still sitting in the account untouched since. That is not a lost customer. That is a parked account with real money still inside your product.
from datetime import datetime, timezone
days_idle = (datetime.now(timezone.utc) - datetime.fromisoformat(last_seen.replace("Z", "+00:00"))).days
if days_idle > 60 and total_held > 50000 and last_transfer["fromAddress"]["address"].lower() == address.lower():
print(f"win back candidate: {address}, idle {days_idle} days, holding ${total_held:,.0f}")
That last condition matters. It checks whether the wallet was the sender on its last transfer, meaning it withdrew rather than deposited. A wallet whose most recent action was a deposit is not someone who left, they might just be between trades.
If Arkham returned a confirmed entity for the wallet, pull the whole firm's balance too, since the single wallet you are looking at might only be a slice of what the firm actually holds across every address it controls.
resp = requests.get(
f"https://api.arkm.com/balances/entity/{entity_id}",
headers={"API-Key": "YOUR_API_KEY"},
)
firm_balance = resp.json()
Sort the whole list by idle days multiplied by balance held. The wallets at the top are large accounts that have been quiet the longest, and that combination is exactly what makes a win back campaign worth running instead of just another generic re-engagement email that gets ignored.
The number of these you find will usually surprise you. Most products have at least a few accounts like this sitting in plain sight, they just never show up on a normal analytics dashboard because the balance is invisible to anything that only tracks logins or app opens.
Here is everything above stitched into one script you can actually run, top to bottom.
import requests
from datetime import datetime, timezone
API_KEY = "YOUR_API_KEY"
HEADERS = {"API-Key": API_KEY}
PRODUCT_ENTITY = "hyperliquid" # swap for your own product's entity id
BALANCE_MIN = 50000
IDLE_DAYS_MIN = 60
# Step 1: pull who moved value with your product over a long window
resp = requests.get(
f"https://api.arkm.com/counterparties/entity/{PRODUCT_ENTITY}",
params={"timeLast": "180d", "limit": 200},
headers=HEADERS,
)
resp.raise_for_status()
long_window = resp.json()
wallets = []
for chain, rows in long_window.items():
for row in rows:
addr_info = row["address"]
if not addr_info.get("contract") and not addr_info.get("depositServiceID"):
wallets.append(addr_info["address"])
print(f"Found {len(wallets)} candidate wallets")
# Step 2 and 3: value each wallet, then check when they last touched you
candidates = []
for address in wallets:
bal_resp = requests.get(
f"https://api.arkm.com/balances/address/{address}",
headers=HEADERS,
)
if bal_resp.status_code != 200:
continue
total_held = sum(bal_resp.json()["totalBalance"].values())
if total_held < BALANCE_MIN:
continue
transfer_resp = requests.get(
"https://api.arkm.com/transfers/unenriched",
params={"base": PRODUCT_ENTITY, "counterparties": address, "limit": 1},
headers=HEADERS,
)
if transfer_resp.status_code != 200:
continue
transfers = transfer_resp.json().get("transfers", [])
if not transfers:
continue
last_transfer = transfers[0]
last_seen = last_transfer["blockTimestamp"]
days_idle = (
datetime.now(timezone.utc)
- datetime.fromisoformat(last_seen.replace("Z", "+00:00"))
).days
was_withdrawal = last_transfer["fromAddress"]["address"].lower() == address.lower()
if days_idle >= IDLE_DAYS_MIN and was_withdrawal:
candidates.append({
"address": address,
"held": total_held,
"days_idle": days_idle,
"last_seen": last_seen,
})
# Step 4: print the win-back list, biggest and stalest first
candidates.sort(key=lambda c: c["held"] * c["days_idle"], reverse=True)
print(f"
{'Address':44} {'Held (USD)':>15} {'Idle Days':>10}")
print("-" * 75)
for c in candidates:
print(f"{c['address']:44} {c['held']:>15,.2f} {c['days_idle']:>10}")