API Reference

Build directly on BulkData.ai.

A JSON REST API over HTTPS: search, enrichment, buying signals and webhooks, with bearer-key authentication, cursor pagination and predictable errors. The same verified data that powers the product, available programmatically.

Base URL and versioning

Every request goes to https://api.bulkdata.ai over HTTPS — never plain HTTP, which would put your key on the wire in clear text. Request and response bodies are JSON; send Content-Type: application/json on anything with a body.

The version is the first path segment — /v1/… — and it is the only version selector. There is no version header and no query parameter, so the URL in your code says exactly which contract it is written against.

Inside a version, changes are additive only. A new field on a response, a new optional parameter, a new endpoint or a new value in an existing enum can appear at any time — write clients that ignore fields they do not recognise. A change that removes or renames a field, changes a field’s type, makes an optional parameter required, or changes what a status code means is a breaking change, and breaking changes only ever ship behind a new prefix. When that happens the previous version keeps serving traffic through a deprecation period announced in advance to every workspace with an active key.

Code example
// Base URL
https://api.bulkdata.ai/v1

// Every path in this reference hangs off it
POST  https://api.bulkdata.ai/v1/contacts/search
GET   https://api.bulkdata.ai/v1/signals

Authentication

Authenticate with a bearer API key in the Authorization header. A key is scoped to one workspace and carries that workspace’s plan and credit balance; there is no separate account id to send.

Keys are created in your workspace settings, under API keys. Issue a separate key per environment — staging and production at minimum — because usage is tracked per key, which is also what lets you see which integration spent what. Any key can be rotated or revoked at any time from the same screen.

Never ship a key in client-side code. Anything in browser JavaScript, a mobile app bundle, a public repository or a CI log is public, however obfuscated. Call the API from your own server, or from a proxy you control that adds the header. A leaked key can be revoked, but it cannot be un-used — rotate immediately and check the usage on that key.

Code example
// Every authenticated request
curl https://api.bulkdata.ai/v1/signals \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json"

// Response 401 Unauthorized — missing, malformed or revoked key
{
  "error": {
    "type": "invalid_api_key",
    "message": "No API key found in the Authorization header."
  }
}

Quickstart

From a fresh key to a first result. Create a key in your workspace settings, export it into your shell so it never lands in a file, and run the search below — it filters the index and returns previews, which costs no credits.

Code example
# 1. Keep the key out of your source tree
export BULKDATA_API_KEY="sk_live_…"

# 2. Search the index — free, returns previews
curl -X POST https://api.bulkdata.ai/v1/contacts/search \
  -H "Authorization: Bearer $BULKDATA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"title":"VP Sales","industry":"SaaS","country":"GB","limit":25}'

# 3. Read the first page, then follow next_cursor for the rest
#    Revealing or exporting those records is what spends credits.

There is no official SDK to install. The API is plain JSON over HTTPS with one header, so whatever HTTP client your language already has is enough — and you are not waiting on us to publish a package before you can ship.

Rate limits

Rate limits are applied per API key. The allowance depends on your plan and on the endpoint — search is cheaper to serve than bulk enrichment — so rather than publishing a figure that would be wrong for most readers, every response tells you where you stand. Read the headers and adapt; do not hardcode a number.

Rate limit response headers
HeaderWhat it tells you
X-RateLimit-LimitRequests allowed in the current window for this key.
X-RateLimit-RemainingRequests left in the current window. Present on every response, not just 429s.
X-RateLimit-ResetUnix timestamp at which the window resets and Remaining returns to Limit.
Retry-AfterSeconds to wait before retrying. Sent with a 429 and with a 503.

Exceeding the allowance returns 429 with a Retry-After header. Retry with exponential backoff and jitter — a fleet of workers that all retry on the same fixed interval simply re-collides. A 429 costs no credits.

Code example
// Response 429 Too Many Requests
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1735689600
Retry-After: 17

{
  "error": {
    "type": "rate_limit_exceeded",
    "message": "Too many requests. Retry after the interval in Retry-After.",
    "request_id": "req_9c14…"
  }
}

