How to connect to HarvestWave

There are two separate ways to try HarvestWave. Do not mix them.

  • Connectivity and the API are tested with a DEMO account.
  • Coverage is tested with the free CSV upload of up to 10,000 numbers on harvestwave.com.

1. Connectivity and the API (DEMO account)

Source pages: portal signup, portal sign-in, API reference, and the DEMO test-number paragraph on harvestwave.com.

Create the DEMO account

Open https://portal.harvestwave.com/signup. The page title is “Create a HarvestWave account.” The page says: “Desktop B2B signup. No card required for DEMO. Confirm your email before signing in.”

Fields on that page, in order:

  • Company legal name
  • Street address
  • City
  • State / region
  • Postal code
  • Business phone
  • Work email
  • Password

The submit button is “Create DEMO account.” A link under it says “Already confirmed? Sign in.”

Create a HarvestWave account signup form

After “Create DEMO account,” the same URL stays on /signup. Heading: “Check your email.” Sentences on that screen: “We sent a confirmation link. The account stays in DEMO until you confirm, and you can add a card later from billing.” and “Look for a message from HarvestWave. Confirm the address before signing in.” Link: “Back to sign in.”

Check your email after Create DEMO account

The confirmation email comes from info@harvestwave.com. Subject: “Confirm your HarvestWave account.” Body lines: “Confirm your email by opening this link:” then a https://portal.harvestwave.com/confirm-email?token=… link, then “This link expires in 24 hours.”

Opening that link goes to /confirm-email. Heading: “Email confirmation.” Sentence: “Email confirmed. You can sign in.” Link: “sign in.”

Email confirmed. You can sign in.

Sign in

Open https://portal.harvestwave.com/login. The page heading is “Sign in.” The line under it is “Use the work email on your HarvestWave account.”

Fields: Email, Password. Button: “Sign in.” Link: “New customer? Create a DEMO account.”

Portal sign-in page

Dashboard

After sign-in the portal opens https://portal.harvestwave.com/dashboard. The page title is “HarvestWave Customer Portal.” The heading is “Account.” Under it is your account name. A LIVE badge sits at the top right. Sidebar: Dashboard (selected), Billing, API credentials, Users, Data permissions.

Three boxes, in order:

  • STATUS. For example, LIVE.
  • CREDITS REMAINING. Shows a number.
  • CALLS MADE. Shows a number.

Under those boxes, a LIVE account shows a box titled “Single-number lookup.” The sentence under the title: “One phone number on this LIVE account. A business hit uses the same credits as the lookup API. A miss does not.” The field label is “Phone number.” The button is “Look up.” The placeholder is +1 303 555 0100.

Four cards below that, in order:

  • Billing. “Add a card or buy prepaid credits. Card match or credit purchase is required to leave DEMO mode.” Link: “Manage billing.”
  • API credentials. “Copy the client_id. Rotate the machine secret when you need a new one.” Link: “Manage credentials.”
  • Users. “Invite people at your company.” Link: “Manage users.”
  • Data permissions. “Choose which slices your application JWT may receive.” Link: “Manage data permissions.”

Account dashboard, email covered

Credentials

Get credentials in the portal. Applications authenticate with an OAuth2 client-credentials bearer token on Authorization: Bearer. Humans create and rotate those credentials in the portal. Do not put the portal password on the lookup request.

Signed-in portal, production. Sidebar items on this screen: Dashboard, Billing, API credentials (selected), Users, Data permissions. Open API credentials.

Title: “API credentials.” The line under the title: “Machine login for POST /oauth/token on lookup-service. The secret is shown once.”

The page shows a CLIENT_ID label and its value, then the buttons “Copy client_id” and “Rotate client secret.” Copy client_id is how the client id is copied.

After “Rotate client secret,” the page shows a box titled “New client_secret,” the sentence “Copy this now. HarvestWave will not show it again.”, and a “Copy secret” button. That is how the client secret is copied.

The portal issues a CLIENT_ID and client_secret for OAuth. There is no separate API key to copy.

API credentials page, client id blurred

API credentials page after rotate, client id and secret blurred

Get an access token

Lookup service POST /oauth/token on https://api.harvestwave.com. The body is grant_type, client_id, and client_secret. grant_type must be client_credentials. The same call accepts Content-Type: application/x-www-form-urlencoded or Content-Type: application/json.

The client id and secret used in this call are the CLIENT_ID and the New client_secret from the API credentials page above.

JSON body:

POST /oauth/token HTTP/1.1
Host: api.harvestwave.com
Content-Type: application/json

{
  "grant_type": "client_credentials",
  "client_id": "YOUR_CLIENT_ID",
  "client_secret": "YOUR_CLIENT_SECRET"
}

Form body, same three fields:

POST /oauth/token HTTP/1.1
Host: api.harvestwave.com
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials&client_id=YOUR_CLIENT_ID&client_secret=YOUR_CLIENT_SECRET

The JSON response fields are access_token, token_type, and expires_in. token_type is Bearer. expires_in is seconds. The lookup service default is 28800 seconds (8 hours). The response has no refresh token field.

{
  "access_token": "YOUR_ACCESS_TOKEN",
  "token_type": "Bearer",
  "expires_in": 28800
}

Call the lookup

Base URL on the API reference: https://api.harvestwave.com

One endpoint: POST /v1/lookups. Send one E.164 number. The match is exact. Off-by-one is a different number. There is no fuzzy matching.

Request sample — send the access_token from POST /oauth/token as Authorization: Bearer:

POST /v1/lookups HTTP/1.1
Host: api.harvestwave.com
Content-Type: application/json
Authorization: Bearer YOUR_ACCESS_TOKEN

{
  "phoneNumber": "+15551234567"
}

Response sample on the API reference:

{
  "isBusiness": true,
  "businesses": [
    { "name": "Example Firm LLP", "confidence": 1.0 }
  ]
}

The same page says confidence is 1.0 for an exact directory listing. Address, additional numbers, and contacts are omitted unless the token includes those data permissions and the directory has them. Entitled extra slices that actually appear on the body add one credit each.

The API reference does not list status codes or SLA numbers. It says to see the portal for live account status of credentials, remaining credits, and any enforced limits.

DEMO test numbers (connectivity only)

The homepage says: create a DEMO account at the portal, then run lookups on DEMO test numbers (NPA-555 last-four 0001–0199). Primes return a deterministic fake business; composites miss. The same paragraph says real production lookups still use prepaid credits outside DEMO, and misses stay free. Use the DEMO test numbers to check that the API call works. Do not use a credit purchase or paid tokens as the connectivity test.

2. Coverage (free CSV, separate from the API)

Source: the hit-rate block on https://www.harvestwave.com/. This is not the DEMO API test.

Heading: “Check your hit rate — free, up to 10,000 numbers.”

The page says you upload a CSV of phone numbers and get a business hit rate plus about 10 sample hits (business name and phone). It is free and rate-limited. Exact E.164 match only.

Coverage line on that page, already rounded: “Nationwide U.S. coverage — ~5.2 million businesses · ~6.2 million phones.” Use those rounded figures only. Do not print exact directory counts.

Points on the page:

  • Free — no credit card for this demo; rate-limited
  • Email required — results go to the address you provide
  • Up to 10,000 rows — the page says they target processing in under 2 seconds for files up to 10k rows; if the CSV is larger, they process the first 10,000 only and do not reject the upload
  • The uploaded file is deleted immediately after processing
  • Results are the hit rate plus about 10 sample hits (business name and phone). The page says business names are not PII
  • Exact match — off-by-one is a different number; no fuzzy matching

Form on that page:

  • Work email (required — we’ll send your results here). Placeholder: you@company.com
  • CSV of phone numbers (one number per row, or a clear phone column). File input accepts .csv
  • Tip under the file field: “Tip: E.164 or common US formats; we normalize for exact match”
  • Button: “Check my hit rate”

CSV file format

The form label on the homepage is the format rule: one number per row, or a clear phone column. The file must be .csv. Numbers may be E.164 or common US formats; HarvestWave normalizes them for exact match.

Example with a header so the phone column is clear:

phone
+13035550100
(303) 555-0101
303-555-0102

Example with one number per line and no header:

+13035550100
(303) 555-0101
3035550102

If the CSV has several columns, include a clear phone column (for example a header such as phone or phone_number). Prefer E.164 (+1…) when you can. Do not put multiple numbers in one cell.

Under the form, a line says “Prefer the API?” and links to “Start free demo.” That link starts the DEMO account path in section 1. It does not turn the CSV upload into the API test.

The fine print under the form says this is a free, rate-limited demo; files over 10,000 rows have only the first 10,000 processed; the uploaded file is deleted immediately after processing; results include hit rate and about 10 sample business hits. It describes the directory as a nationwide U.S. business-number directory (same rounded coverage line). It says this is not CRM enrichment, not a people lookup, and not a credit bureau. It also states the published production lookup price: $0.01 per business hit; misses / non-business = $0. Submitting agrees to one results email. That price is not a step in this coverage test, and it is not a reason to buy credits before uploading the CSV.

Homepage hit-rate form

Appendix A: TypeScript sample (Node)

Customer sample. Placeholders only. Download harvestwaveClient.ts.

This is a Node TypeScript client sample. It is not the portal UI.

Calls

Both calls use one shared undici Pool aimed at https://api.harvestwave.com, with connections: 8 and a 60 second keep-alive. Create one HarvestWaveClient and reuse it. The pool is opened once for the process.

  1. POST /oauth/token with Content-Type: application/x-www-form-urlencoded and these body fields: grant_type=client_credentials, client_id, client_secret. The sample reads YOUR_CLIENT_ID and YOUR_CLIENT_SECRET from the environment.
  2. POST /v1/lookups with Content-Type: application/json, body { "phoneNumber": "..." }, and Authorization set from the token response (token_type and access_token).

The token response is access_token, token_type, and expires_in. Published lifetime is eight hours (expires_in 28800). There is no refresh token. When fewer than 60 seconds remain, the sample requests a new client-credentials token. A lookup that comes back 401 does the same once.

These match the token and lookup calls already described above.

Run

Node 18 or newer. The Pool export comes from the undici package (npm install undici). Global fetch is not a substitute here, because the sample needs one named pool with a connection limit.

export YOUR_CLIENT_ID="paste-the-client-id"
export YOUR_CLIENT_SECRET="paste-the-client-secret"
import { harvestWaveClient } from "./harvestwaveClient";

const result = await harvestWaveClient.lookup("(303) 555-0100");
console.log(result.statusCode, result.body);
await harvestWaveClient.close();

Use the client id and client secret HarvestWave issued for the account. Keep them in the environment or a secret store. Do not commit them, and do not paste live values into this appendix.

(303) 555-0100 is the documented phoneNumber example. The service normalizes it to E.164 (US when the value has no country code). A successful body includes isBusiness, businesses, and responseId.

