Getting Started

The GeoLayer API is organized around REST. Our API has predictable resource-oriented URLs, accepts JSON-encoded request bodies, returns JSON-encoded responses, and uses standard HTTP response codes.

Authentication

The GeoLayer API uses API keys to authenticate requests. You can view and manage your API keys in the GeoLayer Dashboard.

Authorization: Bearer YOUR_API_KEY

Rate Limits

Rate limits are applied based on your subscription tier. If you exceed the rate limit, the API will return a 429 Too Many Requests error. API access requires a paid plan (Starter or higher) — Free plan users must use the dashboard.

Plan Rate Limit Included Leads / mo
Starter 5 req/sec 98
Growth 10 req/sec 220
Pro 50 req/sec 1,500
Enterprise 200 req/sec 5,000

Top up any time at $0.50 / lead ($50 minimum) — credits never expire.

Error Codes

GeoLayer uses conventional HTTP response codes to indicate the success or failure of an API request.

200 OK: Everything worked as expected.
500 Request Failed: Something went wrong, time to debug.
429 Too Many Requests: You have hit your rate limit.

Webhooks

Receive real-time push notifications when your search tasks complete — faster and more reliable than polling. Set your webhook URL via the dashboard or PUT /v1/webhooks. Every delivery is signed with HMAC-SHA256 and retried on transient failure.

Events

EventWhen it fires
task.completedSearch task finished successfully with at least 1 lead returned.
task.out_of_creditsTask short-circuited because your account balance hit zero mid-fetch.

Payload

{
  "timestamp": "2026-05-26T18:42:11.000Z",
  "event": "task.completed",
  "submission_id": "job_8291x_q",
  "total_leads": 10,
  "credits_used": 10,
  "credits_remaining": 188,
  "task": { "keyword": "Plumber", "city": "Austin", "max_leads": 10 }
}

Signature verification

Every request carries an X-Geolayer-Signature: sha256=<hex> header. Compute the same HMAC-SHA256 of the raw request body using your shared secret (set as OUTBOUND_WEBHOOK_SECRET; falls back to your JWT secret if unset). Compare with timing-safe equality — never ===.

Node.js / Express

app.post("/webhook", express.raw({type: "*/*"}), (req, res) => {
  const sig = req.headers["x-geolayer-signature"];
  const hmac = crypto.createHmac("sha256", process.env.SECRET);
  const expected = "sha256=" + hmac.update(req.body).digest("hex");
  const a = Buffer.from(sig);
  const b = Buffer.from(expected);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.status(401).end();
  }
  const event = JSON.parse(req.body.toString());
  // handle event.submission_id ...
  res.status(200).end();
});

Python / Flask

import hmac, hashlib, os
from flask import request

@app.post("/webhook")
def webhook():
    sig = request.headers.get("X-Geolayer-Signature", "")
    expected = "sha256=" + hmac.new(
        os.environ["SECRET"].encode(),
        request.get_data(),
        hashlib.sha256,
    ).hexdigest()
    if not hmac.compare_digest(sig, expected):
        return "", 401
    event = request.get_json()
    # handle event["submission_id"] ...
    return "", 200

Ruby / Sinatra

require "openssl"
require "json"

post "/webhook" do
  raw = request.body.read
  expected = "sha256=" + OpenSSL::HMAC.hexdigest(
    "SHA256", ENV["SECRET"], raw
  )
  sig = request.env["HTTP_X_GEOLAYER_SIGNATURE"].to_s
  halt 401 unless Rack::Utils.secure_compare(sig, expected)
  event = JSON.parse(raw)
  # handle event["submission_id"] ...
  200
end

PHP

$raw = file_get_contents("php://input");
$sig = $_SERVER["HTTP_X_GEOLAYER_SIGNATURE"] ?? "";
$expected = "sha256=" . hash_hmac(
    "sha256", $raw, getenv("SECRET")
);
if (!hash_equals($expected, $sig)) {
    http_response_code(401);
    exit;
}
$event = json_decode($raw, true);
// handle $event["submission_id"] ...
http_response_code(200);

Retry behavior

  • Failed deliveries retry 3 times with exponential backoff: immediate, +2s, +8s.
  • A "failure" = non-2xx response, connection error, or timeout (10s).
  • Final state (delivered or 3-times-failed) is logged in your dashboard under Developers → Webhook deliveries with the response body for debugging.
  • Your endpoint should respond 200 within 5 seconds. If you need longer processing, queue the work and respond immediately.
  • Idempotency: re-deliveries carry the same submission_id. Use it as your dedup key.

Test webhook

Send a signed test payload to your configured URL without running a real task:

curl -X POST https://geolayer.io/v1/webhooks/test \
  -H "Authorization: Bearer YOUR_API_KEY"