Pagination

List and search endpoints are cursor-paginated. Send limit for the page size and cursor to continue; the response returns has_more and, when there is more, next_cursor. Keep requesting until has_more is false.

A cursor is opaque: it encodes a position, not an offset, so do not parse one, build one, or assume it survives a change of filters. Cursors are what keep a long walk stable while new records land in the index — an offset-based page two would silently skip or repeat rows. limit defaults to 25; the maximum accepted value depends on your plan, and a request above it is rejected with a 422 naming the ceiling rather than being quietly truncated.

Code example
// Request — page two
{
  "title": "VP Sales",
  "limit": 25,
  "cursor": "eyJvIjoyNSwic__"
}

// Response 200 OK
{
  "results": [ …25 records ],
  "has_more": true,
  "next_cursor": "eyJvIjo1MCwic__"
}

Errors

Errors use standard HTTP status codes and always return a JSON body with the same shape. Branch on the status code, log the request_id — quoting it is the fastest way for support to find one specific call — and show message only to developers: it explains the problem, it is not end-user copy.

HTTP status codes returned by the API
StatusNameWhat it means
200OKThe request succeeded. Read the body for the result.
202AcceptedA long-running job — a bulk enrichment or an export — was queued. Poll the job or wait for the webhook.
400Bad RequestThe body could not be parsed, or a parameter is the wrong type. The response names the offending parameter.
401UnauthorizedThe Authorization header is missing, malformed, or the key has been revoked. Nothing was charged.
403ForbiddenThe key is valid but the workspace’s plan does not include this endpoint, or the key’s scope excludes it.
404Not FoundNo record, job or webhook exists with that identifier in this workspace.
409ConflictThe request collides with existing state — most often a webhook already registered for the same URL.
422Unprocessable EntityThe request parsed but cannot be acted on — an empty filter set, or a batch above the accepted size.
429Too Many RequestsThe rate limit for this key is exhausted. Back off and retry after the interval in Retry-After.
500Server ErrorSomething broke on our side. The request is safe to retry, and credits are not charged for a 5xx.
503Service UnavailableA dependency is temporarily unavailable. Retry with backoff.
Code example
// Response 400 Bad Request
{
  "error": {
    "type": "invalid_request",
    "message": "limit must be an integer.",
    "param": "limit",
    "request_id": "req_4a71…"
  }
}

Endpoints

Four endpoints cover the product surface: search the index, enrich a record you already have, read buying signals, and subscribe to events. Paths are shown relative to the base URL above.

POST

/v1/enrich

Enrich a record

Send a partial contact or company record and get back verified emails, direct dials, LinkedIn URLs and firmographics — without overwriting the fields you already have.

  • Single record or bulk batch
  • Match rate returned before credits are charged
  • Unmatched rows are never billed
Code example
// Request
{
  "email": "j.doe@acme.com"
}

// Response 200 OK
{
  "phone": "+1 415 555 0199",
  "linkedin_url": "linkedin.com/in/jdoe",
  "company_size": "201-500"
}
GET

/v1/signals

List buying signals

Pull hiring, funding, technographic, leadership and news signals for your tracked accounts, or subscribe by webhook to be notified the moment a new signal fires.

  • Filter by signal category, fit score or account list
  • Real-time or daily digest delivery
  • Reading signals does not consume credits
Code example
// Response 200 OK
{
  "signals": [
    { "type": "funding_round", "category": "funding",
      "account": "Nexus SaaS", "fit_score": 82 }
  ],
  "has_more": false
}
POST

/v1/webhooks

Subscribe to events

Register an endpoint to receive buying-signal and job events the moment they happen, instead of polling. The signing secret is returned once, on creation.

  • Signed payloads with HMAC verification
  • Automatic retries with exponential backoff
  • Filters on the subscription apply before delivery
Code example
// Request
{
  "url": "https://yourapp.com/hooks/bulkdata",
  "events": ["signal.created", "enrichment.completed"]
}

