> ## Documentation Index
> Fetch the complete documentation index at: https://docs.fullenrich.com/llms.txt
> Use this file to discover all available pages before exploring further.

# How Webhooks Work

> The recommended way to receive enrichment results

<Info>
  If you're using **Zapier, Make, Clay, or n8n**, webhooks are handled automatically by these platforms. You can skip this section.
</Info>

The Enrich API and Reverse Email Lookup are **asynchronous**. This means when you start an operation, the API returns immediately with an ID, and results are delivered later via webhook.

<img src="https://mintcdn.com/fullenrich/QlQ00joQ41SH1_-N/images/async-schema.svg?fit=max&auto=format&n=QlQ00joQ41SH1_-N&q=85&s=8bcb5e361d145227592369a31b598a1f" alt="Async Schema" width="2125" height="593" data-path="images/async-schema.svg" />

## Why Webhooks?

Webhooks are notifications sent directly to your server when results are ready. Instead of you having to check for results, we push them to you immediately.

**Benefits:**

* **Fastest method** to receive results
* No need to keep a connection open
* No HTTP timeouts or retries to manage
* Simpler and more reliable than polling

## How It Works

1. When you start an enrichment or reverse lookup, include a `webhook_url` in your request
2. We process your contacts (typically 30-90 seconds per contact)
3. When done, we POST the results directly to your webhook URL

The content sent to your webhook is the same as what you'd get from the GET endpoint.

## Webhook Parameters

### `webhook_url` — Batch Completion

Your webhook URL receives a POST request when the **entire batch** is finished, lacks credits, or is canceled.

```json theme={null}
{
  "name": "My Enrichment",
  "webhook_url": "https://your-server.com/webhook",
  "data": [...]
}
```

## Verifying Webhook Authenticity

Every webhook we send is signed so you can confirm it genuinely comes from FullEnrich and wasn't tampered with in transit. We include an `X-Signature-SHA1` header on each request — an HMAC-SHA1 signature of the request body, using your FullEnrich API key as the secret.

To verify a webhook:

1. **Get the signature** — read the `X-Signature-SHA1` value from the request headers.
2. **Read the raw body** — use the raw request body bytes exactly as received (UTF-8). Don't re-serialize the parsed JSON, as key ordering or whitespace changes will break the check.
3. **Compute the HMAC** — create an HMAC-SHA1 hash of the body using your API key as the secret, and hex-encode the result (lowercase).
4. **Compare** — if your computed hash matches the `X-Signature-SHA1` header, the request is authentic. Use a constant-time comparison to avoid timing attacks.

<Warning>
  Verify the signature against the **raw request body**, before any JSON parsing or reformatting. Signing a re-serialized payload will produce a different hash and fail the check.
</Warning>

<CodeGroup>
  ```javascript Node.js theme={null}
  import crypto from "crypto";

  // Use the raw request body (e.g. via express.raw({ type: "application/json" }))
  function isValidWebhook(rawBody, signatureHeader, apiKey) {
    const expected = crypto
      .createHmac("sha1", apiKey)
      .update(rawBody, "utf8")
      .digest("hex");

    return crypto.timingSafeEqual(
      Buffer.from(expected),
      Buffer.from(signatureHeader)
    );
  }

  app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
    const signature = req.header("X-Signature-SHA1");

    if (!isValidWebhook(req.body, signature, process.env.FULLENRICH_API_KEY)) {
      return res.status(401).send("Invalid signature");
    }

    const payload = JSON.parse(req.body);
    // ... handle the verified payload
    res.sendStatus(200);
  });
  ```

  ```python Python theme={null}
  import hashlib
  import hmac

  def is_valid_webhook(raw_body: bytes, signature_header: str, api_key: str) -> bool:
      expected = hmac.new(
          api_key.encode("utf-8"),
          raw_body,
          hashlib.sha1,
      ).hexdigest()

      return hmac.compare_digest(expected, signature_header)

  # Flask example
  @app.route("/webhook", methods=["POST"])
  def webhook():
      signature = request.headers.get("X-Signature-SHA1", "")
      if not is_valid_webhook(request.get_data(), signature, API_KEY):
          return "Invalid signature", 401

      payload = request.get_json()
      # ... handle the verified payload
      return "", 200
  ```

  ```php PHP theme={null}
  <?php
  $rawBody   = file_get_contents("php://input");
  $signature = $_SERVER["HTTP_X_SIGNATURE_SHA1"] ?? "";

  $expected = hash_hmac("sha1", $rawBody, $apiKey);

  if (!hash_equals($expected, $signature)) {
      http_response_code(401);
      exit("Invalid signature");
  }

  $payload = json_decode($rawBody, true);
  // ... handle the verified payload
  http_response_code(200);
  ```

  ```go Go theme={null}
  package main

  import (
  	"crypto/hmac"
  	"crypto/sha1"
  	"encoding/hex"
  	"io"
  	"net/http"
  )

  func isValidWebhook(rawBody []byte, signatureHeader, apiKey string) bool {
  	mac := hmac.New(sha1.New, []byte(apiKey))
  	mac.Write(rawBody)
  	expected := hex.EncodeToString(mac.Sum(nil))

  	return hmac.Equal([]byte(expected), []byte(signatureHeader))
  }

  func webhookHandler(apiKey string) http.HandlerFunc {
  	return func(w http.ResponseWriter, r *http.Request) {
  		rawBody, err := io.ReadAll(r.Body)
  		if err != nil {
  			http.Error(w, "Cannot read body", http.StatusBadRequest)
  			return
  		}

  		signature := r.Header.Get("X-Signature-SHA1")
  		if !isValidWebhook(rawBody, signature, apiKey) {
  			http.Error(w, "Invalid signature", http.StatusUnauthorized)
  			return
  		}

  		// ... handle the verified payload (parse rawBody as JSON)
  		w.WriteHeader(http.StatusOK)
  	}
  }
  ```