SDKs & Postman

No dedicated SDK package — the API is small enough that a 4-line HTTP call is cleaner than a wrapper. Here's idiomatic code in the languages we get asked about most. Same endpoints, same payloads, swap your YOUR_API_KEY.

Submit a task + fetch results

Node.js (fetch)

const KEY = process.env.GEOLAYER_API_KEY;
const BASE = "https://geolayer.io";

async function pullLeads(keyword, city, max = 50) {
  // 1. submit
  const submit = await fetch(`${BASE}/v1/tasks/submit`, {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ keyword, city, max_leads: max }),
  }).then(r => r.json());

  // 2. poll until done (or use a webhook)
  while (true) {
    await new Promise(r => setTimeout(r, 3000));
    const res = await fetch(
      `${BASE}/v1/tasks/results/${submit.submission_id}`,
      { headers: { "Authorization": `Bearer ${KEY}` } }
    ).then(r => r.json());
    if (res.status === "completed") return res.data;
    if (res.status === "error" || res.status === "out_of_credits") {
      throw new Error(res.reason || res.status);
    }
  }
}

pullLeads("Plumber", "Austin", 20).then(console.log);

Python (requests)

import os, time, requests

KEY  = os.environ["GEOLAYER_API_KEY"]
BASE = "https://geolayer.io"
H    = {"Authorization": f"Bearer {KEY}"}

def pull_leads(keyword, city, max_leads=50):
    # 1. submit
    r = requests.post(f"{BASE}/v1/tasks/submit",
        headers={**H, "Content-Type": "application/json"},
        json={"keyword": keyword, "city": city, "max_leads": max_leads})
    sid = r.json()["submission_id"]

    # 2. poll
    while True:
        time.sleep(3)
        res = requests.get(
          f"{BASE}/v1/tasks/results/{sid}", headers=H
        ).json()
        if res["status"] == "completed":
            return res["data"]
        if res["status"] in ("error", "out_of_credits"):
            raise RuntimeError(res.get("reason") or res["status"])

print(pull_leads("Plumber", "Austin", 20))

Ruby

require "net/http"
require "json"
require "uri"

KEY  = ENV["GEOLAYER_API_KEY"]
BASE = URI("https://geolayer.io")

def pull_leads(keyword, city, max_leads = 50)
  body = JSON.generate(
    keyword: keyword, city: city, max_leads: max_leads
  )
  h = {
    "Authorization" => "Bearer #{KEY}",
    "Content-Type"  => "application/json",
  }
  sid = JSON.parse(Net::HTTP.post(
    URI("#{BASE}/v1/tasks/submit"), body, h
  ).body)["submission_id"]

  loop do
    sleep 3
    res = JSON.parse(Net::HTTP.get(
      URI("#{BASE}/v1/tasks/results/#{sid}"),
      "Authorization" => "Bearer #{KEY}"
    ))
    return res["data"] if res["status"] == "completed"
    raise(res["reason"] || res["status"]) if %w[error out_of_credits].include?(res["status"])
  end
end

puts pull_leads("Plumber", "Austin", 20)

cURL (one-shot synchronous lookup)

# Sync single-business enrichment (no polling).
curl -X POST https://geolayer.io/v1/get-profile \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Acme Plumbing",
    "city": "Austin",
    "state": "TX"
  }'

# Bulk CSV (returns text/csv directly).
curl -G https://geolayer.io/v1/leads/export \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -d keyword=Plumber -d city=Austin -d limit=500 \
  -o austin-plumbers.csv

Postman

A working Postman collection is published as a public workspace. Import directly:

Download postman-collection.json

Includes every endpoint with example payloads, environment variables for {{API_KEY}}, and tests that assert response shape. Import → set your API key → ready.

Embed GeoLayer in Your Stack

Anywhere your tool supports an HTTP / webhook call, you can call GeoLayer. Below are the integration patterns we get asked about most often. None of these require a special connector — the standard REST API handles them.

Clay (table-based enrichment)

Add an HTTP-enrichment column that calls /v1/get-profile per row. Map name + city from existing columns.

// In Clay → Add Column → HTTP API → POST
URL:     https://geolayer.io/v1/get-profile
Method:  POST
Headers: Authorization: Bearer YOUR_API_KEY
         Content-Type: application/json
Body:    {
           "name": {{Company Name}},
           "city": {{City}},
           "state": {{State}}
         }
// Output columns auto-flatten: response.email, response.phone, etc.

Smartlead / Instantly / Lemlist (outbound sequencers)

Either (a) bulk-export from GeoLayer and upload the CSV into a Smartlead campaign, or (b) use a Zapier zap: New Task Completed → Add Leads to Smartlead Campaign.