// Response 201 Created — store the secret, it is shown once
{
  "id": "whk_8f2c…",
  "signing_secret": "whsec_…"
}

Webhooks

Register an endpoint by posting a URL and the list of events you want to /v1/webhooks, or from your workspace settings. The response returns a signing secret once — store it where you keep your other secrets, because it cannot be read back. A subscription can carry the same filters as the in-app feed, so an event you have filtered out never reaches your endpoint at all.

Webhook event types
EventFires when
signal.createdA new buying signal fired on a tracked account. The payload carries its category, its fit score and the account it belongs to.
signal.updatedAn existing signal was re-scored or corrected — most often because the account’s fit changed or a second source confirmed the event.
enrichment.completedA bulk enrichment job finished. The payload carries the job id and its match rate.
enrichment.failedA bulk enrichment job could not be completed. Nothing was charged for it.
export.completedAn export finished and its file is ready to download.

A signal event’s category is one of hiring, funding, technographic, leadership, news — the same five families the signal catalogue describes. Branch on the category rather than on the narrower type if you want a rule that keeps working when a new signal type is added to a family.

Code example
// POST to your endpoint
X-BulkData-Signature: t=1735689600,v1=5f2b…
Content-Type: application/json

{
  "id": "evt_2f91…",
  "type": "signal.created",
  "created_at": "2026-09-08T09:14:02Z",
  "data": {
    "signal_type": "hiring_surge",
    "category": "hiring",
    "account": { "name": "Orbit Tech", "domain": "orbit.example" },
    "fit_score": 82,
    "detected_at": "2026-09-08T09:12:44Z"
  }
}

Verifying the signature

Each delivery carries an X-BulkData-Signature header holding a timestamp and an HMAC-SHA256 of timestamp + "." + rawBody, keyed with your signing secret. Compute the same digest over the raw request body — parsing and re-encoding the JSON first changes the bytes and the digest with them — and reject anything that does not match, or whose timestamp is old enough to be a replay.

Compare the two digests in constant time. A byte-by-byte === returns faster on an earlier mismatch, which leaks the correct prefix to anyone willing to measure. This site’s own /api/revalidate webhook does exactly what the sample below does, for the same reason, and hashes both values first so that a length mismatch cannot throw and leak the length of the real secret.

Code example
// Node — verify before you trust the body
import { createHmac, timingSafeEqual } from 'node:crypto';

const [t, v1] = header.split(',').map((p) => p.split('=')[1]);
const expected = createHmac('sha256', secret)
  .update(`${t}.${rawBody}`)
  .digest('hex');

// Length first — timingSafeEqual throws on a mismatch, and the
// length of a fixed-size digest is not a secret. Then constant time.
const a = Buffer.from(expected);
const b = Buffer.from(v1);
const ok = a.length === b.length && timingSafeEqual(a, b);

Answer with any 2xx as soon as you have stored the event, and do the work afterwards. A delivery that times out or answers non-2xx is retried with exponential backoff, so a slow handler turns into duplicate deliveries — treat id as an idempotency key and ignore one you have already processed.

What consumes credits

The API bills the same way the product does: browsing is free, and you spend credits when data leaves the platform with you. Searching, filtering and previewing cost nothing however many times you do it; revealing a contact and exporting records are what draw down credits.

Operations and what they cost
OperationCostNotes
Searching and filteringFreeAny number of queries against the index, with any combination of filters.
Previewing a resultFreeNames, titles, companies and firmographics in a search response cost nothing.
Reading buying signalsFreeListing signals and receiving them by webhook is included with the plans that have signals.
Revealing a contactCreditsVerified email addresses and direct dials. Charged per record returned, not per request.
Enriching a recordCreditsCharged per row that matches. A row we cannot match costs nothing.
Exporting dataCreditsSending revealed records out of the platform, whether by export or by CRM push.

Charges are per record, not per request, and a request that returns no match is not charged — neither is any 4xx or 5xx. Your usage is visible per key in workspace settings, and the current plans are set out on the pricing page.