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.
// 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/signalsAuthentication
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.
// 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.
# 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.
| Header | What it tells you |
|---|---|
X-RateLimit-Limit | Requests allowed in the current window for this key. |
X-RateLimit-Remaining | Requests left in the current window. Present on every response, not just 429s. |
X-RateLimit-Reset | Unix timestamp at which the window resets and Remaining returns to Limit. |
Retry-After | Seconds 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.
// 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.
// 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.
| Status | Name | What it means |
|---|---|---|
200 | OK | The request succeeded. Read the body for the result. |
202 | Accepted | A long-running job — a bulk enrichment or an export — was queued. Poll the job or wait for the webhook. |
400 | Bad Request | The body could not be parsed, or a parameter is the wrong type. The response names the offending parameter. |
401 | Unauthorized | The Authorization header is missing, malformed, or the key has been revoked. Nothing was charged. |
403 | Forbidden | The key is valid but the workspace’s plan does not include this endpoint, or the key’s scope excludes it. |
404 | Not Found | No record, job or webhook exists with that identifier in this workspace. |
409 | Conflict | The request collides with existing state — most often a webhook already registered for the same URL. |
422 | Unprocessable Entity | The request parsed but cannot be acted on — an empty filter set, or a batch above the accepted size. |
429 | Too Many Requests | The rate limit for this key is exhausted. Back off and retry after the interval in Retry-After. |
500 | Server Error | Something broke on our side. The request is safe to retry, and credits are not charged for a 5xx. |
503 | Service Unavailable | A dependency is temporarily unavailable. Retry with backoff. |
// 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.
/v1/contacts/search
Search contacts
Filter the full BulkData.ai contact index by title, seniority, industry, location, company size and buying signals. Returns a paginated preview — reveal credits are only spent when you request full records.
- Filter by 20+ firmographic and role attributes
- Preview results before spending credits
- Cursor-based pagination for large result sets
// Request
{
"title": "VP Sales",
"industry": "SaaS",
"country": "GB",
"limit": 25
}
// Response 200 OK
{
"total": 3842,
"results": [ …25 preview records ]
}/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
// Request
{
"email": "j.doe@acme.com"
}
// Response 200 OK
{
"phone": "+1 415 555 0199",
"linkedin_url": "linkedin.com/in/jdoe",
"company_size": "201-500"
}/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
// Response 200 OK
{
"signals": [
{ "type": "funding_round", "category": "funding",
"account": "Nexus SaaS", "fit_score": 82 }
],
"has_more": false
}/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
// 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.
| Event | Fires when |
|---|---|
signal.created | A new buying signal fired on a tracked account. The payload carries its category, its fit score and the account it belongs to. |
signal.updated | An existing signal was re-scored or corrected — most often because the account’s fit changed or a second source confirmed the event. |
enrichment.completed | A bulk enrichment job finished. The payload carries the job id and its match rate. |
enrichment.failed | A bulk enrichment job could not be completed. Nothing was charged for it. |
export.completed | An 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.
// 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.
// 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.
| Operation | Cost | Notes |
|---|---|---|
| Searching and filtering | Free | Any number of queries against the index, with any combination of filters. |
| Previewing a result | Free | Names, titles, companies and firmographics in a search response cost nothing. |
| Reading buying signals | Free | Listing signals and receiving them by webhook is included with the plans that have signals. |
| Revealing a contact | Credits | Verified email addresses and direct dials. Charged per record returned, not per request. |
| Enriching a record | Credits | Charged per row that matches. A row we cannot match costs nothing. |
| Exporting data | Credits | Sending 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.