# Daily fresh-leads → Smartlead campaign via cron + curl
curl -G https://geolayer.io/v1/leads/export \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -d keyword=Plumber -d city=Miami -d limit=100 \
  -o today.csv

# then POST today.csv to Smartlead's add-leads endpoint
curl -X POST https://server.smartlead.ai/api/v1/campaigns/$CAMP/leads \
  -H "Authorization: Bearer $SL_KEY" \
  -F "csv=@today.csv"

n8n / Make.com / Zapier (workflow automation)

Zapier has a native GeoLayer app (2 triggers + 3 actions). For n8n or Make, drop in the HTTP Request node with Bearer auth.

  • n8n: HTTP Request node → Authentication: Header Auth → Header Name Authorization, Header Value Bearer YOUR_API_KEY
  • Make.com: "Make an API Call" module → Connection: Generic Bearer → paste API key
  • Zapier: Search for "GeoLayer" in the app picker → install → 2 triggers + 3 actions available

AI sales agents (Twain, Lavender, custom LLM stacks)

Tools that generate personalized outbound first lines or research a target before writing usually need raw company + contact data. Point them at /v1/get-profile for sync enrichment or /v1/tasks/submit for batch.

For OpenAI / Anthropic tool-calling, expose GeoLayer as a function:

{
  "type": "function",
  "function": {
    "name": "geolayer_lookup_business",
    "description": "Look up verified B2B contact info for a US business",
    "parameters": {
      "type": "object",
      "properties": {
        "name":  {"type": "string"},
        "city":  {"type": "string"},
        "state": {"type": "string"}
      },
      "required": ["name", "city"]
    }
  }
}
// implementation: call POST https://geolayer.io/v1/get-profile

High-Volume API Pricing

For embedded use cases — outbound sequencers, vertical SaaS, AI agents, or any product that pulls 25K+ leads/month through the API — we offer volume-tier pricing with SLA and white-label delivery. Email admin@geolayer.io to discuss.

Volume / mo $ / lead Monthly minimum SLA
5K – 25K$0.30$1,50099.5%
25K – 100K$0.25$6,00099.5%
100K – 500K$0.20$20,00099.9% + on-call
500K+customcustom99.95% + signed DPA

What's included

  • Dedicated API key + rate-limit ceiling raised to your peak burst
  • White-label option — delivery emails branded as your product, not GeoLayer
  • Signed DPA (Data Processing Addendum) for procurement
  • Net-30 invoicing instead of card-on-file
  • Direct Slack channel with engineering for incident response

Standard self-serve tiers (Free / Starter / Growth / Pro / Enterprise) on the homepage pricing page remain unchanged — this section is the sales-side reference for embedded/wholesale deals.

POST

Submit a Search Task

POST https://geolayer.io/v1/tasks/submit

Initializes a new search job. Returns a submission_id immediately (HTTP 202). Use this ID to poll for results or wait for a webhook. Each unique lead returned debits one credit; failed scrapes don't burn credits.

Request Body (JSON)

keyword "Plumbers" The business niche. Required.
city "Austin" The target location. Required.
max_leads 20 Optional cap on the number of leads to return. Defaults to 20. Limited by your remaining credits.

Every returned lead has an email (our identity + delivery channel). Phone and address are returned when available but never gate the result set.

Response

  • submission_id: Unique ID. Use it with the results endpoint or expect it back via webhook.
  • status: One of queued, processing, completed, out_of_credits, error.
  • credits_remaining: Your balance immediately after submission (before the task debits).

GET

Fetch Task Results

GET https://geolayer.io/v1/tasks/results/{submission_id}

Retrieves the collected data once the task is complete. If the task is still running, the status will return as "processing".

Response

  • status: One of queued, processing, completed, out_of_credits, error.
  • data: Array of leads (first_name, last_name, email, phone, company_name, title, website, address, city, state, country_code, email_verified_status). Suppression-list emails are filtered out.
  • reason: When data is empty on a completed task, a one-line human explanation (e.g. "Filter excluded every candidate"). Otherwise null.
  • diagnostics: When data is empty, an object with cache_hits, fresh_fetched, must_have, and dropped_by_filter: { email, phone, address, invalid_email } counts.

GET

Get Profile Data

GET https://geolayer.io/v1/me

Legacy alias: /v1/get-user-data.

Retrieves your profile and account data.

Response

  • full_name: Your name.
  • email: Your profile email.
  • credits_remaining: Your remaining credits count.
  • plan_name: Your monthly subscription plan name (e.g. Starter).
  • plan_tier: Programmatic identifier (starter, growth, pro, enterprise, or free). Legacy: scale (existing customers only).
  • api_access: Whether your plan permits raw API calls (free plans must use the dashboard).

