Skip to content

JSON over HTTPS. Every endpoint is also available as a tool on the MCP server.

Basics

Base URL: https://api.wholayer.com/v1

Authenticate with a secret key from Settings, API keys, sent as a Bearer token. Keys are scoped to one workspace. Never put them in browser code.

curl https://api.wholayer.com/v1/people/enrich \
  -H "Authorization: Bearer wl_live_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 4f1d9c2e-order-123" \
  -d '{"linkedin_url":"https://www.linkedin.com/in/dana-okafor","reveal":["work_email"]}'

Send an Idempotency-Key header on POST requests. Retrying with the same key returns the original result and never charges twice. Every response includes credits_used and an X-Credits-Remaining header.

One call can spend at most 250 credits. To allow more, send X-Max-Credits: 1000 (up to 10,000). Test keys (wl_test_...) return sandbox data on every endpoint, never spend credits, and add "test_mode": true to responses.

Endpoints

POST /people/enrich

Find a person's work email, personal email, mobile, and profile from any identifier you have.

Cost: 1 credit per email returned, 5 per mobile. Nothing if no match.

Request

{
  "linkedin_url": "https://www.linkedin.com/in/dana-okafor",
  "email": null,
  "name": "Dana Okafor",
  "company_domain": "fieldline.com",
  "reveal": ["work_email", "personal_email", "mobile"]
}

Response

{
  "id": "per_8f2c1a",
  "name": "Dana Okafor",
  "title": "VP Revenue",
  "seniority": "vp",
  "department": "sales",
  "linkedin_url": "https://www.linkedin.com/in/dana-okafor",
  "company": { "name": "Fieldline", "domain": "fieldline.com", "employees": 210 },
  "work_email": { "address": "dana@fieldline.com", "risk": "A", "verified_at": "2026-11-01T18:22:04Z" },
  "personal_email": { "address": "dana.okafor@gmail.com", "risk": "B", "verified_at": "2026-10-28T09:10:51Z" },
  "mobile": { "number": "+15125550134", "line_type": "mobile", "carrier": "T-Mobile", "checked_at": "2026-11-02T14:03:11Z" },
  "credits_used": 7
}

POST /emails/verify

Check whether up to 1,000 email addresses will deliver, with a risk grade from A (safe) to F (will bounce).

Cost: 0.1 credit per address.

Request

{ "emails": ["dana@fieldline.com", "old.address@example.com"] }

Response

{
  "results": [
    { "email": "dana@fieldline.com", "status": "deliverable", "risk": "A", "catch_all": false },
    { "email": "old.address@example.com", "status": "undeliverable", "risk": "F", "reason": "mailbox_not_found" }
  ],
  "credits_used": 0.2
}

POST /phones/lookup

Find a mobile number for a person, with a live carrier and line-type check.

Cost: 5 credits when a mobile is returned. Nothing if not found.

Request

{ "person_id": "per_8f2c1a" }

Response

{
  "number": "+15125550134",
  "line_type": "mobile",
  "carrier": "T-Mobile",
  "checked_at": "2026-11-02T14:03:11Z",
  "credits_used": 5
}

GET /companies/{domain}

Company profile: size, revenue range, industry, location, and detected technologies.

Cost: 0.2 credit per company.

Response

{
  "domain": "fieldline.com",
  "name": "Fieldline",
  "employees": 210,
  "revenue_range": "$10M-$50M",
  "industry": "Software",
  "hq": { "city": "Austin", "region": "TX", "country": "US" },
  "technologies": ["HubSpot", "Segment", "Stripe"],
  "credits_used": 0.2
}

GET /balance

Credits remaining, plan, monthly spend cap, and this key's daily cap.

Cost: Free.

Response

{
  "credits_remaining": 4210.5,
  "plan": "growth",
  "monthly_spend_cap": 500,
  "key_daily_cap": 1000,
  "key_mode": "live"
}

POST /bulk

Enrich or verify up to 1,000 rows in one synchronous request. Each row is billed like a single call. Stops early if your wallet runs out.

Cost: Same as the single endpoints. Raise the 250-credit per-call ceiling with X-Max-Credits.

Request

{
  "operation": "enrich",
  "rows": [
    { "name": "Dana Okafor", "company_domain": "fieldline.com", "reveal": ["work_email"] },
    { "linkedin_url": "https://www.linkedin.com/in/priya-n", "reveal": ["work_email", "mobile"] }
  ]
}

