Batch lookup
curl --request POST \
--url https://walletlink.social/api/v1/batch \
--header 'Content-Type: application/json' \
--data '
{
"wallets": [
"<string>"
]
}
'import requests
url = "https://walletlink.social/api/v1/batch"
payload = { "wallets": ["<string>"] }
headers = {"Content-Type": "application/json"}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({wallets: ['<string>']})
};
fetch('https://walletlink.social/api/v1/batch', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://walletlink.social/api/v1/batch",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'wallets' => [
'<string>'
]
]),
CURLOPT_HTTPHEADER => [
"Content-Type: application/json"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://walletlink.social/api/v1/batch"
payload := strings.NewReader("{\n \"wallets\": [\n \"<string>\"\n ]\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://walletlink.social/api/v1/batch")
.header("Content-Type", "application/json")
.body("{\n \"wallets\": [\n \"<string>\"\n ]\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://walletlink.social/api/v1/batch")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["Content-Type"] = 'application/json'
request.body = "{\n \"wallets\": [\n \"<string>\"\n ]\n}"
response = http.request(request)
puts response.read_body{
"data": [
{}
],
"meta": {
"requested": 123,
"found": 123,
"matched": 123,
"not_found": 123,
"previously_checked": {}
}
}Lookups
Batch lookup
POST /v1/batch
POST
/
api
/
v1
/
batch
Batch lookup
curl --request POST \
--url https://walletlink.social/api/v1/batch \
--header 'Content-Type: application/json' \
--data '
{
"wallets": [
"<string>"
]
}
'import requests
url = "https://walletlink.social/api/v1/batch"
payload = { "wallets": ["<string>"] }
headers = {"Content-Type": "application/json"}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({wallets: ['<string>']})
};
fetch('https://walletlink.social/api/v1/batch', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://walletlink.social/api/v1/batch",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'wallets' => [
'<string>'
]
]),
CURLOPT_HTTPHEADER => [
"Content-Type: application/json"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://walletlink.social/api/v1/batch"
payload := strings.NewReader("{\n \"wallets\": [\n \"<string>\"\n ]\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://walletlink.social/api/v1/batch")
.header("Content-Type", "application/json")
.body("{\n \"wallets\": [\n \"<string>\"\n ]\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://walletlink.social/api/v1/batch")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["Content-Type"] = 'application/json'
request.body = "{\n \"wallets\": [\n \"<string>\"\n ]\n}"
response = http.request(request)
puts response.read_body{
"data": [
{}
],
"meta": {
"requested": 123,
"found": 123,
"matched": 123,
"not_found": 123,
"previously_checked": {}
}
}Resolves many addresses in one request. Costs 1 match credit per address that resolves to an X handle or a Farcaster account, after duplicates are removed. Addresses that match nothing cost nothing.
This is the endpoint to build on. Fifty single lookups and one fifty-wallet batch cost the same match credits, but the batch is one request instead of fifty and returns in a fraction of the time.
Batch answers from the index, which is why it returns in milliseconds. For a list larger than a batch, or when the misses are worth re-checking against live sources, submit an async job instead: same billing rule, no per-request address ceiling, and live resolution on wallets the index has not checked. To know what a list would cost before sending it, run the free estimate first.
The reachability fields and
Headers
string
Optional retry protection. An opaque value up to 200 characters, such as a
UUID. Resending the identical request with the same key inside 24 hours
replays the stored response, marked
Idempotency-Replayed: true, and bills
nothing. See retries below.Body
string[]
required
Addresses to resolve, up to your plan’s
max_batch_size: 50 on the default
plan, 200 with a live Scale pack, 1,000 with a live Index pack (see
plans). The whole body is capped at 1 MB.Request
curl -X POST https://walletlink.social/api/v1/batch \
-H "Authorization: Bearer wts_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"wallets": [
"0x00000000000000000000000000000000000000a1",
"0x0000000000000000000000000000000000000001"
]
}'
Response
array
One entry per unique address, in submission order. Entries are the same
object as single lookup, or
null where nothing was
found. That includes twitter.also, a second X
account attested for the same
wallet, on the few records that have one. Each found entry also carries
last_updated and stale, the same freshness pair single lookup reports in
its meta, here per row because a batch has one meta for many rows.object
Show properties
Show properties
number
Unique addresses after deduplication.
number
How many resolved to at least one identity, including ENS, Lens and
GitHub.
number
How many resolved to an X handle or a Farcaster account. This is the
number billed, and it is never more than
found.number
Unique addresses with no identity at all:
requested minus found.object
Negative knowledge for the misses: wallet to ISO timestamp, for each
null entry we have checked before and found bare. A miss absent from
this map has never been examined. The whole field is absent when no miss
was previously checked; absent is not false. The single lookup makes the
same distinction with meta.checked_at.{
"data": [
{
"wallet": "0x00000000000000000000000000000000000000a1",
"ens_name": "example.eth",
"farcaster": {
"username": "example",
"url": "https://warpcast.com/example",
"followers": 123456,
"fid": 900001
},
"sources": ["onchain", "farcaster"],
"last_updated": "2026-08-14T02:11:09.000Z",
"stale": false
},
null
],
"meta": {
"requested": 2,
"found": 1,
"matched": 1,
"not_found": 1,
"previously_checked": {
"0x0000000000000000000000000000000000000001": "2026-08-20T05:40:00.000Z"
}
}
}
Positions map to the deduplicated list, not to the array you sent. Submit
[A, B, A] and you get two entries back, not three. Deduplicate before you
send, then index by the wallet field rather than by position.Differences from single lookup
Batch returns a deliberately lighter record. One thing present on/v1/wallet/{address} is absent here:
- No
qualityobject.
stale and last_updated are present as of 2026-09-01, per row rather
than in meta, and misses carry their checked-at time through
meta.previously_checked. Only the staleness response headers stay
single-lookup-only, since one header cannot describe fifty rows.
farcaster.verified is present as of 2026-08-30, on the same terms as
single lookup, and so is twitter.verified. It was previously omitted here
alone, which meant a multi-address caller received an account without the class
of evidence behind it. Every match carries its evidence, and that has to hold on
the route that resolves the most of them.
twitter.verified widened on 20 September 2026. An X handle that came
from a Farcaster account’s verified accounts now reports true. It already
did when our Farcaster sweep wrote the row, and reported false when a live
lookup did, so the same evidence gave two answers depending on which path
reached the address last. If you treat verified as a filter, expect more
rows to pass it; none of them are new evidence, only evidence that was already
there being reported consistently.twitter.also are present here on the same terms
as on single lookup.
If you are filtering on confidence, resolve in bulk with batch and then re-fetch
the shortlist through single lookup. It is usually a small number of addresses
and it is the only way to see the quality metadata.
Cost
Match credits are charged on the addresses that resolve to an X handle or a Farcaster account, after deduplication.meta.matched is exactly that count.
An address that carries only an ENS name, a Lens profile or a GitHub account is
returned and counted in found, but not billed. Submitting [A, B, A] where A
matches and B does not costs 1 credit and returns 2 records. Deduplicating
still helps, because duplicates count against your rate limit even though they
are billed once.
A batch that exceeds your batch limit is rejected whole with
BATCH_SIZE_EXCEEDED. Nothing is charged and nothing is partially processed, so
split oversized batches client-side and send the pieces.
Retries and the Idempotency-Key
A plain retry bills again, because it resolves again. Send anIdempotency-Key
header on any batch you might have to retry, and a resend of the identical
request replays the stored response instead:
- The dedup is scoped to your API key and pinned to the exact request body, and
lasts 24 hours. A replay returns the original body and status with an
Idempotency-Replayed: trueheader, bills no matches, and resolves nothing. - Rate limits still apply to a replay: the request weight is spent at the gate, before the store is consulted.
- Reusing a key with a different body is refused with
422 IDEMPOTENCY_KEY_REUSEDrather than silently answered with the older list. - A response too large to store (over 256 KB, far beyond a full 50-address
batch) is recorded without a body; replaying it answers
409 IDEMPOTENCY_NOT_REPLAYABLE, and the fix is to resend under a fresh key, knowingly billed as a new request. - Only a
200consumes a key. A request that failed can be retried under the same key into a success. - The window protects sequential retries, not concurrent duplicates: two copies of the same request racing each other can both execute and both bill. Serialize retries.
- The balance gate runs before the replay store is consulted, so a key at zero balance cannot replay even a stored response. If the batch that spent your last credits lost its response in transit, read your usage rather than retrying: the matches were billed and recorded.
Errors
INVALID_REQUEST, INVALID_ADDRESS, BATCH_SIZE_EXCEEDED,
IDEMPOTENCY_KEY_REUSED, IDEMPOTENCY_NOT_REPLAYABLE, plus the
standard errors.
A single malformed address rejects the entire batch. The error message names the
first few offenders.