</CodeGroup>

## Reliability & Retries

Webhooks are reliable by design. If we can't deliver your result (for example, receiving a non-2xx status code), we'll automatically **retry every minute, up to 5 times**.

For most use cases, you can treat webhooks as "guaranteed delivery." If there's ever a question about a specific result or missed event, our team can check our delivery logs on request.

## Tracking Requests with `custom`

You can easily keep track of which result belongs to which user or request by using the `custom` parameter. Include any identifier you need (User ID, CRM contact ID, etc.), and it will be returned exactly as provided in the webhook payload.

```json theme={null}
{
  "data": [
    {
      "first_name": "John",
      "last_name": "Doe",
      "domain": "example.com",
      "custom": {
        "user_id": "12584",
        "crm_contact_id": "abc-123"
      }
    }
  ]
}
```

<Note>
  Custom field values must be strings. Numbers will return an error.
</Note>

## Testing Webhooks

<Tip>
  A quick way to test webhooks and see the payload structure is to use [**webhook.site**](https://webhook.site) — a free service that gives you a temporary URL to receive and inspect webhook requests.
</Tip>

## Polling (Not Recommended)

Polling means repeatedly calling the GET endpoint to check if the operation is finished. This is **not recommended** because:

* It consumes your rate limit quota
* Results come slower than with webhooks
* More complex to implement reliably

If you must use polling, don't poll more than once every 5-10 minutes. Never poll every few seconds.

## Real-Time Webhook Events

### `webhook_events.contact_finished`

If you need results as fast as possible, use this parameter. It fires a webhook **immediately after each individual contact** is enriched, without waiting for the entire batch to complete.

This is perfect for real-time integrations where you want to process results as they come in.

```json theme={null}
{
  "name": "My Enrichment",
  "webhook_url": "https://your-server.com/webhook/batch-complete",
  "webhook_events": {
    "contact_finished": "https://your-server.com/webhook/single-contact"
  },
  "data": [...]
}
```

<Tip>
  You can use both `webhook_url` and `webhook_events.contact_finished` together. You'll receive a webhook for each contact as it completes, plus a final webhook when the entire batch is done.
</Tip>

### Event Payload

The webhook receives the enriched profile immediately after processing. The payload follows the standard response format with a single contact in the `data` array:

```json theme={null}
{
  "id": "<string>",
  "name": "<string>",
  "status": "IN_PROGRESS",
  "cost": {
    "credits": 10
  },
  "data": [
    {
      "input": {
        "first_name": "<string>",
        "last_name": "<string>",
        "full_name": "<string>",
        "company_domain": "<string>",
        "company_name": "<string>",
        "professional_network_url": "<string>"
      },
      "custom": {},
      "contact_info": {
        "most_probable_work_email": {
          "email": "<string>",
          "status": "DELIVERABLE"
        },
        "most_probable_personal_email": {
          "email": "<string>",
          "status": "DELIVERABLE"
        },
        "most_probable_phone": {
          "number": "<string>",
          "region": "<string>"
        },
        "work_emails": [
          {
            "email": "<string>",
            "status": "DELIVERABLE"
          }
        ],
        "personal_emails": [
          {
            "email": "<string>",
            "status": "DELIVERABLE"
          }
        ],
        "phones": [
          {
            "number": "<string>",
            "region": "<string>"
          }
        ]
      },
      "profile": {
        "id": "<string>",
        "full_name": "<string>",
        "first_name": "<string>",
        "last_name": "<string>",
        "location": {
          "country": "<string>",
          "country_code": "<string>",
          "city": "<string>",
          "region": "<string>"
        },
        "social_profiles": { ... },
        "educations": [{ ... }],
        "languages": [{ ... }],
        "skills": ["<string>"],
        "employment": { ... }
      }
    }
  ]
}
```

<Note>
  The `status` will be `IN_PROGRESS` since individual contacts are sent as they complete, before the entire batch finishes.
</Note>