harvestwaveClient.ts

/**
 * HarvestWave customer sample (Node, TypeScript).
 *
 * One process-wide undici Pool talks to https://api.harvestwave.com.
 * Both calls reuse that pool:
 *   POST /oauth/token
 *   POST /v1/lookups
 *
 * The access token lasts eight hours (expires_in 28800). The token
 * response has no refresh token. When the held token is near expiry,
 * this client requests a new client-credentials token.
 *
 * Read YOUR_CLIENT_ID and YOUR_CLIENT_SECRET from the environment.
 * Do not hard-code them.
 */

import { Pool } from "undici";

const ORIGIN = "https://api.harvestwave.com";
const TOKEN_PATH = "/oauth/token";
const LOOKUP_PATH = "/v1/lookups";

/** Ask for a new client-credentials token when this little life remains. */
const RENEW_BEFORE_EXPIRY_MS = 60_000;

/**
 * Shared keep-alive pool. connections is the cap for this origin.
 * Idle sockets stay open so the token call and the lookup call reuse them.
 */
const sharedPool = new Pool(ORIGIN, {
  connections: 8,
  pipelining: 1,
  keepAliveTimeout: 60_000,
  keepAliveMaxTimeout: 600_000,
});

export interface LookupCallResult {
  statusCode: number;
  body: unknown;
}

interface HeldToken {
  accessToken: string;
  tokenType: string;
  expiresAtMs: number;
}

function envValue(name: string): string {
  const value = process.env[name];
  if (value === undefined || value.trim() === "") {
    throw new Error(`Set ${name} in the environment`);
  }
  return value.trim();
}

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

function readToken(value: unknown): { accessToken: string; tokenType: string; expiresIn: number } {
  if (!isRecord(value)) {
    throw new Error("Token response was not a JSON object");
  }
  const accessToken = value.access_token;
  const expiresIn = value.expires_in;
  const tokenType = value.token_type;
  if (typeof accessToken !== "string" || accessToken.length === 0) {
    throw new Error("Token response did not include access_token");
  }
  if (typeof expiresIn !== "number" || !Number.isFinite(expiresIn) || expiresIn <= 0) {
    throw new Error("Token response did not include expires_in");
  }
  if (tokenType !== undefined && typeof tokenType !== "string") {
    throw new Error("Token response token_type was not a string");
  }
  const type = typeof tokenType === "string" && tokenType.length > 0 ? tokenType : "Bearer";
  return { accessToken, tokenType: type, expiresIn };
}

async function readBody(response: { body: { text: () => Promise<string> } }): Promise<string> {
  return response.body.text();
}

export class HarvestWaveClient {
  private held: HeldToken | null = null;
  private inflight: Promise<HeldToken> | null = null;

  constructor(private readonly pool: Pool = sharedPool) {}

  /**
   * POST /oauth/token.
   * Body fields: grant_type=client_credentials, client_id, client_secret.
   */
  async requestToken(): Promise<HeldToken> {
    const form = new URLSearchParams({
      grant_type: "client_credentials",
      client_id: envValue("YOUR_CLIENT_ID"),
      client_secret: envValue("YOUR_CLIENT_SECRET"),
    });
    const response = await this.pool.request({
      path: TOKEN_PATH,
      method: "POST",
      headers: {
        accept: "application/json",
        "content-type": "application/x-www-form-urlencoded",
      },
      body: form.toString(),
    });
    const text = await readBody(response);
    if (response.statusCode !== 200) {
      throw new Error(`POST /oauth/token failed with HTTP ${response.statusCode}`);
    }
    let parsed: unknown;
    try {
      parsed = JSON.parse(text) as unknown;
    } catch {
      throw new Error("POST /oauth/token did not return JSON");
    }
    const token = readToken(parsed);
    const held: HeldToken = {
      accessToken: token.accessToken,
      tokenType: token.tokenType,
      // expires_in is seconds. The published value is 28800 (eight hours).
      expiresAtMs: Date.now() + token.expiresIn * 1000,
    };
    this.held = held;
    return held;
  }

  /** Current token, or a new client-credentials token when expiry is near. */
  async accessToken(): Promise<HeldToken> {
    if (this.held !== null && this.held.expiresAtMs - Date.now() > RENEW_BEFORE_EXPIRY_MS) {
      return this.held;
    }
    if (this.inflight === null) {
      this.inflight = this.requestToken().finally(() => {
        this.inflight = null;
      });
    }
    return this.inflight;
  }

  /** POST /v1/lookups with the access token. */
  async lookup(phoneNumber: string): Promise<LookupCallResult> {
    const phone = phoneNumber.trim();
    if (phone.length === 0) {
      throw new Error("phoneNumber is required");
    }
    const first = await this.postLookup(await this.accessToken(), phone);
    if (first.statusCode !== 401) {
      return first;
    }
    this.held = null;
    return this.postLookup(await this.requestToken(), phone);
  }

  /** Close the shared pool when the process is done with HarvestWave. */
  async close(): Promise<void> {
    await this.pool.close();
  }

  private async postLookup(token: HeldToken, phoneNumber: string): Promise<LookupCallResult> {
    const payload = JSON.stringify({ phoneNumber });
    const response = await this.pool.request({
      path: LOOKUP_PATH,
      method: "POST",
      headers: {
        accept: "application/json",
        authorization: `${token.tokenType} ${token.accessToken}`,
        "content-type": "application/json",
      },
      body: payload,
    });
    const text = await readBody(response);
    return { statusCode: response.statusCode, body: parseJson(text) };
  }
}