GET

List Recent Tasks

GET https://geolayer.io/v1/tasks/list-tasks

Returns your last 10 tasks (newest first). Tasks stuck in queued for more than 5 minutes are automatically retried up to 3 times before flipping to error.

Response (array)

  • submission_id, status, created_at, last_retried, retry_count, error_log.
  • payload: The original { keyword, city, max_leads } request body plus a diagnostics object once the task completes.

GET

Get All Your Leads

GET https://geolayer.io/v1/get-all-leads

Every lead ever returned to your account across every task, deduped by email and filtered through your suppression list. Useful for a one-shot CRM sync.

Response (array)

Same row shape as Fetch Task Results.


GET

Bulk CSV Export

GET https://geolayer.io/v1/leads/export?keyword=Plumbers&city=Austin&limit=1000

Streams a CSV of up to limit rows (max 10,000) for a specific keyword + city. Pulls from the cache only — does not trigger live source fetches. Debits credits atomically; if your balance can't cover limit the request returns 402 without touching the data.

Query Parameters

keywordRequired.
cityRequired.
limitOptional. Default 1,000. Hard cap 10,000.

GET POST DELETE

Suppression List

/v1/suppression

Per-account do-not-contact list. Every read path (task results, get-all-leads, bulk export, lead lists) silently filters out matching emails. Use this for opt-outs, bounces, and CAN-SPAM/GDPR removal requests.

  • GET /v1/suppression — list (most recent 1,000).
  • POST /v1/suppression — body { "email": "..." , "reason": "user_added" } or { "emails": ["a@b.com", ...] } for bulk.
  • DELETE /v1/suppression?email=foo@bar.com — remove one entry.

GET POST DELETE

Saved Searches

/v1/searches

Store and re-run searches without retyping the parameters.

  • GET /v1/searches — list your saved searches.
  • POST /v1/searches — body { "name": "Bay Area Plumbers", "keyword": "Plumber", "city": "San Jose" }.
  • POST /v1/searches/run?id=N — submit a fresh task using a saved entry. Forwards to /v1/tasks/submit and returns the same { submission_id, status } response.
  • DELETE /v1/searches?id=N — remove.

GET POST DELETE

Lead Lists

/v1/lists

Lightweight CRM-style grouping. Create lists, add lead IDs to them, read back members for export or sync. Suppression-list filtering is applied to membership reads.

  • GET /v1/lists — all your lists with member_count.
  • POST /v1/lists — create. Body { "name": "Top Pipeline Q3" }.
  • GET /v1/lists?id=N — members (full lead rows, same shape as task results).
  • POST /v1/lists?id=N — add members. Body { "lead_ids": [123, 124] }.
  • DELETE /v1/lists?id=N — delete the list (cascades members).

GET PUT DELETE

Webhook Config

/v1/webhooks

Manage your outbound webhook URL programmatically. See the Webhooks section above for the payload format and signature scheme.

  • GET /v1/webhooks{ webhook_url, signing_secret_env, signature_header, events: [...] }.
  • PUT /v1/webhooks — body { "url": "https://your.server/hook" }. Validated as an http(s) URL.
  • DELETE /v1/webhooks — clear the URL (stops outbound deliveries).
  • POST /v1/webhooks/test — send a signed test.webhook payload to the configured URL. Response includes delivered, attempts, last_status.
1. Submit Request
curl -X POST "https://geolayer.io/v1/tasks/submit" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "keyword": "Plumbers",
    "city": "San Jose",
    "max_leads": 20
  }'

// Response (HTTP 202)
{
  "submission_id": "job_8291x_q",
  "status": "queued",
  "credits_remaining": 188
}
2. Fetch Results
curl -X GET "https://geolayer.io/v1/tasks/results/job_8291x_q" \
  -H "Authorization: Bearer YOUR_API_KEY"

// Response
{
  "status": "completed",
  "data": [
    {
      "first_name": "John",
      "last_name": "Smith",
      "email": "info@draindoc.com",
      "phone": "+1-408-555-1234",
      "company_name": "Drain Doctor",
      "title": "Owner",
      "website": "https://www.draindoc.com",
      "address": "1234 Elm Street",
      "city": "San Jose",
      "state": "CA",
      "country_code": "US",
      "verified": true
    }
  ]
}

3. Get Profile Data
curl -X GET "https://geolayer.io/v1/me" \
  -H "Authorization: Bearer YOUR_API_KEY"

// Response
{
  "full_name": "Tom Harrison",
  "email": "tom.harrisson75@gmail.com",
  "credits_remaining": 1000,
  "plan_name": "Growth"
}