Async jobs
curl --request POST \
--url https://walletlink.social/api/v1/jobs \
--header 'Content-Type: application/json' \
--data '
{
"wallets": [
"<string>"
]
}
'import requests
url = "https://walletlink.social/api/v1/jobs"
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/jobs', 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/jobs",
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/jobs"
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/jobs")
.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/jobs")
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": {
"job_id": "<string>",
"status": "<string>",
"wallets": 123,
"status_url": "<string>"
},
"data.wallets": [
"<string>"
],
"data.results": [
{}
],
"meta": {}
}Jobs
Async jobs
POST /v1/jobs and GET /v1/jobs/
POST
/
api
/
v1
/
jobs
Async jobs
curl --request POST \
--url https://walletlink.social/api/v1/jobs \
--header 'Content-Type: application/json' \
--data '
{
"wallets": [
"<string>"
]
}
'import requests
url = "https://walletlink.social/api/v1/jobs"
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/jobs', 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/jobs",
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/jobs"
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/jobs")
.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/jobs")
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": {
"job_id": "<string>",
"status": "<string>",
"wallets": 123,
"status_url": "<string>"
},
"data.wallets": [
"<string>"
],
"data.results": [
{}
],
"meta": {}
}Submits a list of addresses as a background job. Costs 1 match credit per address that resolves to an X handle or a Farcaster account, debited once, when the job completes. Nothing is billed at submission, misses cost nothing, and a job that fails is never billed.
A job does something no synchronous endpoint does: it resolves live.
The status poll is free on both meters: zero match credits and zero rate-limit units. That is deliberate, not generous: the job being polled may be the very thing that drained the key, and a drained key must be able to collect what it already paid for. Poll every few seconds; there is no penalty, and larger jobs report progress as they go.
While the job runs:
Two things about completed results are worth knowing:
/v1/batch answers from the index and returns in milliseconds; a job reads the index and the cache first, then resolves whatever is left against live sources, so it can find identities the synchronous endpoints report as never seen. It is the same pipeline a lookup in the app runs. The onchain ENS text-record pass, the app’s deep-scan extra, is not part of an API job: see scan depth.
The list has no fixed ceiling. Two rules bound it instead:
- The balance-derived cap. A submission may be at most 10 times your available match credits. A longer list is refused whole with
402 SUBMISSION_LIMIT_EXCEEDED, and the message names the maximum your current balance allows. The worst a job can cost is bounded by what you already hold. - One active job per account. A second submission while one is pending or processing answers
409 JOB_ALREADY_ACTIVE, naming the running job’s id so you can poll it instead of guessing. The rule counts jobs from every surface: a job started in the web app blocks an API submission too, because both drain the same pipeline and bill the same balance.
Body
string[]
required
Addresses to resolve. Duplicates are removed before the job is created and
before billing. No fixed maximum: the ceiling is 10 times your match balance.
The whole body is capped at 4.5 MB, roughly 100,000 addresses.
Request
curl -X POST https://walletlink.social/api/v1/jobs \
-H "Authorization: Bearer wts_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"wallets": [
"0x00000000000000000000000000000000000000a1",
"0x0000000000000000000000000000000000000001"
]
}'
Response
202 Accepted, because the work has not happened yet.
object
Show properties
Show properties
{
"data": {
"job_id": "3f9d2c1a-7b44-4c2e-9a10-5e8f6d4b2a90",
"status": "pending",
"wallets": 2,
"status_url": "https://walletlink.social/api/v1/jobs/3f9d2c1a-7b44-4c2e-9a10-5e8f6d4b2a90"
},
"meta": {
"submitted_at": "2026-09-01T12:00:00.000Z"
}
}
Polling
GET https://walletlink.social/api/v1/jobs/{id}
{
"data": {
"job_id": "3f9d2c1a-7b44-4c2e-9a10-5e8f6d4b2a90",
"status": "processing",
"progress": { "processed": 150, "total": 500, "stage": "live" },
"created_at": "2026-09-01T12:00:00.000Z",
"started_at": "2026-09-01T12:00:02.000Z",
"completed_at": null
},
"meta": { "requested": 500 }
}
status is pending, processing, completed or failed. progress.stage names where the pipeline is, by evidence class rather than by internal component: index, cache, live or onchain, and null between stages.
Only jobs your account submitted are visible. Any other id answers the same
404 JOB_NOT_FOUND as a job that does not exist. That is deliberate: a
distinct “not yours” answer would confirm which ids exist, and a job id is the
only handle an enumerator would need.Results
On completion the poll carries the results one page at a time, in the batch row shape. Page withoffset and limit
(default and maximum 1000 rows): meta.next_offset is the next page’s
offset, null on the last page, so the whole read is
while next_offset is not null. Paged because a job accepts lists far above
the batch cap, and a single response carrying them all would be too large to
ship.
string[]
This page of the deduplicated, lowercased submission, in processing order. It
rides in the same payload as the results because the submission may be
sessions old and you may no longer hold the list you sent.
array
Positionally aligned with
wallets: entry N describes wallet N, and is null
where nothing was attached, so a client branching on if (entry) cannot read
a checked negative as a match. Entries are the batch record without the
per-row freshness pair: identity fields, twitter with the wallet-keyed
reachability and verified, farcaster with fid and verified, and
sources mapped through the same evidence-class allowlist as every other
endpoint. sources is omitted where nothing classifiable backs the row.object
found and not_found count this page; requested and matched are
whole-job totals, with the batch arithmetic: matched is the number the job
billed at completion, never more than the sum of found across pages, because
ENS, Lens and GitHub identities are free. offset, limit and next_offset
place the page.- Reachability is read at poll time. The X reachability fields and
twitter.alsocome from the same wallet-keyed reads every other endpoint uses, at the moment you poll, not from a stamp stored with the job. A repeat poll can differ where the daily reachability check has moved a handle, and that is the poll telling you the truth. - Follower counts follow your entitlement. A job run on pack credits carries
farcaster.followers; one run on the free allowance reports it asnull, the same as a free lookup in the app.
Failure
A failed job reportsstatus: failed and a generic error stating the one fact you can act on: nothing was billed, submit the list again. The internal failure detail is not returned.
Retries
There is noIdempotency-Key on this endpoint. The two halves of a retry are covered differently:
- A resend while the job is active is refused by the one-active-job rule, with the active id in the message. Nothing runs twice, and a lost id comes back in that refusal.
- A resend after it completes is a new job: it runs the whole list again and bills its matches again. Once a job completes there is no way to rediscover its id, so persist the id from the 202 and poll before resubmitting.
Cost
Match credits are charged once, at completion, on the addresses that resolved 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, counted in found, and not billed. A failed job is charged nothing.
Because a job resolves live, an address the index had never seen can come back as a match and be billed, which is the point: you are paying for resolution, not for a read of what was already known.
When to use batch instead
/v1/batch is the right call when the answer should come back in the same request: it reads the index only, returns in milliseconds, and takes an Idempotency-Key for safe retries. A job is the right call when the list is larger than a batch, or when misses are worth re-checking against live sources. The billing rule is identical on both.
Errors
INVALID_REQUEST, INVALID_ADDRESS, SUBMISSION_LIMIT_EXCEEDED,
JOB_ALREADY_ACTIVE, JOB_NOT_FOUND, plus the
standard errors. The body cap answers 413 with
INVALID_REQUEST.
A single malformed address rejects the entire submission. The error message
names the first few offenders.