function parseJson(text: string): unknown {
  if (text.trim() === "") {
    return null;
  }
  try {
    return JSON.parse(text) as unknown;
  } catch {
    return text;
  }
}

/** One client for the process. Token and lookup calls share sharedPool. */
export const harvestWaveClient = new HarvestWaveClient();

Appendix B: Java sample

Customer sample. Secrets from the environment only. Download HarvestWaveClient.java.

This is a JDK client sample. It is not the portal UI.

Calls

One process-wide java.net.http.HttpClient (HTTP/1.1) with pool size 8 via jdk.httpclient.connectionPoolSize and a 30 second keep-alive (jdk.httpclient.keepalive.timeout). Create one HarvestWaveClient and reuse it. Every instance shares that client.

  1. POST /oauth/token as JSON (Content-Type: application/json) with body {"grant_type":"client_credentials","client_id":...,"client_secret":...}. The sample reads HARVESTWAVE_CLIENT_ID and HARVESTWAVE_CLIENT_SECRET from the environment. Unlike the TypeScript and Python samples, this Java sample sends the token request as JSON, not form-urlencoded.
  2. POST /v1/lookups with Content-Type: application/json, body { "phoneNumber": "..." }, and Authorization: Bearer with the access_token.

The token response is access_token, token_type (Bearer), and expires_in. Published lifetime is eight hours (expires_in 28800). There is no refresh token. When fewer than 60 seconds remain, the sample requests a new client-credentials token.

These match the token and lookup calls already described above.

Run

JDK with java.net.http.HttpClient (Java 11+). No extra libraries.

export HARVESTWAVE_CLIENT_ID="paste-the-client-id"
export HARVESTWAVE_CLIENT_SECRET="paste-the-client-secret"
java HarvestWaveClient "+15551234567"

Use the client id and client secret HarvestWave issued for the account. Keep them in the environment or a secret store. Do not commit them, and do not paste live values into this appendix.

Connectivity checks use DEMO test numbers (NPA-555, last four 0001–0199), not a credit purchase.

HarvestWaveClient.java

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
 * Example client for the HarvestWave business-lookup API.
 *
 * <p>One {@link HttpClient} is shared by the process. It uses the JDK
 * HTTP/1.1 keep-alive pool, bounded before the client starts, and an explicit
 * connect timeout. {@code POST /oauth/token} uses
 * {@code grant_type=client_credentials}. The access token is reused until
 * shortly before {@code expires_in}, then a new client-credentials token is
 * requested. Lookup is {@code POST /v1/lookups} with
 * {@code Authorization: Bearer}.
 *
 * <p>Credentials are read from {@code HARVESTWAVE_CLIENT_ID} and
 * {@code HARVESTWAVE_CLIENT_SECRET}.
 */
public final class HarvestWaveClient {

    private static final URI TOKEN_URI = URI.create("https://api.harvestwave.com/oauth/token");
    private static final URI LOOKUP_URI = URI.create("https://api.harvestwave.com/v1/lookups");

