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

# How Webhooks Work

> Receive real-time webhook notifications when Enrow searches and verifications complete, instead of polling the API

export const PollingVsWebhook = () => {
  const [pollingStep, setPollingStep] = useState(0);
  const [webhookPhase, setWebhookPhase] = useState("idle");
  useEffect(() => {
    const timers = [];
    const addTimer = (fn, ms) => {
      const id = setTimeout(fn, ms);
      timers.push(id);
    };
    const runCycle = () => {
      setPollingStep(0);
      for (let i = 0; i < 5; i++) {
        addTimer(() => setPollingStep(i + 1), (i + 1) * 900);
      }
      addTimer(() => setPollingStep(0), 6500);
      setWebhookPhase("idle");
      addTimer(() => setWebhookPhase("sent"), 400);
      addTimer(() => setWebhookPhase("processing"), 1200);
      addTimer(() => setWebhookPhase("callback"), 2800);
      addTimer(() => setWebhookPhase("idle"), 6500);
    };
    runCycle();
    const loop = setInterval(runCycle, 7200);
    return () => {
      clearInterval(loop);
      timers.forEach(clearTimeout);
    };
  }, []);
  const POLLING_STEPS = [{
    response: "pending",
    success: false
  }, {
    response: "pending",
    success: false
  }, {
    response: "pending",
    success: false
  }, {
    response: "pending",
    success: false
  }, {
    response: "complete",
    success: true
  }];
  const svgIcon = ({className, circle, paths}) => <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
      {circle ? <circle cx={circle.cx} cy={circle.cy} r={circle.r} /> : null}
      {paths.map((d, i) => <path key={i} d={d} />)}
    </svg>;
  const icons = {
    arrowRight: className => svgIcon({
      className,
      paths: ["M5 12h14", "m12 5 7 7-7 7"]
    }),
    bell: className => svgIcon({
      className,
      paths: ["M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9", "M10.3 21a1.94 1.94 0 0 0 3.4 0"]
    }),
    checkCircle: className => svgIcon({
      className,
      circle: {
        cx: 12,
        cy: 12,
        r: 10
      },
      paths: ["m9 12 2 2 4-4"]
    }),
    xCircle: className => svgIcon({
      className,
      circle: {
        cx: 12,
        cy: 12,
        r: 10
      },
      paths: ["m15 9-6 6", "m9 9 6 6"]
    }),
    loader2: className => svgIcon({
      className,
      paths: ["M21 12a9 9 0 1 1-6.219-8.56"]
    })
  };
  return <div className="my-6 grid grid-cols-1 sm:grid-cols-2 gap-4 not-prose">
      {}
      <div className="rounded-xl border border-rose-500/20 bg-zinc-50/50 dark:bg-zinc-900/40 p-4">
        <div className="flex items-start gap-2.5 mb-3">
          <div className="size-8 rounded-lg bg-rose-500/10 flex items-center justify-center shrink-0">
            {icons.xCircle("size-4 text-rose-600 dark:text-rose-400")}
          </div>
          <div className="min-w-0">
            <h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 m-0 leading-8">
              Polling
            </h3>
            <div className="text-[11px] text-zinc-500 dark:text-zinc-400" style={{
    lineHeight: 1.5
  }}>
              Your app repeatedly asks "is it done yet?" until the result is ready.
            </div>
          </div>
        </div>

        <div className="space-y-1.5 mb-3 min-h-[140px]">
          {POLLING_STEPS.map((step, i) => <div key={i} className="flex items-center gap-2 text-xs font-mono transition-all duration-300" style={{
    opacity: i < pollingStep ? 1 : 0.2,
    transform: i < pollingStep ? "translateX(0)" : "translateX(-4px)"
  }}>
              <span className="text-zinc-500 dark:text-zinc-400 w-20 shrink-0">
                GET /status
              </span>
              {icons.arrowRight("size-3 text-zinc-400/70 shrink-0")}
              {step.success ? <span className="flex items-center gap-1 text-emerald-600 dark:text-emerald-400">
                  {icons.checkCircle("size-3")}
                  {step.response}
                </span> : <span className="flex items-center gap-1 text-rose-600 dark:text-rose-400">
                  {icons.xCircle("size-3")}
                  {step.response}
                </span>}
            </div>)}
        </div>

        <div className="border-t border-zinc-200 dark:border-zinc-800 pt-3 space-y-1">
          {["5+ requests per result", "Wasted bandwidth", "Delayed results"].map(stat => <div key={stat} className="flex items-center gap-2 text-xs text-zinc-500 dark:text-zinc-400">
              {icons.xCircle("size-3 text-rose-500/60 shrink-0")}
              {stat}
            </div>)}
        </div>
      </div>

      {}
      <div className="rounded-xl border border-emerald-500/20 bg-zinc-50/50 dark:bg-zinc-900/40 p-4">
        <div className="flex items-start gap-2.5 mb-3">
          <div className="size-8 rounded-lg bg-emerald-500/10 flex items-center justify-center shrink-0">
            {icons.checkCircle("size-4 text-emerald-600 dark:text-emerald-400")}
          </div>
          <div className="min-w-0">
            <h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 m-0 leading-8">
              Webhook
            </h3>
            <div className="text-[11px] text-zinc-500 dark:text-zinc-400" style={{
    lineHeight: 1.5
  }}>
              Enrow notifies your server the instant the result is ready.
            </div>
          </div>
        </div>

        <div className="space-y-1.5 mb-3 min-h-[140px]">
          <div className="flex items-center justify-between text-xs font-mono transition-all duration-500" style={{
    opacity: webhookPhase !== "idle" ? 1 : 0.2,
    transform: webhookPhase !== "idle" ? "translateX(0)" : "translateX(-4px)"
  }}>
            <span className="text-emerald-700 dark:text-emerald-400 font-semibold whitespace-nowrap">
              POST /email/find/single
            </span>
            <span className="text-zinc-500 dark:text-zinc-400 shrink-0 ml-2">
              {webhookPhase === "sent" && "sent ✓"}
              {webhookPhase === "processing" && <span className="inline-flex items-center gap-1">
                  {icons.loader2("size-3 animate-spin")}
                  Processing...
                </span>}
              {webhookPhase === "callback" && <span className="text-emerald-600 dark:text-emerald-400">✓ done</span>}
            </span>
          </div>

          {webhookPhase === "callback" && <div className="mt-3 rounded-md border border-emerald-400/30 bg-emerald-500/5 px-3 py-2 font-mono text-xs">
              <div className="flex items-center gap-1.5 mb-1">
                {icons.bell("size-3 text-emerald-500")}
                <span className="text-emerald-600 dark:text-emerald-400 font-semibold text-[10px] uppercase tracking-wider">
                  Webhook callback
                </span>
              </div>
              <span className="text-zinc-600 dark:text-zinc-400">
                {'{"email":"found","status":"valid"}'}
              </span>
            </div>}
        </div>

        <div className="border-t border-zinc-200 dark:border-zinc-800 pt-3 space-y-1">
          {["1 request, 1 callback", "Zero waste", "Real-time delivery"].map(stat => <div key={stat} className="flex items-center gap-2 text-xs text-zinc-500 dark:text-zinc-400">
              {icons.checkCircle("size-3 text-emerald-500/70 shrink-0")}
              {stat}
            </div>)}
        </div>
      </div>
    </div>;
};

