Engineering

Idempotency Keys API Design for Reliable Enrichment Jobs

The Enrichments TeamOctober 6, 202610 min read
Abstract editorial illustration for “Idempotency Keys API Design for Reliable Enrichment Jobs” — Enrichments

An enrichment client has to handle an uncomfortable case: the request may have reached the server even when your client never received the response. Idempotency keys API design gives you a safe way to recover from that ambiguity without starting duplicate jobs or creating duplicate credit charges.

For enrichment workflows, retry safety belongs in the client design as much as in the API contract. You need to persist the original operation, its Idempotency-Key, and the job returned for it.

What idempotency means in an enrichment API

Idempotency means you can safely repeat a client request without creating a second logical operation.

That definition matters because a repeated request can look harmless while still creating duplicate work. Two searches may return similar people. Two enrichment requests may resolve the same identifiers. But if they created separate jobs, they are separate operations. They can each reserve credits and each produce their own result set.

An idempotent retry is different. It tells the API that this request is a replay of an operation you already started. The server can return the existing job rather than create another one.

This is especially important for asynchronous API jobs. Your client may send a request successfully, then lose the connection before it receives a response. From the client's perspective, the outcome is unknown:

  • The request may never have reached the API.
  • The API may have accepted the request and created a job.
  • The job may already be running or finished.
  • The original response may have been lost after the job was created.

Without duplicate request prevention, a retry is a new request. It creates a second job, takes a second hold, and can be charged as a new run.

With Enrichments, a retry is free only when it carries the same Idempotency-Key header as the original request. A retry without that header is not retry-safe.

Choose the operations that need an idempotency key

Use an idempotency key for POST operations that start searches or enrichments.

The main REST API candidates are:

  • POST /api/v1/people/search
  • POST /api/v1/people/enrich
  • POST /api/v1/companies/search
  • POST /api/v1/companies/enrich

Each of these routes can start work that consumes credits when data is returned. A people search is charged per person returned. An enrichment row is charged when at least one requested field is resolved. Email lookups are charged only for work email addresses actually found.

By contrast, job polling does not create new work. GET /api/v1/jobs/{id} reads the current state of an existing job. It does not need the same request-deduplication treatment.

The same distinction applies to operational tooling. Reading a previously returned result should not create another enrichment request. Starting a new POST because you cannot find the previous job ID can.

Common retry triggers include:

  • A client timeout while waiting for the initial response.
  • A dropped connection after the request was sent.
  • A worker restart during request handling.
  • A process crash before the client persisted the returned job ID.
  • An ambiguous response where your client cannot tell whether the API accepted the request.

Treat all of these as unknown-outcome failures. Do not assume the request failed just because your client did not receive a clean response.

A retry is safe only when it reuses the original Idempotency-Key. A new key represents a new operation.

Send and persist an Idempotency-Key correctly

Generate the idempotency key before your first request, then store it with the client-side operation state.

The key belongs in the Idempotency-Key request header. Do not place it in an undocumented JSON body field. The API uses the header to identify retries of the same request.

A practical client-side record should associate these values:

  • Your stable operation identifier.
  • The Idempotency-Key.
  • The request type and endpoint.
  • A request fingerprint for the body and requested fields.
  • The returned job.id, once known.
  • The latest observed job.status.
  • The terminal state, when the job finishes.

Your internal operation identifier and the idempotency key can be different. The operation identifier helps your own system track business intent. The idempotency key is the value you send to the API to make a replay safe.

For example, a worker can create its operation state before it sends a people search:

operation_id: outbound-prospecting-run
idempotency_key: stable-key-for-this-operation
endpoint: /api/v1/people/search
state: submitting

Then it sends the request with the key:

curl -X POST https://enrichments.io/api/v1/people/search \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: stable-key-for-this-operation" \
  -d '{
    "query": "engineering leaders at software companies",
    "fields": ["linkedin_url", "title", "seniority", "company", "company_domain"]
  }'