    private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10);
    private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(30);
    private static final int POOL_SIZE = 8;
    private static final int KEEP_ALIVE_SECONDS = 30;
    private static final long DEFAULT_EXPIRES_IN_SECONDS = 28_800L;
    private static final long RENEW_BEFORE_SECONDS = 60L;

    /**
     * Pool settings are read once, when the JDK client implementation loads.
     * A {@code connectionPoolSize} of 0 means unbounded.
     */
    private static final HttpClient HTTP = sharedClient();

    private static volatile HarvestWaveClient shared;

    private final String clientId;
    private final String clientSecret;
    private final Object tokenLock = new Object();
    private String accessToken;
    private Instant renewAt;

    private HarvestWaveClient(String clientId, String clientSecret) {
        this.clientId = clientId;
        this.clientSecret = clientSecret;
    }

    /**
     * Returns the process-wide client. Credentials come from the environment.
     */
    public static HarvestWaveClient fromEnvironment() {
        HarvestWaveClient existing = shared;
        if (existing != null) {
            return existing;
        }
        String clientId = requiredEnv("HARVESTWAVE_CLIENT_ID");
        String clientSecret = requiredEnv("HARVESTWAVE_CLIENT_SECRET");
        synchronized (HarvestWaveClient.class) {
            if (shared == null) {
                shared = new HarvestWaveClient(clientId, clientSecret);
            }
            return shared;
        }
    }

    /**
     * Looks up a phone number.
     *
     * @param phoneNumber value of the {@code phoneNumber} field
     * @return the lookup document
     */
    public LookupResult lookup(String phoneNumber) {
        if (phoneNumber == null || phoneNumber.isBlank()) {
            throw new IllegalArgumentException("phoneNumber is required");
        }
        // Connectivity checks use DEMO test numbers (NPA-555, last four 0001-0199), not a credit purchase.
        String body = lookupJson(phoneNumber);
        String json = post(LOOKUP_URI, body, bearer());
        return readLookup(json);
    }

    public static void main(String[] args) {
        if (args.length != 1) {
            throw new IllegalArgumentException("Usage: java HarvestWaveClient <phoneNumber>");
        }
        LookupResult result = fromEnvironment().lookup(args[0]);
        StringBuilder out = new StringBuilder();
        out.append("isBusiness=").append(result.isBusiness());
        for (Business business : result.businesses()) {
            out.append('\n')
                    .append(business.name())
                    .append(" confidence=")
                    .append(business.confidence());
        }
        System.out.println(out);
    }

    private String bearer() {
        Instant now = Instant.now();
        synchronized (tokenLock) {
            String reusable = reusableToken(now);
            if (reusable != null) {
                return reusable;
            }
            Issued issued = readToken(post(TOKEN_URI, tokenJson(clientId, clientSecret), null), now);
            accessToken = issued.accessToken();
            renewAt = issued.renewAt();
            return accessToken;
        }
    }

    /** Caller holds {@code tokenLock}. */
    private String reusableToken(Instant now) {
        if (accessToken == null || renewAt == null || !now.isBefore(renewAt)) {
            return null;
        }
        return accessToken;
    }

    private static HttpClient sharedClient() {
        setPoolPropertyIfAbsent("jdk.httpclient.connectionPoolSize", Integer.toString(POOL_SIZE));
        setPoolPropertyIfAbsent("jdk.httpclient.keepalive.timeout", Integer.toString(KEEP_ALIVE_SECONDS));
        return HttpClient.newBuilder()
                .version(HttpClient.Version.HTTP_1_1)
                .connectTimeout(CONNECT_TIMEOUT)
                .followRedirects(HttpClient.Redirect.NEVER)
                .build();
    }

    private static void setPoolPropertyIfAbsent(String key, String value) {
        if (System.getProperty(key) == null) {
            System.setProperty(key, value);
        }
    }

    private static String requiredEnv(String name) {
        String value = System.getenv(name);
        if (value == null || value.isBlank()) {
            throw new IllegalStateException(name + " is required");
        }
        return value;
    }

    private String post(URI uri, String body, String bearer) {
        HttpRequest.Builder request = HttpRequest.newBuilder(uri)
                .timeout(REQUEST_TIMEOUT)
                .header("Accept", "application/json")
                .header("Content-Type", "application/json")
                .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8));
        if (bearer != null) {
            request.header("Authorization", "Bearer " + bearer);
        }
        try {
            HttpResponse<String> response =
                    HTTP.send(request.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
            int status = response.statusCode();
            if (status < 200 || status > 299) {
                throw new Failure("POST " + uri.getPath() + " returned HTTP " + status);
            }
            return response.body();
        } catch (IOException ex) {
            throw new Failure("POST " + uri.getPath() + " failed", ex);
        } catch (InterruptedException ex) {
            Thread.currentThread().interrupt();
            throw new Failure("POST " + uri.getPath() + " was interrupted", ex);
        }
    }

    private static String tokenJson(String clientId, String clientSecret) {
        return "{\"grant_type\":\"client_credentials\",\"client_id\":"
                + quote(clientId)
                + ",\"client_secret\":"
                + quote(clientSecret)
                + "}";
    }

    private static String lookupJson(String phoneNumber) {
        return "{\"phoneNumber\":" + quote(phoneNumber) + "}";
    }

    private static String quote(String value) {
        StringBuilder out = new StringBuilder(value.length() + 2);
        out.append('"');
        for (int index = 0; index < value.length(); index++) {
            char current = value.charAt(index);
            switch (current) {
                case '"' -> out.append("\\\"");
                case '\\' -> out.append("\\\\");
                case '\b' -> out.append("\\b");
                case '\f' -> out.append("\\f");
                case '\n' -> out.append("\\n");
                case '\r' -> out.append("\\r");
                case '\t' -> out.append("\\t");
                default -> {
                    if (current < 0x20) {
                        out.append(String.format("\\u%04x", (int) current));
                    } else {
                        out.append(current);
                    }
                }
            }
        }
        out.append('"');
        return out.toString();
    }

    private static Issued readToken(String json, Instant now) {
        Map<String, Object> document = asObject(Json.parse(json), "token response");
        String token = asString(document.get("access_token"), "access_token");
        requireUsableToken(token);
        String tokenType = asString(document.get("token_type"), "token_type");
        if (!"Bearer".equals(tokenType)) {
            throw new Failure("token_type must be Bearer");
        }
        long expiresIn = document.containsKey("expires_in")
                ? asLong(document.get("expires_in"), "expires_in")
                : DEFAULT_EXPIRES_IN_SECONDS;
        if (expiresIn < 0) {
            throw new Failure("expires_in must not be negative");
        }
        long skew = renewSkewSeconds(expiresIn);
        Instant renewAt;
        try {
            renewAt = now.plusSeconds(expiresIn - skew);
        } catch (ArithmeticException | java.time.DateTimeException ex) {
            throw new Failure("expires_in is not usable", ex);
        }
        return new Issued(token, renewAt);
    }

    private static long renewSkewSeconds(long expiresInSeconds) {
        return Math.min(RENEW_BEFORE_SECONDS, Math.max(0L, expiresInSeconds - 1L));
    }

    private static void requireUsableToken(String token) {
        if (token.isEmpty()) {
            throw new Failure("access_token is empty");
        }
        for (int index = 0; index < token.length(); index++) {
            char current = token.charAt(index);
            if (current <= ' ' || current == 0x7f) {
                throw new Failure("access_token is not usable");
            }
        }
    }

    private static LookupResult readLookup(String json) {
        Map<String, Object> document = asObject(Json.parse(json), "lookup response");
        Object businessFlag = document.get("isBusiness");
        if (!(businessFlag instanceof Boolean isBusiness)) {
            throw new Failure("isBusiness must be a boolean");
        }
        Object rawBusinesses = document.get("businesses");
        if (!(rawBusinesses instanceof List<?> businesses)) {
            throw new Failure("businesses must be an array");
        }
        List<Business> parsed = new ArrayList<>();
        for (Object item : businesses) {
            Map<String, Object> business = asObject(item, "business");
            String name = asString(business.get("name"), "name");
            double confidence = asDouble(business.get("confidence"), "confidence");
            parsed.add(new Business(name, confidence));
        }
        return new LookupResult(isBusiness, parsed);
    }

    private static Map<String, Object> asObject(Object value, String field) {
        if (!(value instanceof Map<?, ?> map)) {
            throw new Failure(field + " must be a JSON object");
        }
        Map<String, Object> typed = new LinkedHashMap<>();
        for (Map.Entry<?, ?> entry : map.entrySet()) {
            if (!(entry.getKey() instanceof String key)) {
                throw new Failure(field + " has a non-text field");
            }
            typed.put(key, entry.getValue());
        }
        return typed;
    }

    private static String asString(Object value, String field) {
        if (!(value instanceof String text)) {
            throw new Failure(field + " must be a string");
        }
        return text;
    }

    private static long asLong(Object value, String field) {
        if (value instanceof Long number) {
            return number;
        }
        if (value instanceof Double number
                && !number.isNaN()
                && !number.isInfinite()
                && number == Math.rint(number)
                && number <= Long.MAX_VALUE
                && number >= Long.MIN_VALUE) {
            return number.longValue();
        }
        throw new Failure(field + " must be an integer");
    }

    private static double asDouble(Object value, String field) {
        if (!(value instanceof Number number)) {
            throw new Failure(field + " must be a number");
        }
        double parsed = number.doubleValue();
        if (Double.isNaN(parsed) || Double.isInfinite(parsed)) {
            throw new Failure(field + " must be a finite number");
        }
        return parsed;
    }

    /** Lookup document from {@code POST /v1/lookups}. */
    public record LookupResult(boolean isBusiness, List<Business> businesses) {
        public LookupResult {
            businesses = List.copyOf(businesses);
        }
    }

    /** One element of {@code businesses}. */
    public record Business(String name, double confidence) {
        public Business {
            if (name == null) {
                throw new IllegalArgumentException("name is required");
            }
            if (Double.isNaN(confidence) || Double.isInfinite(confidence)) {
                throw new IllegalArgumentException("confidence must be finite");
            }
        }
    }

    /** Call failed. The message does not include credentials. */
    public static final class Failure extends RuntimeException {
        private static final long serialVersionUID = 1L;

        Failure(String message) {
            super(message);
        }

        Failure(String message, Throwable cause) {
            super(message, cause);
        }
    }

    private record Issued(String accessToken, Instant renewAt) {}

    /** Small JSON reader for the token and lookup documents. Unknown fields are kept, then ignored. */
    private static final class Json {
        private final String text;
        private int index;

        private Json(String text) {
            this.text = text == null ? "" : text;
        }

        static Object parse(String text) {
            Json reader = new Json(text);
            Object value = reader.readValue();
            reader.skipWs();
            if (reader.index != reader.text.length()) {
                throw new Failure("Invalid JSON: trailing data");
            }
            return value;
        }

        private Object readValue() {
            skipWs();
            if (index >= text.length()) {
                throw fail("unexpected end");
            }
            char current = text.charAt(index);
            return switch (current) {
                case '{' -> readObject();
                case '[' -> readArray();
                case '"' -> readString();
                case 't' -> readLiteral("true", Boolean.TRUE);
                case 'f' -> readLiteral("false", Boolean.FALSE);
                case 'n' -> readLiteral("null", null);
                case '-', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' -> readNumber();
                default -> throw fail("unexpected character");
            };
        }

        private Map<String, Object> readObject() {
            expect('{');
            Map<String, Object> map = new LinkedHashMap<>();
            if (consume('}')) {
                return map;
            }
            while (true) {
                skipWs();
                String key = readString();
                expect(':');
                Object value = readValue();
                if (map.containsKey(key)) {
                    throw fail("duplicate field");
                }
                map.put(key, value);
                if (consume('}')) {
                    return map;
                }
                expect(',');
            }
        }

        private List<Object> readArray() {
            expect('[');
            List<Object> list = new ArrayList<>();
            if (consume(']')) {
                return list;
            }
            while (true) {
                list.add(readValue());
                if (consume(']')) {
                    return list;
                }
                expect(',');
            }
        }

        private String readString() {
            if (index >= text.length() || text.charAt(index) != '"') {
                throw fail("expected string");
            }
            index++;
            StringBuilder out = new StringBuilder();
            while (index < text.length()) {
                char current = text.charAt(index++);
                if (current == '"') {
                    return out.toString();
                }
                if (current == '\\') {
                    if (index >= text.length()) {
                        throw fail("truncated escape");
                    }
                    char escaped = text.charAt(index++);
                    switch (escaped) {
                        case '"', '\\', '/' -> out.append(escaped);
                        case 'b' -> out.append('\b');
                        case 'f' -> out.append('\f');
                        case 'n' -> out.append('\n');
                        case 'r' -> out.append('\r');
                        case 't' -> out.append('\t');
                        case 'u' -> out.append((char) readHex4());
                        default -> throw fail("bad escape");
                    }
                } else if (current < 0x20) {
                    throw fail("raw control character");
                } else {
                    out.append(current);
                }
            }
            throw fail("unterminated string");
        }

        private int readHex4() {
            if (index + 4 > text.length()) {
                throw fail("truncated unicode escape");
            }
            int codePoint = 0;
            for (int step = 0; step < 4; step++) {
                int digit = Character.digit(text.charAt(index++), 16);
                if (digit < 0) {
                    throw fail("bad unicode escape");
                }
                codePoint = (codePoint << 4) + digit;
            }
            return codePoint;
        }

        private Object readLiteral(String word, Object value) {
            if (!text.startsWith(word, index)) {
                throw fail("expected literal");
            }
            index += word.length();
            if (index < text.length() && Character.isLetterOrDigit(text.charAt(index))) {
                throw fail("bad literal");
            }
            return value;
        }

        private Number readNumber() {
            int start = index;
            if (index < text.length() && text.charAt(index) == '-') {
                index++;
            }
            if (index >= text.length() || !isDigit(text.charAt(index))) {
                throw fail("bad number");
            }
            if (text.charAt(index) == '0') {
                index++;
            } else {
                while (index < text.length() && isDigit(text.charAt(index))) {
                    index++;
                }
            }
            boolean real = false;
            if (index < text.length() && text.charAt(index) == '.') {
                real = true;
                index++;
                int fraction = index;
                while (index < text.length() && isDigit(text.charAt(index))) {
                    index++;
                }
                if (index == fraction) {
                    throw fail("bad number");
                }
            }
            if (index < text.length() && (text.charAt(index) == 'e' || text.charAt(index) == 'E')) {
                real = true;
                index++;
                if (index < text.length() && (text.charAt(index) == '+' || text.charAt(index) == '-')) {
                    index++;
                }
                int exponent = index;
                while (index < text.length() && isDigit(text.charAt(index))) {
                    index++;
                }
                if (index == exponent) {
                    throw fail("bad number");
                }
            }
            String literal = text.substring(start, index);
            try {
                if (!real) {
                    return Long.parseLong(literal);
                }
                return Double.valueOf(literal);
            } catch (NumberFormatException ex) {
                throw fail("bad number");
            }
        }

        private void expect(char wanted) {
            skipWs();
            if (index >= text.length() || text.charAt(index) != wanted) {
                throw fail("expected delimiter");
            }
            index++;
        }

        private boolean consume(char wanted) {
            skipWs();
            if (index < text.length() && text.charAt(index) == wanted) {
                index++;
                return true;
            }
            return false;
        }

        private void skipWs() {
            while (index < text.length()) {
                char current = text.charAt(index);
                if (current != ' ' && current != '\t' && current != '\n' && current != '\r') {
                    return;
                }
                index++;
            }
        }

        private static boolean isDigit(char current) {
            return current >= '0' && current <= '9';
        }

        private Failure fail(String reason) {
            return new Failure("Invalid JSON: " + reason);
        }
    }
}