Response

{
  "results": [
    { "ok": true, "id": "per_8f2c1a", "work_email": { "address": "dana@fieldline.com", "risk": "A" }, "credits_used": 1 },
    { "ok": false, "error": { "code": "person_not_available", "message": "No match for this person. You were not charged." } }
  ],
  "credits_used": 1
}

GET /visitors?site_id=site_123&since=2026-11-01T00:00:00Z&level=person

List people and companies identified on your sites. Paginated with cursor.

Cost: Free. Credits were spent when each visitor was identified.

Response

{
  "results": [
    {
      "id": "vis_77a1",
      "level": "person",
      "person_id": "per_8f2c1a",
      "company_domain": "fieldline.com",
      "first_seen": "2026-11-02T15:41:09Z",
      "pages": [{ "url": "/pricing" }, { "url": "/compare" }],
      "visits_this_month": 3
    }
  ],
  "next_cursor": null
}

Suppressed and opted-out people

If a person has opted out or is on your own suppression list, every endpoint behaves as if no match exists: you get 404 person_not_available and are not charged. We don't distinguish opted-out people from unknown people in responses, so don't build logic that depends on it.

Webhooks

Events are POSTed as JSON to your endpoint. Every request carries a WhoLayer-Signature header in the form t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of t + "." + rawBody using your workspace webhook secret (Destinations page). Recompute it, compare in constant time, and reject timestamps more than 5 minutes old to block replays. Respond with any 2xx within 10 seconds; failed deliveries are retried hourly for up to 24 hours. Company-level matches use the same visitor.identified event with data.level set to "company" and data.person set to null.

// Node.js verification
import { createHmac, timingSafeEqual } from "node:crypto";

function verify(rawBody, header, secret) {
  const { t, v1 } = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  return timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}

visitor.identified

{
  "type": "visitor.identified",
  "id": "evt_19ab",
  "created": "2026-11-02T15:41:12Z",
  "site_id": "site_123",
  "data": {
    "identity_id": "8f2c1a7e-…",
    "level": "person",
    "person": { "id": "8f2c1a7e-…", "name": "Dana Okafor", "title": "VP Revenue",
                "linkedin_url": "https://www.linkedin.com/in/dana-okafor",
                "work_email": "dana@fieldline.com", "location": "Austin, TX" },
    "company": { "name": "Fieldline", "domain": "fieldline.com", "employees": 210 },
    "page": { "url": "/pricing", "referrer": null },
    "pages": [{ "url": "/compare" }, { "url": "/pricing" }],
    "visits_this_month": 3,
    "first_seen": "2026-11-01T18:20:04Z",
    "last_seen": "2026-11-02T15:41:12Z",
    "source": "wholayer"
  }
}

shopper.identified

{
  "type": "shopper.identified",
  "id": "evt_19ac",
  "created": "2026-11-02T16:02:40Z",
  "site_id": "site_456",
  "data": {
    "identity_id": "c41d09b2-…",
    "email": "marco.reyes@example.com",
    "email_risk": "A",
    "first_name": "Marco",
    "pages": [{ "url": "/products/linen-shirt" }, { "url": "/cart" }],
    "visits_this_month": 2,
    "source": "wholayer"
  }
}

Errors

StatusCodeMeaning
400invalid_requestA field is missing or malformed. The message says which.
401unauthorizedMissing or invalid API key.
402credits_exhaustedYour wallet is empty or you've hit your spend cap.
403forbiddenRead-only key used on an endpoint that reveals contact details.
404person_not_availableNo match, or the person has opted out. You are not charged.
409duplicate_requestSame Idempotency-Key reused with a different body.
409request_in_progressA request with this Idempotency-Key is still running. Retry after a few seconds to get its stored result.
413payload_too_largeMore than 1,000 rows sent to /bulk, or a body over 4 MB.
429rate_limitedToo many requests. Retry after the Retry-After header.
500internal_errorOur fault. Retry with the same Idempotency-Key.
503provider_unavailableA data source is temporarily unavailable. You are not charged. Retry later.

Error bodies look like {"error":{"code":"rate_limited","message":"..."}}.

Rate limits

PlanRequests per second
Free2
Starter10
Growth25
Scale100
PlatformCustom, up to 1,000

For more than 1,000 records, send several POST /bulk requests in sequence. Ask us about file and warehouse delivery on the Platform plan.