LUPORiverAPI reference
River API reference
River is LUPO's identity answer for personal email signups. Send River the address someone signed up with (Gmail, Outlook, iCloud and the like) and River tells you who is behind it, but only when a public record ties that exact address to a named person, business or organisation. Each answer carries the written name, the holder type, one plain sentence on how River knows, and links to the public record it rests on. When River cannot prove a name it says so and keeps looking for a while; it never guesses. Every lookup gets an id at once, and the answer arrives later on your webhook and through the API. Results are produced by River-1.
Base URL: https://river.lupolabs.ai. All paths start with /v1. JSON in, JSON out.
Quick start
export RIVER_BASE=https://river.lupolabs.ai
export RIVER_KEY=rk_live_... # your API key, from LUPO202 and a lookup id straight away.curl -s "$RIVER_BASE/v1/lookups" \
-H "Authorization: Bearer $RIVER_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: signup-8841" \
-d '{"email":"someone@example.com","external_id":"user_8841","country":"US"}'curl -s "$RIVER_BASE/v1/lookups/lkp_3f9c0a1b2c3d4e5f60718293" \
-H "Authorization: Bearer $RIVER_KEY"whsec_...) once. Store it.curl -s -X PUT "$RIVER_BASE/v1/webhook" \
-H "Authorization: Bearer $RIVER_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://hooks.yourcompany.com/river"}'
# send a signed ping now
curl -s -X POST "$RIVER_BASE/v1/webhook/test" -H "Authorization: Bearer $RIVER_KEY"standardwebhooks library. Verify the raw body, before parsing it.import express from "express";
import { Webhook } from "standardwebhooks";
const wh = new Webhook(process.env.RIVER_WEBHOOK_SECRET); // "whsec_..."
const app = express();
app.post("/river", express.raw({ type: "application/json" }), (req, res) => {
let event;
try {
event = wh.verify(req.body.toString("utf8"), req.headers);
} catch {
return res.status(400).end();
}
// Deduplicate on req.headers["webhook-id"]. Keep the highest event.sequence per event.data.id.
res.status(204).end();
});import os
from flask import Flask, request
from standardwebhooks.webhooks import Webhook
wh = Webhook(os.environ["RIVER_WEBHOOK_SECRET"]) # "whsec_..."
app = Flask(__name__)
@app.post("/river")
def river():
headers = {k.lower(): v for k, v in request.headers.items()}
try:
event = wh.verify(request.get_data(), headers)
except Exception:
return "", 400
# Deduplicate on headers["webhook-id"]. Keep the highest event["sequence"] per event["data"]["id"].
return "", 204Authentication
Send your key on every request: Authorization: Bearer rk_live_.... A missing, unknown or revoked key always gets the same 401. Keep keys on your server; never put one in a browser or an app. GET /v1/health is the only path that needs no key.
Endpoints
| Method and path | What it does |
|---|---|
POST /v1/lookups | Submit one signup, or a batch of up to 100. Answers 202. |
GET /v1/lookups/{id} | The current state of one lookup. |
GET /v1/lookups | Your lookups, newest first. Query: status, limit (1 to 100, default 20), starting_after (the last id you saw, for the next page). |
PUT /v1/webhook | Register or replace your webhook endpoint: {"url", "events"?, "rotate_secret"?}. 201 the first time, 200 after. |
GET /v1/webhook | Your endpoint (never the secret), or 404 when none is registered. |
DELETE /v1/webhook | Remove your endpoint. Always 200. |
POST /v1/webhook/test | Send a signed ping now and return the result. |
GET /v1/usage | {"object": "usage", "limit", "used", "remaining"} for your key. |
GET /v1/health | {"status":"ok"}. |
Paths are exact and have no trailing slash. Any other path gets the JSON 404 not_found.
Responses
GET /v1/lookups returns a page. has_more is false on the last page.
{ "object": "list", "data": [ { "id": "lkp_...", "object": "lookup", "...": "lookup objects, newest first" } ], "has_more": true }PUT /v1/webhook returns the endpoint. The first registration answers 201 and is the only answer that carries secret, unless you send "rotate_secret": true (then 200 with the new secret). Every other PUT answers 200 without it. GET /v1/webhook returns the same object without secret.
{
"object": "webhook_endpoint",
"id": "8d0c6f1e-3b7a-4f5e-9a51-2c1d0e9f7a64",
"url": "https://hooks.yourcompany.com/river",
"events": ["lookup.closed", "lookup.identified", "lookup.progress", "lookup.signal", "lookup.withdrawn"],
"enabled": true,
"created_at": "2026-10-05T09:00:00Z",
"secret": "whsec_..."
}DELETE /v1/webhook answers 200 with {"object": "webhook_endpoint", "deleted": true}, or "deleted": false when nothing was registered.
POST /v1/webhook/test answers 200 whether or not your endpoint accepted the ping:
{ "object": "webhook_test", "event_id": "evt_...", "delivered": true, "status_code": 204, "error": null }When delivered is false, status_code is what your endpoint answered (or null if it did not answer) and error is one of unsafe_target, dns_failed, timeout, connection_failed, redirect_not_followed, gone, http_error. One test per 10 seconds; more get 429 rate_limited.
GET /v1/usage:
{ "object": "usage", "limit": 100, "used": 7, "remaining": 93 }limit and remaining are null when your key has no limit.
Submitting
One signup:
{ "email": "someone@example.com", "external_id": "user_8841", "metadata": { "plan": "trial" }, "country": "US" }The answer is the lookup object (below) with status 202.
A batch: {"lookups": [ {...}, {...} ]}, up to 100 items. The answer is 202 with one result per item, in order. Each result holds either a lookup or an error, and quota shows what is left on your key:
{
"object": "batch",
"results": [
{ "index": 0, "lookup": { "id": "lkp_...", "status": "queued" } },
{ "index": 1, "error": { "code": "lookup_limit_reached", "message": "This API key has reached its lookup limit. Ask LUPO to raise it." } }
],
"quota": { "limit": 100, "used": 100, "remaining": 0 }
}quota.limit and quota.remaining are null when your key has no limit.
If any item in a batch is malformed, the whole batch is refused with 400 and the error names the item, for example lookups[3].phone.
Sending an address that already has an open lookup returns that lookup with "duplicate": true. It does not count against your limit. The lookup keeps the external_id and metadata of the request that created it, not the ones you just sent, so keep your own map from lookup id to your users.
The lookup object
{
"id": "lkp_3f9c0a1b2c3d4e5f60718293",
"object": "lookup",
"email": "someone@example.com",
"external_id": "user_8841",
"metadata": { "plan": "trial" },
"status": "answered",
"stage": "watching",
"final": false,
"answer": {
"version": 1,
"tier": "verified",
"who": { "name": "Jane Example", "type": "person" },
"company": null,
"how_we_know": "This address and the name Jane Example appear together in one public record.",
"proof": [ { "url": "https://...", "retrieved_at": "2026-10-05T09:12:44Z", "sha256": "9b1f..." } ],
"answered_at": "2026-10-05T09:13:02Z"
},
"reason": null,
"next_check_at": "2026-10-06T09:00:00Z",
"created_at": "2026-10-05T09:00:00Z",
"updated_at": "2026-10-05T09:13:02Z",
"erased_at": null,
"model": "River-1"
}| Field | Meaning |
|---|---|
email | The address as River keeps it: trimmed and lower-cased. null once the lookup is erased. |
status | queued (received), searching (River is working on it), answered (an answer is available; read answer.tier, which can be withdrawn), closed (finished without an answer). |
stage | first_pass (the first look), deep_research (a longer look), watching (River checks again later), or null once final. |
final | true when River will not change this lookup again. |
answer | The current answer, or null. An answered lookup can later get a higher version, for example when a company is added. |
reason | Plain words, only when status is closed. A closed lookup means "not found yet", never "this person does not exist". |
next_check_at | When River next looks at it, while not final. |
external_id, metadata | Yours, as sent with the request that created the lookup. metadata is null once the lookup is erased. |
erased_at | When River erased this lookup's personal data (see data handling), or null. |
model | Always River-1. |
The answer
| Field | Meaning |
|---|---|
tier | How the answer stands: see answer tiers. |
who | {"name", "type"}. The name exactly as the record writes it. type is person, business, sole_trader or organisation, or null when the record names a business but does not settle what kind of holder it is. who is null for a signal and for a withdrawn answer. |
company | {"name", "domain"?, "relationship"?, "basis"} or null. basis is record (stated in a public record) or probable (not proven; see whats_missing). null for a withdrawn answer. |
how_we_know | One plain sentence. |
proof | The public record or records the answer rests on: the link, when it was retrieved, and a SHA-256 fingerprint of what was retrieved. Empty for a withdrawn answer. |
whats_missing | Only on probable content: what is not proven. |
about | Optional plain facts. |
answered_at | When this version was released. |
Answer tiers
- verifieda public record ties this exact address to the named holder. This is the only tier that names someone as identified.
- probablea likely link that is not proven, with the gap spelled out in
whats_missing. Off unless your agreement switches it on. Never treat it as identified. - signala fact tied to the address, with no name (
whoisnull). Useful as a hint, nothing more. - withdrawnan earlier answer no longer holds (
whoisnull,proofis empty,statusstaysanswered). Stop using it; the lookup stays open for review. - No answerthe lookup is
closedwith areason. Nothing tied the address to a named holder in the time River looked.
Webhooks
PUT /v1/webhook with {"url": "https://..."}. Optional "events": [...] limits what you receive (default: all five lookup events below). A subscription to lookup.identified also receives lookup.withdrawn, so a withdrawal always reaches whoever holds the identified answer. The first registration returns "secret": "whsec_..." once; replacing the URL keeps the secret; "rotate_secret": true issues a new one (shown once). Your URL must be https, use a public host name, and use port 443 or 8443. River does not follow redirects.
Events
| Type | When |
|---|---|
lookup.identified | A verified (or, if switched on, probable) answer is released, including a new version. |
lookup.signal | A signal answer is released. |
lookup.progress | The first look finished with nothing to release; River keeps looking. |
lookup.closed | Final, with no answer. |
lookup.withdrawn | An earlier answer no longer holds. |
ping | Sent by POST /v1/webhook/test. |
Body:
{
"id": "evt_8e1d2c3b4a5f60718293a4b5",
"type": "lookup.identified",
"created_at": "2026-10-05T09:13:02Z",
"timestamp": "2026-10-05T09:13:02Z",
"sequence": 2,
"data": { "id": "lkp_3f9c0a1b2c3d4e5f60718293", "object": "lookup", "...": "the lookup object as it stood" }
}Headers: webhook-id (the event id), webhook-timestamp (Unix seconds), webhook-signature (v1,<base64>), the Standard Webhooks scheme. Verify with the standardwebhooks library as in the quick start, and reject timestamps more than five minutes old.
Delivery and retries
- A delivery succeeds when your endpoint answers any
2xxwithin 15 seconds. Answer first and do the work afterwards. - On any other answer, a timeout or a redirect, River tries again after about 30 seconds, 2 minutes, 10 minutes, 1 hour, 6 hours and 24 hours, then stops (seven attempts in all). A failed
pingis not retried. 410 Goneturns your endpoint off. Register it again withPUT /v1/webhook.- Delivery is at least once: the same event can arrive more than once. Deduplicate on
webhook-id. - Events may arrive out of order.
sequencecounts up per lookup: if you already handled a highersequencefor thatdata.id, ignore the older one.GET /v1/lookups/{id}always gives the current state. - Events go to the endpoint registered when they are sent. Events from before a registration are not replayed.
Idempotency
Send Idempotency-Key: <your id> (1 to 255 printable ASCII characters, no spaces) on POST /v1/lookups. Repeating the same request with the same key returns the same lookups, adds the header Idempotent-Replayed: true, and uses no quota. Reusing a key with a different body is refused with 409 idempotency_conflict. Retry network errors and 5xx answers with the same key.
Limits
| What | Limit |
|---|---|
| Items per batch | 100 |
| Request body | 512 KiB |
metadata | A JSON object of at most 1 KiB |
external_id | 1 to 128 characters |
| Lookups per key | Set in your agreement; see GET /v1/usage. When reached, new items get 429 lookup_limit_reached (in a batch, per item). Duplicates and replays do not count. |
| List page | 1 to 100 lookups |
Errors
Every error has one shape, and every response carries an X-Request-Id header. Quote it when you contact us.
{ "error": { "code": "forbidden_field", "message": "Phone numbers are not accepted. Send phone_country_code (digits only, for example 44) instead.", "field": "phone" } }| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_request, invalid_json, unknown_field, forbidden_field, invalid_field, invalid_email, batch_too_large | The request is not valid; field names the problem. |
| 401 | unauthorized | Missing, unknown or revoked key. |
| 403 | customer_suspended | Your account is paused. |
| 404 | not_found | No such lookup for your key, no webhook registered, or no such path (paths have no trailing slash). |
| 405 | method_not_allowed | Wrong method for the path. |
| 409 | idempotency_conflict | The Idempotency-Key was used with a different body. |
| 413 | payload_too_large | Body over 512 KiB. |
| 415 | unsupported_media_type | Send Content-Type: application/json. |
| 429 | lookup_limit_reached | Your key's lookup limit is used up. |
| 429 | rate_limited | More than one webhook test in 10 seconds. Wait for Retry-After. |
| 500 | internal_error | Our fault. Retry with the same Idempotency-Key. |
| 503 | unavailable | Briefly unavailable. Retry after a few seconds. |
Optional signup fields
Anything you know from the signup can help River check. All optional:
| Field | Format |
|---|---|
name | As typed at signup |
company | As typed at signup |
website | A domain or URL |
country | Two letter ISO code, for example US |
github_login | A code hosting username the person gave at signup |
signed_up_at | ISO 8601 time with a time zone |
phone_country_code | 1 to 3 digits, for example 44 |
ip | The signup IP address |
These are hints to check, never proof. River never returns a name because you sent it; an answer stands only on a public record.
Phone numbers are not accepted (phone, phone_number, mobile and the like are refused): send only phone_country_code. The ip is reduced to its network (the first three parts of an IPv4 address, the first 48 bits of IPv6) and hashed the moment it arrives; the address itself is never stored. Unknown fields are refused by name, so a typo never passes silently.
Inside metadata, a key is refused (400 forbidden_field, naming the key) when it names a phone number, a password or message content. A key is read as words, so userPhone, user_phone and user-phone are the same:
- a key with any of the words
phone,phones,cellphone,telephone,msisdn,e164,password,passwords,passwd,pwd,passcodeorpassphrase, for exampleuser_phoneoruserPassword; - a key made only of the words
mobile,telorcell, withnumber,noornum, for examplemobileortel_no; - a key made only of the words
message,messages,body,text,content,templateorsms, withemailormail, for examplemessage,sms_textoremail_body; - the keys
phonenumber,mobilenumber,telnumber,cellnumberandcontactnumber, with or without separators (contact_number), and any key whose letters start withpasswordorpasswd.
Other keys are accepted, for example utm_content, message_id, content_id, body_type and sms_opt_in.
Results page
LUPO can give you a private, read only results link. It opens in any browser, with no login and without your API key, and shows your lookups as they are answered: status, name, holder type, company and a link to the public record, with a CSV download. Each link expires (after 14 days unless agreed otherwise) and LUPO can turn it off at any time. Anyone who has the link can see your results, so share it only inside your company.
Data handling
Once a lookup has been final for 30 days, River erases its address (keeping only a one way fingerprint of it), your metadata, the optional signup fields and everything River gathered for it, and keeps the lookup id, your external_id, its status and dates and the answer you received, shown with email and metadata as null and erased_at set. 30 days is the default term; the term that applies to you is the one set in your agreement.