Appendix C: Python sample

Customer sample. Secrets from the environment only. Download harvestwave_client.py.

This is a Python 3.12 client sample. It is not the portal UI.

Calls

One process-wide httpx.Client with an explicit connection limit and keep-alive. Create one HarvestWaveClient and reuse it. Every instance shares that pool.

  1. POST /oauth/token as form-urlencoded with grant_type=client_credentials, client_id, and client_secret. The sample reads HARVESTWAVE_CLIENT_ID and HARVESTWAVE_CLIENT_SECRET from the environment.
  2. POST /v1/lookups with Content-Type: application/json, body { "phoneNumber": "..." }, and Authorization: Bearer with the access_token.

The token response is access_token, token_type (Bearer), and expires_in. Published lifetime is eight hours (expires_in 28800). There is no refresh token. When fewer than 60 seconds remain, the sample requests a new client-credentials token.

These match the token and lookup calls already described above.

Run

Python 3.12. Install httpx (pip install httpx).

export HARVESTWAVE_CLIENT_ID="paste-the-client-id"
export HARVESTWAVE_CLIENT_SECRET="paste-the-client-secret"
python harvestwave_client.py "+15551234567"

Use the client id and client secret HarvestWave issued for the account. Keep them in the environment or a secret store. Do not commit them, and do not paste live values into this appendix.

