# Guide to FullEnrich Webhooks: Features and Best Practices

FullEnrich webhooks deliver the results of contact enrichment. FullEnrich finds work emails, personal emails and phone numbers for the contacts you send it, and because that takes 30 to 90 seconds per contact, the API returns straight away with an ID and pushes the results to your URL when they are ready. If you enrich leads from a CRM or your own product, webhooks are how those results come back.

This guide covers how FullEnrich callbacks are requested and signed, what their bodies contain, the retry behaviour you need to plan around, and the practices that keep a FullEnrich receiver reliable.

## What are FullEnrich webhooks?

FullEnrich doesn't have webhook subscriptions or event types. Instead, you put callback URLs in each enrichment or reverse email lookup request:

* `webhook_url` receives one `POST` when the whole batch is finished, runs out of credits, or is cancelled.
* `webhook_events.contact_finished` receives one `POST` for each contact as soon as it is enriched, without waiting for the rest of the batch.

You can set either or both. The body is the same object you would get from the Get Enrichment Result endpoint.

Every callback has an `X-Signature-SHA1` header: the lowercase hex HMAC-SHA1 of the raw request body. The key is your FullEnrich API key, the same one you use to call the API. There is no separate webhook secret.

## FullEnrich webhook features

| Feature | Details |
| --- | --- |
| Configuration | Per request: `webhook_url` and `webhook_events.contact_finished` in the request body |
| Callbacks | Batch completion, and optionally one per finished contact |
| Event types | None. The body's `status` gives the job state |
| Signature header | `X-Signature-SHA1` |
| Scheme | HMAC-SHA1, lowercase hex, no prefix |
| Signed content | The raw request body, nothing else |
| Key | Your FullEnrich API key |
| Correlation | `custom` object on each contact, echoed back; the job `id` |
| Success response | Any `2xx` |
| Retries | Every minute, up to 5 times |
| Delivery logs | FullEnrich's team can check them on request |

FullEnrich doesn't state a response timeout or publish source IPs.

## Payloads and statuses

A callback body looks like the Get Enrichment Result response: the job's `id` and `name`, its `status`, the credit `cost`, and a `data` array of contacts. The `status` tells you which callback you are looking at:

| `status` | Callback | Meaning |
| --- | --- | --- |
| `FINISHED` | `webhook_url` | The whole batch completed |
| `CANCELED` | `webhook_url` | The batch was cancelled |
| `CREDITS_INSUFFICIENT` | `webhook_url` | The batch ran out of credits |
| `IN_PROGRESS` | `contact_finished` | One contact is done, and the batch is still running |

A per-contact callback carries exactly one contact in `data`. Here is the shape from FullEnrich's example, trimmed:

```json
{
  "id": "<string>",
  "name": "<string>",
  "status": "IN_PROGRESS",
  "cost": { "credits": 10 },
  "data": [
    {
      "input": {
        "first_name": "<string>",
        "last_name": "<string>",
        "company_domain": "<string>"
      },
      "custom": {},
      "contact_info": {
        "most_probable_work_email": { "email": "<string>", "status": "DELIVERABLE" },
        "most_probable_phone": {
          "number": "+1 415-555-0100",
          "region": "US",
          "line_type": "MOBILE"
        },
        "work_emails": [{ "email": "<string>", "status": "DELIVERABLE" }],
        "personal_emails": [{ "email": "<string>", "status": "DELIVERABLE" }],
        "phones": []
      },
      "profile": { "full_name": "<string>", "headline": "<string>" }
    }
  ]
}

```

`custom` is how you match results to your own records. Put identifiers such as a user ID or CRM contact ID in each contact's `custom` object when you start the enrichment, and FullEnrich returns them "exactly as provided". The values must be strings: a number makes the request fail.

## Setting up FullEnrich webhooks

1. Copy your API key from the FullEnrich API dashboard and store it server-side, for example as `FULLENRICH_API_KEY`. It authenticates your API calls and verifies the callbacks.
2. Start an enrichment with your callback URLs in the body:

```bash
curl -X POST "https://app.fullenrich.com/api/v2/contact/enrich/bulk" \
  -H "Authorization: Bearer $FULLENRICH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Sales Operations in London",
    "webhook_url": "https://example.com/webhooks/fullenrich/batch",
    "webhook_events": { "contact_finished": "https://example.com/webhooks/fullenrich/contact" },
    "data": [
      {
        "first_name": "John",
        "last_name": "Snow",
        "domain": "example.com",
        "enrich_fields": ["contact.work_emails", "contact.phones"],
        "custom": { "crm_contact_id": "abc-123" }
      }
    ]
  }'

```

1. Store the `id` the API returns, so you can match it to the callback.

Reverse email lookup takes the same `webhook_url` and `webhook_events` parameters.

If you receive FullEnrich webhooks through Hookdeck, create a source with the FullEnrich type and paste your FullEnrich API key as the secret, since FullEnrich signs webhooks with that key. Use the source URL, or paths on it, as your callback URLs.

## Securing FullEnrich webhooks

