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

# Loops and webhooks

> Register a webhook destination and get results pushed to you instead of polling.

A Loop gives you a URL to send records to, and a webhook to receive results at — no polling required. Create one from your dashboard under **Loops**, or via the API.

## Creating a Loop

```bash theme={null}
curl -X POST "https://api.enrichloop.com/v1/loops" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"Inbound signups","webhook_url":"https://yourapp.com/webhooks/enrichment"}'
```

Your webhook URL must be `https` and publicly reachable. On success, you'll get back the Loop along with a **signing secret** — shown once, at creation:

```json theme={null}
{
  "data": {
    "id": "13d3c366-db0f-45c1-870d-7493e7d99c37",
    "name": "Inbound signups",
    "slug": "inbound-signups",
    "object_type": "company",
    "operation": "enrich",
    "enabled": true,
    "destination": {
      "kind": "webhook",
      "url": "https://yourapp.com/webhooks/enrichment",
      "secret": "whsec_e0908de179..."
    }
  }
}
```

Store the secret securely — you'll need it to verify incoming webhooks, and we can't show it to you again. If you lose it, delete the Loop and create a new one.

## Sending records to a Loop

```bash theme={null}
curl -X POST "https://api.enrichloop.com/v1/loops/13d3c366-db0f-45c1-870d-7493e7d99c37/run" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"domain":"stripe.com"}'
```

This behaves exactly like `POST /v1/companies/enrich` — you get the same task response immediately, and can still poll it if you like — but once the enrichment finishes, we also `POST` the result to your Loop's webhook.

## The webhook we send you

```http theme={null}
POST https://yourapp.com/webhooks/enrichment
Content-Type: application/json
User-Agent: EnrichLoop-Webhook/1
X-EnrichLoop-Event: task.succeeded
X-EnrichLoop-Delivery-Id: 9f1c2a3e-...
X-EnrichLoop-Signature: t=1754570000,v1=6c3a9f...
```

```json theme={null}
{
  "event": "task.succeeded",
  "task": {
    "id": "...",
    "object_type": "company",
    "status": "succeeded",
    "target": "stripe.com"
  },
  "data": { "name": "Stripe", "domain": "stripe.com" },
  "error": null
}
```

`event` is one of `task.succeeded`, `task.failed`, or `test` (sent when you use the **Send test event** button in the dashboard).

`X-EnrichLoop-Delivery-Id` is stable across retries of the same delivery — use it to deduplicate on your end.

Respond with any `2xx` status to acknowledge receipt. Anything else is treated as a failure and retried.

## Verifying webhook signatures

Every webhook is signed with your Loop's secret, using the same scheme as Stripe:

```
X-EnrichLoop-Signature: t=<unix timestamp>,v1=<hex-encoded HMAC-SHA256>
```

The signature is computed over `{timestamp}.{raw request body}`. Verify it like this:

<CodeGroup>
  ```js Node theme={null}
  import { createHmac, timingSafeEqual } from "node:crypto";

  function verifyWebhook(secret, rawBody, signatureHeader, toleranceSeconds = 300) {
    const parts = Object.fromEntries(
      signatureHeader.split(",").map((p) => {
        const i = p.indexOf("=");
        return [p.slice(0, i).trim(), p.slice(i + 1).trim()];
      }),
    );

    const timestamp = Number(parts.t);
    if (
      !Number.isFinite(timestamp) ||
      Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds
    ) {
      return false; // missing, malformed, or too old — reject
    }

    const expected = createHmac("sha256", secret)
      .update(`${timestamp}.${rawBody}`)
      .digest("hex");
    const a = Buffer.from(expected, "hex");
    const b = Buffer.from(parts.v1 ?? "", "hex");
    return a.length === b.length && timingSafeEqual(a, b);
  }
  ```

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

  def verify_webhook(secret: str, raw_body: bytes, signature_header: str, tolerance_seconds: int = 300) -> bool:
      parts = dict(p.split("=", 1) for p in signature_header.split(",") if "=" in p)
      timestamp = parts.get("t")
      if timestamp is None or abs(time.time() - int(timestamp)) > tolerance_seconds:
          return False

      signed_payload = f"{timestamp}.".encode() + raw_body
      expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
      return hmac.compare_digest(expected, parts.get("v1", ""))
  ```
</CodeGroup>

<Warning>
  Verify against the **raw** request body, before any JSON parsing — re-serializing and comparing will not match.
</Warning>

## Retries

If your endpoint doesn't respond with a `2xx`, we retry automatically:

| Attempt | Delay       |
| ------- | ----------- |
| 1       | immediately |
| 2       | 10 seconds  |
| 3       | 1 minute    |
| 4       | 5 minutes   |
| 5       | 30 minutes  |
| 6       | 2 hours     |
| 7       | 6 hours     |

After the final attempt, delivery is marked failed. You can see delivery history and retry manually from your dashboard.

## Managing Loops

See the [API Reference](/api-reference) for the full `GET`/`PATCH`/`DELETE` schemas on `/v1/loops/{id}`. Disabling a Loop stops new runs from being accepted through it; existing tasks already in flight still complete.