Your API client still needs to authenticate with its API key. Keep authentication handling separate from idempotency handling. The key question for retry safety is whether the retried request carries the same Idempotency-Key as the original request.

Persist the idempotency key before making the network call. If you generate it in memory and crash before saving it, your recovery worker may create a new key and accidentally start duplicate work.

Retain the key with the operation state long enough to recover from delayed failures, worker restarts, and operator investigation. Do not discard it immediately after a timeout.

Recover when the first response is missing or ambiguous

When the initial response is missing, retry the original request with the same Idempotency-Key, then inspect the returned job envelope.

Do not build a second request from memory. Reuse the stored endpoint, request body, requested fields, and idempotency key. This gives your retry the best chance of representing the same logical operation.

Every search, enrichment, and job poll response uses this envelope:

{
  "job": {
    "object": "job",
    "id": "job identifier",
    "type": "people_search",
    "status": "queued",
    "fields": ["linkedin_url", "title"],
    "requestedCount": 0,
    "resultCount": 0,
    "creditsUsed": 0,
    "error": null,
    "createdAt": "timestamp",
    "startedAt": null,
    "finishedAt": null,
    "url": "job URL"
  },
  "results": null,
  "page": null,
  "estimatedCredits": 0
}

Read the job identity from job.id. Read state from job.status.

Do not look for a top-level id. Do not look for a top-level status. Neither is part of the response envelope.

A job status can be:

  • queued
  • running
  • succeeded
  • empty
  • failed
  • cancelled

While a job is queued or running, results can be null. That is normal. It does not mean the request was rejected, and it does not justify starting a replacement request.

When results are present, each result is a wrapper rather than a person or company row directly:

{
  "position": 0,
  "status": "succeeded",
  "data": {
    "title": "Engineering Manager",
    "company": "Example Company"
  },
  "evidence": null,
  "error": null
}

The person or company record is in data. The item wrapper also has its own status:

  • pending
  • running
  • succeeded
  • not_found
  • failed

This distinction matters in API error recovery. A job can finish while some individual items are not_found or failed. That is not the same as a job-level failure.

Design around inline and queued enrichment responses

Inspect job.status before you decide whether to poll, because some requests complete inline.

A search asking for a small result set can return inline. An enrichment with a small set of input rows can also return inline. In those cases, the API responds with populated results, and job.status is already terminal.

Other requests are queued:

  • Larger searches.
  • Larger enrichment calls.
  • Any request asking for the email field.
  • Requests sent with async: true.
  • Requests sent with Prefer: respond-async.

This means your client should not assume that every POST starts a job it must poll. It should also not assume that a successful HTTP response contains final data.

Use the response envelope as the source of truth:

  1. Send the POST with the persisted Idempotency-Key.
  2. Read job.id and job.status.
  3. If the job is terminal, process results immediately when present.
  4. If the job is queued or running, poll GET /api/v1/jobs/{id}.
  5. Stop polling when the status is succeeded, empty, failed, or cancelled.

Job polling is free. It is the correct way to observe a queued operation rather than resubmitting it.

For a completed job with more results than fit in one response, use the opaque forward-only cursor returned in page. Treat that cursor as an implementation detail owned by the API. Store and send it exactly as returned. Do not derive offsets, reconstruct cursors, or invent a cursor field name.

Email requests deserve extra care. A work email address is only returned when you request email by name. It is billed per address actually found, and deliverability checking is included with that lookup. Because an email request is queued, your client needs to handle job polling even when the rest of the request is small.

Avoid retry patterns that still create duplicate work

A retry without the original idempotency key is a new request, even if its JSON body is identical.

This is the most common failure mode in API retry handling. A client catches a timeout, regenerates request headers, and sends the same body again. If the regenerated headers contain a fresh Idempotency-Key, the API sees a new operation.