## Why use webhooks?

Webhooks let results come to you instead of you repeatedly asking for them. Rather than polling a GET endpoint until a search finishes, Enrow sends each result to your server the moment it's ready — saving requests, reducing latency, and keeping your code simple.

**Stop wasting requests — let results come to you.**

<PollingVsWebhook />

Because every Enrow endpoint is **asynchronous**, webhooks are the recommended way to receive results across [Email Finder](/api-reference/email-finder/find-single), [Email Verifier](/api-reference/email-verifier/verify-single), and [Phone Finder](/api-reference/phone/find-single). Webhooks also bypass [rate limits](/rate-limits) entirely, since Enrow calls you rather than the other way around.

## How does a webhook flow work?

A webhook flow turns a single search request into an automatic delivery. You tell Enrow where to send results, and Enrow does the rest:

1. You **POST** a search request with a `webhook` URL in the `settings`
2. Enrow returns a search ID immediately
3. Enrow processes the search in the background
4. When complete, Enrow **POSTs** results to your webhook URL

## How do I set up a webhook?

You can register a webhook in two ways, depending on whether you want it for one search or every search:

1. **Per-request**: Include a `webhook` URL in the `settings` object of any API call
2. **Global**: Configure a default webhook from the [integrations page](https://app.enrow.io/integrations) on the dashboard

```json theme={null}
{
  "fullname": "Dwight Schrute",
  "company_domain": "dundermifflin.com",
  "settings": {
    "webhook": "https://your-app.com/webhooks/enrow"
  }
}
```

<Warning>
  Your webhook URL must be a valid **HTTPS** endpoint that returns a `200` status code.
</Warning>

## Which events trigger a webhook call?

Six types of events can trigger a webhook call, one per endpoint and search type:

| Event                          | Description                              |
| ------------------------------ | ---------------------------------------- |
| `single_search_finished`       | A single email search has finished       |
| `bulk_search_finished`         | A bulk email search has finished         |
| `verification_finished`        | A single email verification has finished |
| `bulk_verification_finished`   | A bulk email verification has finished   |
| `single_phone_search_finished` | A single phone search has finished       |
| `bulk_phone_search_finished`   | A bulk phone search has finished         |

## What does a webhook payload look like?

The webhook payload depends on the endpoint and whether the search was single or bulk. Single searches deliver the full result directly, while bulk searches deliver a completion notification that you follow up with a GET request.

### Email Finder — Single

For single searches, you receive the **full result directly** in the webhook notification. This removes the need to perform a GET request.

```json theme={null}
{
  "event": "single_search_finished",
  "id": "910f3e13-b2bf-442d-ab0b-4cf44dfrij84fjrt",
  "credits": {
    "cost": 1
  },
  "result": {
    "email": "dwight.schrute@dundermifflin.com",
    "qualification": "valid",
    "custom": "your_custom_data",
    "info": {
      "company_domain": "dundermifflin.com",
      "fullname": "Dwight Schrute",
      "firstname": "Dwight",
      "lastname": "Schrute"
    }
  }
}
```

### Email Finder — Bulk

For bulk searches, you receive a notification that the batch is finished. Then call the [GET /email/find/bulk](/api-reference/email-finder/get-bulk-results) endpoint with the `id` to retrieve results.

```json theme={null}
{
  "event": "bulk_search_finished",
  "id": "910f3e13-b2bf-442d-ab0b-4cf44dfrij84fjrt",
  "credits": {
    "cost": 2284
  }
}
```

### Email Verifier — Single

The full result is included directly — no GET request needed.

```json theme={null}
{
  "event": "verification_finished",
  "id": "910f3e13-b2bf-442d-ab0b-4cf44dfrij84fjrt",
  "email": "pam.beesly@dundermifflin.com",
  "qualification": "valid",
  "custom": "your_custom_data"
}
```

### Email Verifier — Bulk

This is a notification only. Call [GET /email/verify/bulk](/api-reference/email-verifier/get-bulk-verifications) with the `id` to retrieve results.

```json theme={null}
{
  "event": "bulk_verification_finished",
  "id": "910f3e13-b2bf-442d-ab0b-4cf44dfrij84fjrt",
  "credits": {
    "cost": 386.25
  }
}
```

### Phone Finder — Single

The full result is included directly — no GET request needed.

```json theme={null}
{
  "event": "single_phone_search_finished",
  "id": "910f3e13-b2bf-442d-ab0b-4cf44dfrij84fjrt",
  "credits": {
    "cost": 50
  },
  "result": {
    "number": "+15705551234",
    "params": {
      "linkedin_url": "https://www.linkedin.com/in/michael-scott"
    },
    "qualification": "found"
  }
}
```

### Phone Finder — Bulk

This is a notification only. Call [GET /phone/bulk](/api-reference/phone/get-bulk-results) with the `id` to retrieve results.

```json theme={null}
{
  "event": "bulk_phone_search_finished",
  "id": "910f3e13-b2bf-442d-ab0b-4cf44dfrij84fjrt"
}
```

## How are single and bulk webhooks different?

Single-search webhooks contain the complete result, so no extra call is needed. Bulk-search webhooks only signal that the batch is done — you then fetch the results with the matching GET endpoint.

| Type            | Single searches      | Bulk searches                            |
| --------------- | -------------------- | ---------------------------------------- |
| **Payload**     | Full result included | Notification only (ID + credits)         |
| **GET needed?** | No                   | Yes — use the GET endpoint with the `id` |

<Info>
  For single searches, the webhook contains everything you need. For bulk searches, the webhook tells you the batch is done — then you fetch the results.
</Info>

## What are the best practices for webhook endpoints?

A reliable webhook endpoint returns quickly, accepts only HTTPS, and tolerates the occasional duplicate. Follow these practices to keep deliveries dependable:

<AccordionGroup>
  <Accordion title="Return 200 Quickly">
    Process webhook payloads asynchronously. Return a `200` immediately, then handle the data in a background job.
  </Accordion>

  <Accordion title="Use HTTPS">
    Always use HTTPS endpoints. HTTP webhooks will be rejected.
  </Accordion>

  <Accordion title="Handle Duplicates">
    In rare cases, webhooks may be delivered more than once. Use the `id` field to deduplicate.
  </Accordion>

  <Accordion title="Use Custom Fields">
    Pass `custom` data in your requests to identify which record a webhook result belongs to:

    ```json theme={null}
    {
      "fullname": "Dwight Schrute",
      "company_domain": "dundermifflin.com",
      "custom": { "crm_id": "lead_001" }
    }
    ```

    The `custom` field is returned as-is in the webhook payload.
  </Accordion>
</AccordionGroup>

## Should I use webhooks or polling?

Use webhooks for production and polling only for quick prototyping or debugging. Webhooks deliver results in real time without consuming your request quota, while polling makes repeated GET calls that count against your [rate limits](/rate-limits).

|                       | Webhooks                | Polling (GET)             |
| --------------------- | ----------------------- | ------------------------- |
| **Latency**           | Real-time               | Depends on poll interval  |
| **API calls**         | 0 (Enrow calls you)     | Multiple calls per search |
| **Rate limit impact** | None                    | Consumes quota            |
| **Complexity**        | Requires endpoint setup | Simpler to implement      |

<Note>
  We recommend webhooks for production use. Use polling only for quick prototyping or debugging.
</Note>

## FAQ

<AccordionGroup>
  <Accordion title="Do webhooks cost extra credits?">
    No. Webhooks don't consume additional credits — you only pay for the search itself. The credit cost is reported in the `credits.cost` field of the payload. See [Credits & billing](/credits-billing) for per-endpoint costs.
  </Accordion>

  <Accordion title="What happens if my endpoint doesn't return a 200?">
    Your webhook URL must be a valid HTTPS endpoint that returns a `200` status code. If your server is unreachable or responds with another status, the delivery is treated as failed. As a fallback, you can always retrieve results by polling the relevant GET endpoint with the search `id`.
  </Accordion>

  <Accordion title="How do I match a webhook to the original request?">
    Use the `id` from the search response, or pass a `custom` object in your request — it's returned as-is in the webhook payload so you can map results back to your own records, such as a CRM lead ID.
  </Accordion>

  <Accordion title="Why didn't I receive a webhook?">
    The most common causes are a non-HTTPS URL, an endpoint that doesn't return `200`, or a server that times out. Confirm your endpoint is publicly reachable over HTTPS. For broader troubleshooting, see [Error handling](/error-handling) and [Status codes](/status-codes).
  </Accordion>
</AccordionGroup>

## Next steps

<CardGroup cols={2}>
  <Card title="Find an email" icon="envelope" href="/api-reference/email-finder/find-single">
    Pass a webhook URL in settings to get the result delivered automatically.
  </Card>

  <Card title="Get bulk results" icon="layer-group" href="/api-reference/email-finder/get-bulk-results">
    Fetch batch results after a bulk\_search\_finished webhook fires.
  </Card>

  <Card title="Authentication" icon="key" href="/authentication">
    How to pass your API key in the x-api-key header.
  </Card>

  <Card title="Rate limits" icon="gauge-high" href="/rate-limits">
    See why webhooks avoid the request quota that polling consumes.
  </Card>
</CardGroup>