Connectivity checks use DEMO test numbers (NPA-555, last four 0001–0199), not a credit purchase.

harvestwave_client.py

"""HarvestWave phone-number lookup example.

One process-wide httpx.Client (explicit connection limit, HTTP keep-alive).
Bearer tokens are reused until shortly before expires_in. No refresh token.

Depends on the httpx package.

Secrets are read from the environment only:

* HARVESTWAVE_CLIENT_ID
* HARVESTWAVE_CLIENT_SECRET
"""

from __future__ import annotations

import json
import os
import sys
import threading
import time
from dataclasses import dataclass

import httpx  # dependency: httpx

_TOKEN_URL = "https://api.harvestwave.com/oauth/token"
_LOOKUP_URL = "https://api.harvestwave.com/v1/lookups"
_DEFAULT_EXPIRES_IN_SECONDS = 28800
# Renew before expires_in so a lookup does not start on a dying token.
_RENEW_SKEW_SECONDS = 60
_CLIENT_ID_ENV = "HARVESTWAVE_CLIENT_ID"
_CLIENT_SECRET_ENV = "HARVESTWAVE_CLIENT_SECRET"

# Connectivity checks use DEMO test numbers (NPA-555, last four 0001-0199), not a credit purchase.

# Process-wide pool. Keep-alive is capped by keepalive_expiry. Not one client per call.
_HTTP = httpx.Client(
    limits=httpx.Limits(
        max_connections=10,
        max_keepalive_connections=10,
        keepalive_expiry=30.0,
    ),
    timeout=httpx.Timeout(30.0),
)
_TOKEN_LOCK = threading.Lock()
_access_token: str | None = None
_token_deadline_monotonic: float = 0.0