Read `X-Signature-SHA1`, compute an HMAC-SHA1 of the raw request body with your API key as the key, hex-encode it in lowercase, and compare in constant time. The algorithm is SHA-1, not SHA-256.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifyFullEnrichSignature(rawBody, signatureHeader, apiKey) {
  if (!signatureHeader || !apiKey) return false;
  const expected = crypto.createHmac('sha1', apiKey).update(rawBody).digest('hex');
  const received = Buffer.from(signatureHeader.trim().toLowerCase(), 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

const app = express();

app.post('/webhooks/fullenrich/:callback', express.raw({ type: '*/*' }), (req, res) => {
  const apiKey = process.env.FULLENRICH_API_KEY;
  if (!apiKey) return res.sendStatus(500); // fail closed

  if (!verifyFullEnrichSignature(req.body, req.get('x-signature-sha1'), apiKey)) {
    return res.sendStatus(401);
  }

  const result = JSON.parse(req.body.toString('utf8'));
  // Branch on result.status; match contacts to your records with data[].custom.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac

def verify_fullenrich_signature(raw_body: bytes, signature_header, api_key: str) -> bool:
    if not signature_header or not api_key:
        return False
    expected = hmac.new(api_key.encode("utf-8"), raw_body, hashlib.sha1).hexdigest()
    return hmac.compare_digest(
        signature_header.strip().lower().encode("utf-8"), expected.encode("utf-8")
    )

# In FastAPI: raw_body = await request.body()
# verify_fullenrich_signature(raw_body, request.headers.get("x-signature-sha1"),
#                             os.environ["FULLENRICH_API_KEY"])

```

FullEnrich's Node sample mounts `express.raw({ type: "application/json" })` and calls `crypto.timingSafeEqual` without a length check, which throws if the header is the wrong length. Using `*/*` keeps the body raw whatever the content type, and the length check turns a malformed header into a clean `401`.

There is no timestamp in the request, so nothing tells you how old a callback is.

> **Make FullEnrich webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `X-Signature-SHA1` header, stores every enrichment result as it arrives, and delivers it to your service with retries you control.

## FullEnrich webhook limitations and pain points

### Five minutes of retries

**The Problem:** A short outage or a slow deploy loses enrichment results you have already paid for.

**Why It Happens:** FullEnrich retries a failed callback every minute, up to 5 times. If your endpoint is down for longer than that, the callback is gone.

**Workarounds:**

* Store the job `id` when you start an enrichment, and fall back to the Get Enrichment Result endpoint for any job whose batch callback never arrived.
* If you need to poll, do it no more than once every 5 to 10 minutes, as FullEnrich asks.
* Ask FullEnrich to check its delivery logs if a specific result is missing.

**How Hookdeck Can Help:** Hookdeck accepts each callback as soon as FullEnrich sends it and stores it, then delivers it to your service with [retries](/docs/retries) you configure, so your downtime no longer has to fit inside FullEnrich's five minutes.

### The API key is the signing key

**The Problem:** Every service that verifies callbacks holds a key that can also spend your credits, and regenerating it breaks verification.

**Why It Happens:** FullEnrich has no separate webhook secret. The key that calls the API is the key that signs the callbacks.

**Workarounds:**

* Keep verification in one place, so only one service needs the key.
* When you regenerate the key, update every verifier at the same time. Any verifier still holding the old key fails every callback signed with the new one.

**How Hookdeck Can Help:** With Hookdeck in front, the API key is stored on one Hookdeck source and your services behind it don't need it at all. Hookdeck answers FullEnrich with a `200` either way, then records a request that fails the check as failed verification, so it never reaches your destination.

### No event type to route on

**The Problem:** One handler receives both batch and per-contact callbacks and treats a single contact as the whole batch.

**Why It Happens:** FullEnrich sends no event name. The only differences are the URL each callback was sent to and the `status` field.

**Workarounds:**

* Use different paths for `webhook_url` and `contact_finished`, as in the example above.
* Treat `IN_PROGRESS` as a single contact and the other statuses as the end of the batch.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/filters) on the path or on `status` can send per-contact results and batch completions to different destinations.

### Duplicate results with both callbacks

**The Problem:** Each contact is written twice.

**Why It Happens:** When you set both callbacks, each contact arrives once in its own `contact_finished` callback and again in the batch callback's `data`.

**Workarounds:**

* Write contacts from one callback only, and use the other for job status.
* Or upsert on your `custom` identifier, so the second write changes nothing.

**How Hookdeck Can Help:** Hookdeck stores every callback, so you can compare what arrived per contact with the batch result when you're checking a job.

## Best practices

### Verify against the raw body

FullEnrich warns that re-serialised JSON produces a different hash. In Express, use `express.raw({ type: '*/*' })`. In Next.js, use `await request.text()`. In FastAPI, use `await request.body()`.

### Always send `custom` identifiers

Without `custom`, you have to match results to your records by name and domain. Add your own string IDs to every contact you submit.

### Handle the failure statuses

`CREDITS_INSUFFICIENT` and `CANCELED` are batch callbacks too. Mark the job as incomplete, alert someone about credits, and don't treat missing contacts as "not found".

### Make handlers idempotent

Retries resend the same body. Key batch callbacks on the job `id` and contact results on your `custom` identifier. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Acknowledge fast and process asynchronously

With only five retries a minute apart, a slow handler is risky. Verify, store the body, return `200`, and update your CRM afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

FullEnrich pushes enrichment results to URLs you set on each request and signs them with `X-Signature-SHA1`, a lowercase hex HMAC-SHA1 of the raw body keyed with your API key. Route on the callback URL and `status`, match contacts with `custom`, and keep a fallback to the results endpoint, because FullEnrich stops retrying after five minutes.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, verifies every FullEnrich callback, and holds each result until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle FullEnrich webhooks reliably in minutes.