Avoid these patterns:

  • Generating a fresh idempotency key for every attempt.
  • Generating the key only after a timeout occurs.
  • Retrying from a background worker that cannot access the original operation state.
  • Resubmitting a request because results is null while the job is queued.
  • Treating an HTTP success response alone as proof that the intended request body and fields were accepted.
  • Reconstructing a request manually during incident response without checking the original request fingerprint.

HTTP success tells you that the API responded. It does not replace validation of the returned job envelope. Your client should compare the returned job metadata with the operation it expected to run:

  • Confirm the endpoint and job type.
  • Confirm the requested fields.
  • Record job.id.
  • Record job.status.
  • Store any job-level error.
  • Process result wrappers and their item statuses separately.

Log the request fingerprint, idempotency key, returned job ID, and terminal state together. This gives you a single audit trail for an operation.

If an operator later asks why two jobs exist, you can determine whether they came from separate business operations or from a retry that lost its original key.

Build an operational runbook for failed enrichment requests

Your runbook should preserve the original operation and classify failures before it creates any new work.

For a timeout or missing initial response:

  1. Find the saved operation record.
  2. Retrieve the original endpoint, body, fields, and Idempotency-Key.
  3. Retry the original request with that same key.
  4. Read job.id and job.status from the returned envelope.
  5. Poll only if the job remains queued or running.

For a failed job:

  1. Inspect job.error.
  2. Preserve the job ID and idempotency key.
  3. Do not treat a new POST as a continuation of the failed job.
  4. Decide whether a new business operation is appropriate, then create a new operation record only if you intentionally want new work.

For partial item outcomes, separate the job state from item states. A terminal job may contain wrappers with succeeded, not_found, or failed statuses. A not_found result is not proof that your client should immediately repeat the whole batch. A repeated live enrichment can legitimately return different data later, but that is a new decision, not automatic recovery.

For a client-side crash:

  1. Restore the persisted operation state.
  2. Use the existing idempotency key.
  3. Recover the job by replaying the original request if needed.
  4. Continue job polling from the returned job.id.
  5. Persist the terminal job state before downstream processing.

The append-only usage ledger supports reconciliation when an operator needs to investigate a disputed run. Charges are written line by line. That gives you a record to compare with the operation log, returned job metadata, and delivered results.

Your implementation checks should be simple and explicit:

  • API clients: persist an idempotency key before sending a POST.
  • Background workers: reuse the original key after restarts.
  • Job processors: branch on job.status, not on assumptions about request size.
  • Result consumers: read records from each wrapper's data field.
  • Manual recovery tools: display the request fingerprint, idempotency key, job ID, job status, and usage-ledger entries together.
  • Retry policies: retry ambiguous requests with the same key; create a new key only for a deliberately new operation.

For API schemas and route details, use the generated OpenAPI document at /api/v1/openapi or review the docs. Pricing and credit allowances are available on pricing.

Frequently asked questions

What is an idempotency key in an enrichment API?
An idempotency key identifies a retry as a replay of an operation already started, allowing the API to return the existing job instead of creating another one.
Which enrichment API requests need an idempotency key?
Use an idempotency key for POST requests that start people or company searches and enrichments. Job polling with GET reads an existing job and does not start new work.
What should I do if an enrichment API response is missing or ambiguous?
Retry the original endpoint and request body with the same persisted Idempotency-Key, then inspect job.id and job.status in the response envelope.
Where can I find the job ID and status in an API response?
Read the job identity from job.id and the state from job.status. The response envelope does not use top-level id or status fields.
When should I poll an enrichment job?
Poll GET /api/v1/jobs/{id} only when job.status is queued or running. Process results immediately when the returned job is already in a terminal state.

Keep reading

Put this into practice

Enrichments resolves people and companies from a REST API, an MCP server, the chat agent or a CSV upload, checks every email address it finds, and bills you only for the data that comes back.

Start enriching for free