class HarvestWaveError(Exception):
    """Raised when a response body does not match the lookup contract."""


@dataclass(frozen=True, slots=True)
class Business:
    """One entry from the lookup ``businesses`` array."""

    name: str
    confidence: float


@dataclass(frozen=True, slots=True)
class LookupResult:
    """Lookup payload: ``isBusiness`` and ``businesses``."""

    is_business: bool
    businesses: tuple[Business, ...]


def _env(name: str) -> str:
    """Return a required environment variable, or raise if it is empty."""
    value = os.environ.get(name)
    if value is None or value.strip() == "":
        raise HarvestWaveError(f"Set environment variable {name}")
    return value


def _json_object(response: httpx.Response) -> dict[str, object]:
    """Parse a response body as a JSON object."""
    try:
        body: object = response.json()
    except ValueError as exc:
        raise HarvestWaveError("Response was not JSON") from exc
    if not isinstance(body, dict):
        raise HarvestWaveError("Response JSON was not an object")
    return body


def _request_access_token() -> tuple[str, int]:
    """POST client credentials and return ``(access_token, expires_in)``.

    The token endpoint accepts form-urlencoded or JSON. This client sends form.
    """
    response = _HTTP.post(
        _TOKEN_URL,
        data={
            "grant_type": "client_credentials",
            "client_id": _env(_CLIENT_ID_ENV),
            "client_secret": _env(_CLIENT_SECRET_ENV),
        },
        headers={"Accept": "application/json"},
    )
    response.raise_for_status()
    body = _json_object(response)
    access_token = body.get("access_token")
    token_type = body.get("token_type")
    if not isinstance(access_token, str) or access_token == "":
        raise HarvestWaveError("Token response missing access_token")
    if token_type != "Bearer":
        raise HarvestWaveError("Token response token_type was not Bearer")
    raw_expires = body.get("expires_in", _DEFAULT_EXPIRES_IN_SECONDS)
    if isinstance(raw_expires, bool) or not isinstance(raw_expires, (int, float)):
        raise HarvestWaveError("Token response expires_in was not a number")
    expires_in = int(raw_expires)
    if expires_in < 0:
        raise HarvestWaveError("Token response expires_in was negative")
    return access_token, expires_in


def _bearer_token() -> str:
    """Return the cached Bearer token, refreshing shortly before expiry."""
    global _access_token, _token_deadline_monotonic
    with _TOKEN_LOCK:
        now = time.monotonic()
        if _access_token is not None and now < _token_deadline_monotonic:
            return _access_token
        access_token, expires_in = _request_access_token()
        skew = min(float(_RENEW_SKEW_SECONDS), expires_in / 2.0)
        _access_token = access_token
        _token_deadline_monotonic = now + max(0.0, expires_in - skew)
        return access_token


def _parse_business(item: object) -> Business:
    """Read one business object's contracted ``name`` and ``confidence``."""
    if not isinstance(item, dict):
        raise HarvestWaveError("Lookup business entry was not an object")
    name = item.get("name")
    confidence = item.get("confidence")
    if not isinstance(name, str):
        raise HarvestWaveError("Lookup business missing name")
    if isinstance(confidence, bool) or not isinstance(confidence, (int, float)):
        raise HarvestWaveError("Lookup business missing confidence")
    return Business(name=name, confidence=float(confidence))


def _parse_lookup(body: dict[str, object]) -> LookupResult:
    """Read ``isBusiness`` and ``businesses`` from a lookup body."""
    is_business = body.get("isBusiness")
    raw_businesses = body.get("businesses")
    if not isinstance(is_business, bool):
        raise HarvestWaveError("Lookup response missing isBusiness")
    if not isinstance(raw_businesses, list):
        raise HarvestWaveError("Lookup response missing businesses")
    businesses = tuple(_parse_business(item) for item in raw_businesses)
    return LookupResult(is_business=is_business, businesses=businesses)


class HarvestWaveClient:
    """Lookup client. Every instance shares the process-wide HTTP pool."""

    def lookup(self, phone_number: str) -> LookupResult:
        """POST /v1/lookups with ``{"phoneNumber": phone_number}``.

        Args:
            phone_number: Number to look up, for example ``+15551234567``.

        Returns:
            The ``isBusiness`` flag and the ``businesses`` list.
        """
        response = _HTTP.post(
            _LOOKUP_URL,
            json={"phoneNumber": phone_number},
            headers={
                "Authorization": f"Bearer {_bearer_token()}",
                "Accept": "application/json",
            },
        )
        response.raise_for_status()
        return _parse_lookup(_json_object(response))

    def close(self) -> None:
        """Close the shared pool and drop the cached access token."""
        global _access_token, _token_deadline_monotonic
        with _TOKEN_LOCK:
            _access_token = None
            _token_deadline_monotonic = 0.0
            _HTTP.close()


def main(argv: list[str] | None = None) -> int:
    """Look up one number and print the contracted JSON object."""
    args = list(sys.argv[1:] if argv is None else argv)
    if len(args) != 1:
        print(
            "usage: python harvestwave_client.py <phoneNumber>",
            file=sys.stderr,
        )
        return 2
    result = HarvestWaveClient().lookup(args[0])
    payload = {
        "isBusiness": result.is_business,
        "businesses": [
            {"name": item.name, "confidence": item.confidence}
            for item in result.businesses
        ],
    }
    print(json.dumps(payload))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
Scroll to Top