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

Environment
export RIVER_BASE=https://river.lupolabs.ai
export RIVER_KEY=rk_live_...            # your API key, from LUPO
01
Submit a signup. You get 202 and a lookup id straight away.
Request · POST /v1/lookups
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"}'
02
Poll it (or wait for the webhook).
Request · GET /v1/lookups/{id}
curl -s "$RIVER_BASE/v1/lookups/lkp_3f9c0a1b2c3d4e5f60718293" \
  -H "Authorization: Bearer $RIVER_KEY"
03
Register your webhook. The response carries your signing secret (whsec_...) once. Store it.
Request · PUT /v1/webhook
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"
04
Verify every webhook with the open standardwebhooks library. Verify the raw body, before parsing it.
Node: npm install standardwebhooks
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();
});
Python: pip install standardwebhooks
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 "", 204

Authentication

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 pathWhat it does
POST /v1/lookupsSubmit one signup, or a batch of up to 100. Answers 202.
GET /v1/lookups/{id}The current state of one lookup.
GET /v1/lookupsYour lookups, newest first. Query: status, limit (1 to 100, default 20), starting_after (the last id you saw, for the next page).
PUT /v1/webhookRegister or replace your webhook endpoint: {"url", "events"?, "rotate_secret"?}. 201 the first time, 200 after.
GET /v1/webhookYour endpoint (never the secret), or 404 when none is registered.
DELETE /v1/webhookRemove your endpoint. Always 200.
POST /v1/webhook/testSend 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.

Response · GET /v1/lookups
{ "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.

Response · PUT /v1/webhook
{
  "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:

Response · POST /v1/webhook/test
{ "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:

Response · 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:

Request body · POST /v1/lookups
{ "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:

Response · batch
{
  "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

Lookup
{
  "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"
}
FieldMeaning
emailThe address as River keeps it: trimmed and lower-cased. null once the lookup is erased.
statusqueued (received), searching (River is working on it), answered (an answer is available; read answer.tier, which can be withdrawn), closed (finished without an answer).
stagefirst_pass (the first look), deep_research (a longer look), watching (River checks again later), or null once final.
finaltrue when River will not change this lookup again.
answerThe current answer, or null. An answered lookup can later get a higher version, for example when a company is added.
reasonPlain words, only when status is closed. A closed lookup means "not found yet", never "this person does not exist".
next_check_atWhen River next looks at it, while not final.
external_id, metadataYours, as sent with the request that created the lookup. metadata is null once the lookup is erased.
erased_atWhen River erased this lookup's personal data (see data handling), or null.
modelAlways River-1.

The answer

FieldMeaning
tierHow 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_knowOne plain sentence.
proofThe 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_missingOnly on probable content: what is not proven.
aboutOptional plain facts.
answered_atWhen 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 (who is null). Useful as a hint, nothing more.
  • withdrawnan earlier answer no longer holds (who is null, proof is empty, status stays answered). Stop using it; the lookup stays open for review.
  • No answerthe lookup is closed with a reason. 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

TypeWhen
lookup.identifiedA verified (or, if switched on, probable) answer is released, including a new version.
lookup.signalA signal answer is released.
lookup.progressThe first look finished with nothing to release; River keeps looking.
lookup.closedFinal, with no answer.
lookup.withdrawnAn earlier answer no longer holds.
pingSent by POST /v1/webhook/test.

Body:

Webhook 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 2xx within 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 ping is not retried.
  • 410 Gone turns your endpoint off. Register it again with PUT /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. sequence counts up per lookup: if you already handled a higher sequence for that data.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

WhatLimit
Items per batch100
Request body512 KiB
metadataA JSON object of at most 1 KiB
external_id1 to 128 characters
Lookups per keySet 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 page1 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
{ "error": { "code": "forbidden_field", "message": "Phone numbers are not accepted. Send phone_country_code (digits only, for example 44) instead.", "field": "phone" } }
StatusCodeMeaning
400invalid_request, invalid_json, unknown_field, forbidden_field, invalid_field, invalid_email, batch_too_largeThe request is not valid; field names the problem.
401unauthorizedMissing, unknown or revoked key.
403customer_suspendedYour account is paused.
404not_foundNo such lookup for your key, no webhook registered, or no such path (paths have no trailing slash).
405method_not_allowedWrong method for the path.
409idempotency_conflictThe Idempotency-Key was used with a different body.
413payload_too_largeBody over 512 KiB.
415unsupported_media_typeSend Content-Type: application/json.
429lookup_limit_reachedYour key's lookup limit is used up.
429rate_limitedMore than one webhook test in 10 seconds. Wait for Retry-After.
500internal_errorOur fault. Retry with the same Idempotency-Key.
503unavailableBriefly unavailable. Retry after a few seconds.

Optional signup fields

Anything you know from the signup can help River check. All optional:

FieldFormat
nameAs typed at signup
companyAs typed at signup
websiteA domain or URL
countryTwo letter ISO code, for example US
github_loginA code hosting username the person gave at signup
signed_up_atISO 8601 time with a time zone
phone_country_code1 to 3 digits, for example 44
ipThe 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, passcode or passphrase, for example user_phone or userPassword;
  • a key made only of the words mobile, tel or cell, with number, no or num, for example mobile or tel_no;
  • a key made only of the words message, messages, body, text, content, template or sms, with email or mail, for example message, sms_text or email_body;
  • the keys phonenumber, mobilenumber, telnumber, cellnumber and contactnumber, with or without separators (contact_number), and any key whose letters start with password or passwd.

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.