# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
This site has an agentic ask interface you may use to query the documentation dynamically by asking a question.

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

```
GET https://docs.insurgrid.com/index.md?ask=<question>&goal=<user_goal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `automate deployments from our CI pipeline` lets GitBook tailor the answer to that use case.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

---

# Get started

InsurGrid sends your app structured insurance data — extracted policy details and completed intake forms — pushed to a URL you control. This section takes you from zero to your first received webhook in about 30 minutes.

```mermaid
flowchart LR
    A["A customer submits<br/>(dec page or form)"] --> B["InsurGrid processes it"]
    B --> C["We POST to your URL"]
    C --> D["Your server receives it"]
```

## The path

Follow these in order — each page is a few minutes:

1. **What you can build** — the two kinds of data you can receive, and the one envelope they share.
2. **Get access** — create an account and open the API section where credentials and webhook URLs live.
3. **Get your API credentials** — your API token and signing secret, and what each is for.
4. **Set up a webhook endpoint** — stand up an HTTPS URL that returns `2xx`, and register it.
5. **Expose your local endpoint** — the one non-obvious step: give your `localhost` a public HTTPS URL for testing.

## Then pick a quickstart

Once you're set up, follow a runnable, end-to-end guide for your use case:

* [**Receive policy data**](/quickstarts/a-receive-policy-data) — get extracted policy fields by webhook (the simplest path — start here).
* [**Embed a form & receive submissions**](/intake-forms/b-embed-form) — drop an InsurGrid intake form into your app.
* [**Build forms via the API**](/intake-forms/c-build-forms-api) — create and publish forms from your backend.

New to webhooks? Every term is defined inline the first time it appears, and collected in the [Glossary](/glossary).


# What you can build

InsurGrid turns insurance documents and intake forms into structured data, and delivers that data to your application automatically.

Here's the whole idea in one picture:

```mermaid
flowchart LR
    A["1 · A customer submits<br/>(declaration page or<br/>an embedded form)"] --> B["2 · InsurGrid processes it<br/>(extracts / assembles<br/>the structured data)"]
    B --> C["3 · We POST the data<br/>to a URL you gave us"]
    C --> D["Your server<br/>receives it"]
```

You never poll InsurGrid asking "is it done yet?" — you give us a URL, and we send the data to it when it's ready. That's the core of every integration on this platform.

## The two ways data reaches you

**1. Policy data (from a submitted declaration page).** When a declaration page is submitted and processed, we send you the extracted policy — coverages, premiums, the named insured, vehicles or property, and so on — as a webhook.

**2. Form submissions (from an embedded intake form).** When you embed an InsurGrid form and a customer completes it, we send you the full submission: every answer, links to any files they uploaded, and — if the form maps to ACORD forms — a link to the filled ACORD PDFs.

Both arrive the same way: an HTTP `POST` to your endpoint with a small JSON envelope:

```json
{
  "topic": "client-submission",
  "data": { }
}
```

* `topic` tells you *what kind* of event this is.
* `data` holds the actual content. Its shape depends on the topic — the [API reference](/api-reference/webhook-events) documents every field.

## What you'll need

Before you write any code, you'll set up three things (the next pages walk through each):

1. An **account** and access to the **API section**.
2. Your **API credentials** — a token and a signing secret.
3. A **webhook endpoint** — an HTTPS URL on your server that can receive a `POST`.

That's it. If you can run a small web server and make an HTTPS URL reachable, you can complete a quickstart in under 30 minutes.

Next: [Get access →](/get-started/02-get-access)


# Get access

To start receiving data, you need an InsurGrid account and access to the **API section** of your dashboard. This is where you'll get your credentials and tell us where to send your webhooks.

![The portal Overview — your home for credentials, webhook URLs, delivery stats, and the staging environment.](https://3825213797-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FjBgAFiZ99Eug1CmRq2QO%2Fuploads%2Fx9NlITX9PdMkpyhOJnWG%2Fportal-overview.png?alt=media)

## Steps

1. **Create an account.** Sign up for InsurGrid and sign in to your dashboard.
2. **Open the API section.** In the dashboard, go to the **API** area. This is your home for everything in these docs — credentials, webhook URLs, and delivery logs.
3. **Add your webhook URLs.** For each topic you want to receive (for example, `client-submission`), add the URL InsurGrid should `POST` to. You can add a **staging** URL and a **production** URL separately, so you can test without touching live traffic.
4. **Start using them.** Once a URL is saved for a topic, InsurGrid begins delivering matching events to it.

![Registering a webhook endpoint — enter your destination URL, pick the topic, and choose how deliveries are authenticated (Default signs every request).](https://3825213797-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FjBgAFiZ99Eug1CmRq2QO%2Fuploads%2FPlw2N8Qp4OnhpVZGdS5j%2Fregister-endpoint.png?alt=media)

## Staging vs production

You'll add URLs for **two environments**:

| Environment    | Use it for                                            | Example URL                                    |
| -------------- | ----------------------------------------------------- | ---------------------------------------------- |
| **Staging**    | Building and testing your integration. Safe to break. | `https://staging.your-app.com/hooks/insurgrid` |
| **Production** | Real customer data, once your integration works.      | `https://your-app.com/hooks/insurgrid`         |

Build and verify everything in **staging** first. When it works end to end, add the production URL and switch over. See [Testing & go-live](/testing/sandbox) for the full flow.

> **Developing on your own machine?** Your local server is `http://localhost:3000`, which InsurGrid can't reach. You'll expose it with a public HTTPS URL — see [Expose your local endpoint](/get-started/05-local-development). Don't skip this; it's the one step that isn't obvious.

Next: [Get your API credentials →](/get-started/03-api-credentials)


# Get your API credentials

Where to create an InsurGrid API key, and what to do when a call is rejected.

Every call you make to InsurGrid is authenticated with an API key you create in your own dashboard. This page covers where to find it, what it looks like, and what the most common rejections mean.

***

## Create a key

In the dashboard, open **Settings → API keys**. Type a name for the key and select **Create API key**. Existing keys are listed below the form, each showing when it was created, when it was last used, and a **Revoke** button.

Name keys for where they'll be used — "CRM integration", "CI pipeline" — so you can tell them apart later and revoke one without guessing which is which.

> ⚠️ **The key is shown once.** It appears immediately after you create it and is **not shown again** once you refresh or leave the page. Copy it straight into a secrets manager or an environment variable.

There is no rotate. If a key is lost or you think it may have been exposed, **Revoke** it, create a new one, and update your application. Revoking takes effect on the next request — and stops any live embeds using that key too.

## Use it

Send the key as a bearer token on every request:

```
Authorization: Bearer YOUR_API_KEY
```

A key is a numeric id, a pipe character, then a secret — shaped like `4821|xWq3…`. Send the whole string, pipe included.

| Environment | API base URL                                   |
| ----------- | ---------------------------------------------- |
| Staging     | `https://phoenix-staging.insurgrid.com/api/v1` |
| Production  | `https://phoenix.insurgrid.com/api/v1`         |

***

## A key belongs to one environment

Staging and production are separate systems, with separate dashboards and separate data. **A key created in one will not work against the other** — it comes back `401 invalid_api_key`.

Create your staging key at `dashboard-staging.insurgrid.com` and your production key at `dashboard.insurgrid.com`, keep them in separate configuration, and when you go live change the base URL and the key together.

If a call is failing with `401` and the address looks right, this is the first thing to check. More in [Environments](/guides/environments).

## Forms and Loss Runs need an agency admin

For the Forms API, embed sessions and Loss Runs, the key must have been created by an **agency admin**. One created by any other user is rejected with `403 admin_required`, however correct the request is.

The Policy Comparison API doesn't require this — any valid key works there.

## The product has to be enabled too

A valid key is not sufficient on its own. Forms and Loss Runs are each enabled separately, so a `403 feature_disabled` means your key is fine and the product simply isn't switched on for the agent who owns it. Contact us and we'll enable it.

***

## Webhooks authenticate in the other direction

Your API key authenticates requests **you make to InsurGrid**. It plays no part in the requests **InsurGrid makes to you**.

When you register a webhook endpoint you separately choose how InsurGrid should prove a delivery is genuine — a signature, an API key of your own choosing, a bearer token, or basic auth, set per endpoint. See [Set up a webhook endpoint](/get-started/04-webhook-endpoint).

## Keeping credentials safe

* Store keys in a secrets manager or an environment variable — never hard-coded.
* Never put one in browser or mobile code, a public repository, a URL, or your logs.
* Use a separate key per integration, so you can revoke one without taking down the rest.
* Revoke immediately if a key may have been exposed. There's no way to rotate in place.

***

Next: [Set up a webhook endpoint →](/get-started/04-webhook-endpoint)


# Set up a webhook endpoint

A webhook endpoint is just a URL on your server that accepts an HTTP `POST`. When something happens, InsurGrid sends the data there. This is the one prerequisite the webhook quickstarts need.

## The rules

Your endpoint must:

1. **Be reachable over HTTPS.** Not plain HTTP. (In local development you'll get an HTTPS URL with a tunnel — see [next page](/get-started/05-local-development).)
2. **Accept a `POST`** with a JSON body.
3. **Return a `2xx` status quickly.**

> **New term — `2xx`:** any HTTP status code from 200 to 299 (like `200 OK` or `202 Accepted`). To InsurGrid it means "I received it, thanks." Anything else (or a timeout) tells us delivery failed, and we'll retry.

## Acknowledge first, work later

Return `2xx` *immediately*, then do your real processing (saving to your database, calling other services) afterwards — in a background job, a queue, or after you've sent the response.

Why: we expect a fast acknowledgement. If your handler does slow work *before* replying, you may time out, we'll count it as failed, and we'll retry — so you'd process the same event several times.

**Do this:**

```js
// server (Node/Express)
app.post("/hooks/insurgrid", (req, res) => {
  res.sendStatus(200);        // acknowledge first
  queue.add("process-webhook", req.body);   // then work, off the request
});
```

**Not this:**

```js
// server — DON'T: slow work before replying
app.post("/hooks/insurgrid", async (req, res) => {
  await doSlowProcessing(req.body);   // may time out → we retry → duplicate work
  res.sendStatus(200);
});
```

## Register it

Add the endpoint's URL in the **API section** of your dashboard, under the topic you want (see [Get access](/get-started/02-get-access)). Add a staging URL first.

![Registering a webhook endpoint — enter your HTTPS destination URL, choose the topic, and pick how deliveries are authenticated (Default signs every request).](https://3825213797-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FjBgAFiZ99Eug1CmRq2QO%2Fuploads%2FPlw2N8Qp4OnhpVZGdS5j%2Fregister-endpoint.png?alt=media)

You now have everything you need. Continue to the [first quickstart](/quickstarts/a-receive-policy-data) — but if you're developing locally, read the next page first.

Next: [Expose your local endpoint →](/get-started/05-local-development)


# Expose your local endpoint (local development)

When you build on your own machine, your server runs at something like `http://localhost:3000`. That address only exists *on your computer* — InsurGrid's servers can't reach it, and it isn't HTTPS. So webhooks will never arrive while you develop, unless you bridge that gap.

The fix is a **tunnel**: a tool that gives your local server a temporary public HTTPS URL and forwards any request on that URL to `localhost`. This is standard for webhook development — Stripe, GitHub, and every webhook provider expect it.

## Using ngrok (quickest)

1. Install [ngrok](https://ngrok.com/download) and sign up for a free account.
2. Start your local server (say, on port 3000).
3. In a second terminal, run:

   ```bash
   ngrok http 3000
   ```
4. ngrok prints a public HTTPS URL that forwards to your local server:

   ```
   Forwarding   https://a1b2-34-56-78-90.ngrok-free.app -> http://localhost:3000
   ```
5. Copy that `https://…` URL, add your endpoint path, and register it in the dashboard's **API section** as your **staging** webhook URL:

   ```
   https://a1b2-34-56-78-90.ngrok-free.app/hooks/insurgrid
   ```

Now any webhook InsurGrid sends to that URL is forwarded straight to your `localhost` server, and you can set breakpoints and read logs like normal.

## Good to know

* **The URL changes each restart** on the free plan. When you restart ngrok, update the URL in the dashboard. (A paid ngrok plan or a reserved domain gives you a stable URL.)
* **Any tunnel works** — [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/), [localtunnel](https://github.com/localtunnel/localtunnel), or others. ngrok is just the most common.
* **This is for development only.** In production, register your real deployed HTTPS URL instead.
* Inspect what's arriving at ngrok's local dashboard (`http://127.0.0.1:4040`) — handy when debugging.

You're ready. Continue to Quickstart A →


# Quickstarts

Runnable, copy-paste integrations, end to end. Start with A — it's the simplest.

* **Quickstart A — Receive policy data via webhook**
* **Quickstart B — Embed a form & receive submissions**
* **Quickstart C — Build forms via the API**


# Quickstart A — Receive policy data via webhook

**What you'll build:** a small web server that receives extracted policy data from InsurGrid, verifies it's genuine, and prints it. By the end you'll have handled a real (test) policy-data webhook end to end.

**Time:** \~20 minutes.

**Prerequisites** (from [Get started](/get-started/01-what-you-can-build)):

* [ ] An InsurGrid account with access to the **API section**.
* [ ] Your **signing secret** (from [API credentials](/get-started/03-api-credentials)).
* [ ] Node 18+ *or* Python 3.9+ installed.
* [ ] [ngrok](/get-started/05-local-development) (or any tunnel) if you're on your local machine.

***

## Step 1 — Get your signing secret

From the dashboard's **API section**, copy your **signing secret** and put it in an environment variable so it never lands in your code:

```bash
export INSURGRID_SIGNING_SECRET="whsec_...your secret..."
```

You'll use it in Step 4 to prove each webhook really came from InsurGrid.

## Step 2 — Stand up an endpoint · *server*

Create a tiny server with one route that accepts a `POST`. Read the **raw request body** as well as the parsed JSON — you need the raw bytes for signature verification in Step 4.

**Node (Express):**

```js
// server.js — run with: node server.js
const express = require("express");
const app = express();

// Keep the raw body; we need the exact bytes to verify the signature.
app.use(express.json({
  verify: (req, _res, buf) => { req.rawBody = buf; },
}));

app.post("/hooks/insurgrid", (req, res) => {
  res.sendStatus(200);          // Step 3: acknowledge fast
  console.log("Received:", req.body.topic);
  // signature check + processing come next
});

app.listen(3000, () => console.log("Listening on :3000"));
```

**Python (Flask):**

```python
# server.py — run with: flask --app server run --port 3000
from flask import Flask, request

app = Flask(__name__)

@app.post("/hooks/insurgrid")
def insurgrid_hook():
    raw = request.get_data()          # exact bytes, for the signature check
    event = request.get_json()
    print("Received:", event["topic"])
    return "", 200                    # Step 3: acknowledge fast
```

## Step 3 — Acknowledge fast, process later

Notice both handlers return `200` right away. Do any slow work (saving to your DB, calling other services) *after* you respond — see [Set up a webhook endpoint](/get-started/04-webhook-endpoint) for why this matters. For this quickstart, printing is enough.

## Step 4 — Expose your endpoint & register it · *local dev*

If you're on your machine, start a tunnel so InsurGrid can reach you:

```bash
ngrok http 3000
```

Copy the `https://…` URL it prints, append `/hooks/insurgrid`, and add it in the dashboard's **API section** as the **staging** URL for the `client-submission` topic. (Full walkthrough: [Expose your local endpoint](/get-started/05-local-development).)

## Step 5 — Verify the signature · *server*

Every webhook carries an `x-signature` header: an HMAC-SHA256 of the **raw request body**, computed with your signing secret. Recompute it on your side and compare — if it doesn't match, reject the request.

> **New term — HMAC signature:** a fingerprint of the message made with a shared secret. Because only you and InsurGrid know the secret, a matching fingerprint proves the message is genuine and unaltered. **Compute it over the exact raw bytes you received** — if you re-serialize the parsed JSON first, the bytes differ and the check will falsely fail. This is the #1 first-timer bug.

**Node:**

```js
const crypto = require("crypto");

function verify(rawBody, signature, secret) {
  const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
  // timing-safe compare avoids leaking info via response time
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature || ""));
}

app.post("/hooks/insurgrid", (req, res) => {
  const ok = verify(req.rawBody, req.get("x-signature"), process.env.INSURGRID_SIGNING_SECRET);
  if (!ok) return res.sendStatus(401);   // not from InsurGrid — reject
  res.sendStatus(200);
  handle(req.body);
});
```

**Python:**

```python
import hmac, hashlib, os

def verify(raw_body: bytes, signature: str, secret: str) -> bool:
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature or "")

@app.post("/hooks/insurgrid")
def insurgrid_hook():
    raw = request.get_data()
    if not verify(raw, request.headers.get("x-signature", ""),
                  os.environ["INSURGRID_SIGNING_SECRET"]):
        return "", 401                    # not from InsurGrid — reject
    handle(request.get_json())
    return "", 200
```

> Prefer a different scheme (Bearer token, Basic auth, or an API-key header) instead of a signature? You can configure that per endpoint — see Register your endpoint & choose an auth type.

## Step 6 — Read the payload · *server*

A verified policy-data webhook looks like this:

```json
{
  "topic": "client-submission",
  "data": {
    "document_link": "https://files.insurgrid.com/…/declaration.pdf",
    "policy_type": "Personal Auto",
    "policy": { "provider": "…", "policy_number": "…" },
    "policyholder": { "first_name": "…", "last_name": "…" },
    "vehicles": [ … ],
    "…": "…extracted fields, depending on policy_type…"
  }
}
```

Key fields:

| Field                | Type         | Meaning                                                                                   | Example                           |
| -------------------- | ------------ | ----------------------------------------------------------------------------------------- | --------------------------------- |
| `topic`              | string       | The event type. For policy data it's `client-submission`.                                 | `"client-submission"`             |
| `data.document_link` | string (URL) | A **time-limited** link to the source declaration-page file.                              | `"https://files.insurgrid.com/…"` |
| `data.policy_type`   | string       | A descriptive label for the insurance line — a hint, not a fixed code (don't hard-match). | `"Personal Auto"`, `"Homeowners"` |
| `data.*`             | object/array | The extracted policy fields. The set depends on `policy_type`.                            | see Webhook events & payloads     |

> ⚠️ **`document_link` expires.** It's a temporary download link. If you need the file, fetch and store it on your side promptly — don't save the link and expect it to work later.

The full field list per policy type is in the Webhook events & payloads reference.

## Step 7 — De-duplicate · *server*

Delivery is **at-least-once**: the same event may occasionally arrive more than once (for example, if a retry overlaps a slow success). So make your handler **idempotent** — processing the same event twice must be harmless.

The policy-data payload has no dedicated event-id field, so de-duplicate on a **hash of the payload contents** — identical content means it's the same event. Record hashes you've already handled and skip repeats:

```js
// server (sketch)
const crypto = require("crypto");
function handle(event) {
  const key = crypto.createHash("sha256")
    .update(JSON.stringify(event.data)).digest("hex");   // content fingerprint
  if (alreadyProcessed(key)) return;      // skip duplicate
  markProcessed(key);
  // …save the policy data…
}
```

See Delivery & de-duplication for patterns. (The form-submission event is simpler — it carries a `submission_id` you can key on directly.)

## Step 8 — Try it out (sandbox)

1. In the **API section**, use **Send test event** to fire a sample `client-submission` at your staging URL.
2. Watch your server log the topic and the payload.
3. Check the **delivery log** in the dashboard — you'll see the attempt and your `200` response.

If nothing arrives, jump to Troubleshooting → "My webhook isn't arriving".

## Step 9 — Go live

When staging works end to end:

1. Deploy your endpoint to a real HTTPS URL.
2. Add that URL as your **production** `client-submission` webhook.
3. Switch to your **production** signing secret and API token.
4. Run the Integration checklist.

***

## Next steps

* [Embed a form to collect submissions →](/intake-forms/b-embed-form)
* Webhook events & payloads (all fields) →
* Verify signatures — deeper dive →


# Intake Forms

Collect a customer's details through an InsurGrid form, and get the answers back as structured data.

An intake form collects what you need from a customer before you can quote them. InsurGrid builds, hosts and runs the form; you get the answers back as structured data — every response, any files they uploaded, and filled ACORD PDFs where the form maps onto them.

An integration has three parts: **build** a form, **share** it, and **receive** what comes back.

***

## 1. Build a form

Two ways, and they work on the same forms — build one in the embedded builder and manage it through the API later, or the other way round.

### Let your users build it — the embeddable builder

Drop InsurGrid's form builder into a page of your own product and your users build and edit their own forms by clicking, without leaving your app.

They don't need InsurGrid accounts, and each signed-in user sees only their own forms. You add a snippet to the page and one route on your server that hands the builder a short-lived token — your API key never reaches the browser.

```html
<div id="insurgrid-forms"></div>
<script
  src="https://dashboard.insurgrid.com/embed.js"
  data-token-url="/insurgrid-token">
</script>
```

Best when the people building forms aren't developers. Full walkthrough: [Embed the form builder](/intake-forms/builder-sessions).

![The embedded builder's "Choose a starting point" screen — begin from a blank form or a starter template.](https://3825213797-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FjBgAFiZ99Eug1CmRq2QO%2Fuploads%2Fb7Nh5takZf8C4S5MtN8T%2Fform-builder-starting-point.png?alt=media)

### Build it from your backend — the API

Create a draft with `POST /api/v1/forms`, set its steps and questions with `PATCH`, then publish with `PUT /api/v1/forms/{id}/publish`.

Best for templating, bulk creation, automation, or keeping form definitions in source control. Reference: [Forms API](/intake-forms/forms) · walkthrough: [Build forms via the API](/intake-forms/c-build-forms-api).

> **Forms built in the InsurGrid dashboard are separate.** The API and the embedded builder both work on forms that belong to one of *your* users. A form an agent creates in the InsurGrid dashboard has no such owner, so it won't appear in `GET /forms` and `GET /forms/{id}` returns `404` for it.

## 2. Share it

A published form has a `shareUrl` — an ordinary web address. Send it to a customer, link to it from your site, or put it in an iframe if you'd rather it appeared inside your own page.

Walkthrough: [Share a form and receive submissions](/intake-forms/b-embed-form).

## 3. Receive what comes back

Every completed form appears in your InsurGrid dashboard, with nothing to set up.

To get submissions into your own systems as well, register a webhook endpoint. InsurGrid posts each one to you as a `form-submission` event — every answer, links to any uploaded files, and a link to the filled ACORD PDFs — through the same signed, at-least-once delivery pipeline used for policy data, with a delivery log and replay.

> **Submissions are delivered, not fetched.** The Forms API doesn't return them, so store what you need when the event arrives. See [Submissions](/intake-forms/submissions).

***

## In this section

* [Share a form and receive submissions](/intake-forms/b-embed-form) — get a published form in front of customers, and the answers back.
* [Build forms via the API](/intake-forms/c-build-forms-api) — create, structure and publish forms from your backend.
* [Forms API](/intake-forms/forms) — the full endpoint, error and rate-limit reference.
* [Embed the form builder](/intake-forms/builder-sessions) — let your users build their own forms inside your product.
* [Submissions](/intake-forms/submissions) — how completed forms reach you.
* [Tracking & analytics](/intake-forms/tracking-and-analytics) — see which ads produce leads, and how many people finish the form. Set up a Meta Pixel or a Google Tag Manager container in a few clicks; no code needed.

## Related

* [The two embeds](/guides/embeds) — the builder versus the form customers fill in.
* [Environments](/guides/environments) — staging and production addresses.


# Quickstart B — Share a form and receive submissions

Get a published form in front of customers, and get the completed submissions back.

Once a form is published it has a link. Send that link to a customer and they can fill the form in — that's the whole of sharing it.

What happens next is the part worth setting up. Every completed form shows up in your InsurGrid dashboard automatically. If you also want the submission delivered to your own systems — every answer, the contact details, any uploaded files, and the filled ACORD PDFs — you register a webhook endpoint, and InsurGrid posts each one to you as it arrives.

This page covers both halves. About 20 minutes for the webhook half; sharing the link takes a minute.

**What you'll need**

* An InsurGrid account with at least one **published** form.
* For the webhook half: an HTTPS address InsurGrid can reach, that accepts `POST`. A deployed server is simplest — if you're working on your laptop, it isn't reachable from the internet, so put a tunnel in front of it ([how](/get-started/05-local-development)).

|            | Staging                                 | Production                      |
| ---------- | --------------------------------------- | ------------------------------- |
| API        | `https://phoenix-staging.insurgrid.com` | `https://phoenix.insurgrid.com` |
| Form links | `https://link-staging.insurgrid.com`    | `https://link.insurgrid.com`    |

***

## Part one — share the form

### Get the link

Every published form has a **`shareUrl`**. It's in the dashboard's **Forms** section, or fetch it:

```bash
curl 'https://phoenix-staging.insurgrid.com/api/v1/forms' \
  -H 'Authorization: Bearer YOUR_API_KEY'
```

```
https://link-staging.insurgrid.com/jane-smith/form/f_9c2a7b1e4d/apartment-intake-acme
```

> ⚠️ **`shareUrl` is `null` until the form is published.** Drafts and archived forms have no link at all. Publish first, with `PUT /api/v1/forms/{id}/publish`.

The first segment is the **slug of the agent who created the form**, and that's who submissions through this link are credited to. It appears again as `agent` in every webhook.

### Give it to people

It's an ordinary web address, so use it however suits you — email or text it to a customer, put it behind a button on your website, print it as a QR code. InsurGrid hosts the page; nothing needs registering beforehand.

### Showing it inside your own page

If you'd rather the form appeared within your product than on a page of its own, put the link in an `<iframe>` and add `?embed=1`. That flag strips InsurGrid's header and page furniture, so only the form shows — no InsurGrid branding around it.

```html
<iframe
  src="https://link-staging.insurgrid.com/jane-smith/form/f_9c2a7b1e4d/apartment-intake-acme?embed=1"
  title="Apartment Intake"
  width="100%"
  height="800"
  style="border:0;"
  loading="lazy">
</iframe>
```

Give the frame a height that suits your layout — the form doesn't message your page, so there's nothing to listen for and nothing to resize against. Everything else works exactly as it does through a plain link.

***

## Part two — receive the submissions

When someone completes the form, the response lands in your InsurGrid dashboard straight away. That happens for every account with nothing to set up — you can read the answers, download any files, and review the submission there.

To get the same submission into **your own** systems, register a webhook endpoint. InsurGrid then posts each completed submission to your server as a `form-submission` event, carrying the contact details, every answer, links to any uploaded files and, for ACORD-mapped forms, a download of the filled PDFs.

The rest of this page is that setup.

### Register your endpoint

In the InsurGrid **webhooks dashboard**:

1. **Add your endpoint** — a publicly reachable HTTPS URL that accepts `POST`. Each account has one.
2. **Choose your events** — tick **`form-submission` · intake & ACORD**. You can receive `client-submission` (policy data) on the same endpoint too.
3. **Choose how InsurGrid authenticates to you** — see below.
4. **Send a test event** to check your handler before real traffic arrives.

### How InsurGrid proves it's InsurGrid

You pick this per endpoint; it isn't fixed:

| Option                | What your endpoint receives                                                                   |
| --------------------- | --------------------------------------------------------------------------------------------- |
| **Default signature** | Every request signed by InsurGrid. No setup needed.                                           |
| **Custom signature**  | An HMAC-SHA256 signature of the body, in a header you name, signed with a secret you provide. |
| **API key**           | Your key, in a header or query parameter you name.                                            |
| **Bearer token**      | `Authorization: Bearer <token>`                                                               |
| **Basic auth**        | `Authorization: Basic <credentials>`                                                          |

We recommend **Custom signature**, **API key** or **Bearer token**, so your server can reject anything it can't verify.

A staging secret never validates a production event, and vice versa.

### Acknowledge first, work second

Return any `2xx` as soon as the event arrives — the body is ignored. Do the slow work after you've responded.

```js
app.post("/hooks/insurgrid", (req, res) => {
  if (!verifyHoweverYouConfiguredIt(req)) return res.sendStatus(401);
  res.sendStatus(200);               // acknowledge
  handleSubmission(req.body.data);   // then process
});
```

Anything else — another status, a timeout, no response — counts as a failed delivery and is retried. You can also resend any event from the dashboard.

Two behaviours to design for:

* **Events can arrive more than once.** Use `data.submission_id` to skip ones you've handled.
* **Order isn't guaranteed.** If sequence matters, sort on `data.submitted_at`.

***

## What the event looks like

Every event has the same envelope — check `topic` first, since one endpoint can receive several kinds:

```json
{ "topic": "form-submission", "data": { … } }
```

### Inside `data`

| Field                   | Type           | What it is                                                                                                                             |
| ----------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `submission_id`         | integer        | Unique id for this submission. Your de-duplication key.                                                                                |
| `trace_id`              | string \| null | The form session that produced it. Quote it if you contact support.                                                                    |
| `submitted_at`          | string         | When it was submitted, UTC ISO 8601.                                                                                                   |
| `form.id`               | integer        | Form id.                                                                                                                               |
| `form.public_id`        | string         | The public id used in links and embeds.                                                                                                |
| `form.name`             | string         | Form name.                                                                                                                             |
| `form.insurance_line`   | string         | `personal` or `commercial`.                                                                                                            |
| `form.external_user_id` | string \| null | Your own user id, if the form was created through the Forms API with one.                                                              |
| `agent`                 | object         | Who it's credited to: `id`, `name`, `email`. Name and email can be `null`.                                                             |
| `contact`               | object         | The submitter's `name`, `email`, `phone`. Filled from questions carrying a `field_mapping` — see the warning below. Any can be `null`. |
| `answers`               | array          | One entry per question. See below.                                                                                                     |
| `acord`                 | object         | ACORD output. See below.                                                                                                               |

> ⚠️ **`contact` only fills if the questions are tagged.** InsurGrid reads it from questions carrying a `field_mapping` of `FullName`, `Email` or `Phone` — never from a question's type or label. A form built through the API with its own `steps`, and no tags, delivers `contact: {name: null, email: null, phone: null}` on every submission, with the answers still present in `answers[]`. See [Keeping the contact block populated](/intake-forms/forms#keeping-the-contact-block-populated).

### Answers

`answers` lists **every question on the form, in order — including ones nobody answered**, which carry `value: null`.

| Field         | What it is                                                              |
| ------------- | ----------------------------------------------------------------------- |
| `question_id` | Stable id. **Map on this, not `label`** — labels get edited, ids don't. |
| `label`       | The question text as shown to the submitter.                            |
| `type`        | The question type.                                                      |
| `value`       | The answer. Shape depends on `type`.                                    |
| `columns`     | `data_table` only — the table's columns (`id`, `label`, `type`).        |

| Type                               | `value`                                                     |
| ---------------------------------- | ----------------------------------------------------------- |
| `short_text`, `long_text`, `email` | string                                                      |
| `number`                           | number                                                      |
| `date`                             | date string                                                 |
| `dropdown`, `single_choice`        | string — the option they picked                             |
| `multi_choice`                     | array of strings                                            |
| `address`                          | object: `line1`, `line2`, `city`, `state`, `zip`, `country` |
| `data_table`                       | array of rows, each keyed by column id                      |
| `file_upload`                      | array of `{ filename, content_type, url }`                  |

> ⚠️ **File links are public and permanent — treat them as sensitive.** The `url` on a `file_upload` answer, and `acord.archive_url`, are unsigned links to a public bucket. They don't expire, and **anyone who has the URL can download the file without authenticating**. Since these are documents your customer uploaded, don't paste one into a ticket, a chat, an email or a log you wouldn't paste the document itself into.
>
> Download and store what you need when the event arrives: there is no endpoint to re-fetch a submission, and a file's `url` is `null` once the document is no longer available.

### ACORD

| Field                  | What it is                                                                                           |
| ---------------------- | ---------------------------------------------------------------------------------------------------- |
| `acord.is_acord_ready` | `true` when the form is mapped to ACORD forms.                                                       |
| `acord.form_ids`       | The ACORD numbers filled from this submission, e.g. `["125", "140"]`. Empty when not ACORD-ready.    |
| `acord.archive_url`    | A ZIP of the filled PDFs. Present only when ACORD-ready *and* the archive built — treat as optional. |

<details>

<summary>A complete example event</summary>

```json
{
  "topic": "form-submission",
  "data": {
    "form": {
      "id": 4412,
      "public_id": "f_9c2a7b1e4d",
      "name": "Apartment Intake — Acme Agency",
      "insurance_line": "commercial",
      "external_user_id": "partner-user-123"
    },
    "submission_id": 55231,
    "trace_id": "tr_demo5f3c9a2b",
    "submitted_at": "2026-08-24T18:22:40Z",
    "agent": { "id": 88213, "name": "Jane Smith", "email": "jane@acme-agency.com" },
    "contact": { "name": "Jimmy Swanson", "email": "jimmy.swanson@example.com", "phone": "(555) 010-1234" },
    "answers": [
      { "question_id": "b1c2-demo", "label": "Full name", "type": "short_text", "value": "Jimmy Swanson" },
      { "question_id": "c3d4-demo", "label": "Property address", "type": "address",
        "value": { "line1": "12 Oak St", "line2": "", "city": "Austin", "state": "TX", "zip": "78701", "country": "US" } },
      { "question_id": "e5f6-demo", "label": "Drivers", "type": "data_table",
        "value": [ { "col-name-demo": "Jimmy Swanson", "col-license-demo": "TX", "col-dob-demo": "1989-04-12" } ],
        "columns": [
          { "id": "col-name-demo", "label": "Driver name", "type": "short_text" },
          { "id": "col-license-demo", "label": "License state", "type": "dropdown" },
          { "id": "col-dob-demo", "label": "Date of birth", "type": "date" }
        ] },
      { "question_id": "d4e5-demo", "label": "Loss runs", "type": "file_upload",
        "value": [ { "filename": "losses.pdf", "content_type": "application/pdf",
                     "url": "https://s3.us-east-2.amazonaws.com/<bucket>/documents/…/losses.pdf" } ] },
      { "question_id": "f7a8-demo", "label": "Prior claims in the last 5 years?",
        "type": "single_choice", "value": null }
    ],
    "acord": {
      "is_acord_ready": true,
      "form_ids": ["125", "140", "126"],
      "archive_url": "https://s3.us-east-2.amazonaws.com/<bucket>/acord/…/archive.zip"
    }
  }
}
```

</details>

### Handling a repeat delivery

```python
def handle_submission(data):
    sid = data["submission_id"]
    if already_processed(sid):
        return
    mark_processed(sid)
    save(data)
```

***

## Next steps

* [Build & manage forms via the API](/intake-forms/c-build-forms-api)
* [Embed the form builder](/intake-forms/builder-sessions) — let your users build their own forms
* [Submissions](/intake-forms/submissions)
* [Environments](/guides/environments)


# Quickstart C — Build forms via the API

Create, structure and publish a form entirely from your backend.

If your users build their forms inside your product, you have two options: embed InsurGrid's builder, or drive the API yourself and put your own interface on top. This walkthrough is the second.

By the end you'll have created a form, given it questions, and published it — without opening a dashboard. About 20 minutes.

**What you'll need**

* An InsurGrid account with Forms access enabled.
* An API key belonging to an **agency admin** ([where to get one](/get-started/03-api-credentials)), kept on your server.

> No webhook endpoint or tunnel needed here — this is API-only. To collect the submissions as well, do [Quickstart B](/intake-forms/b-embed-form) once your form is live.

| Environment | Base URL                                             |
| ----------- | ---------------------------------------------------- |
| Staging     | `https://phoenix-staging.insurgrid.com/api/v1/forms` |
| Production  | `https://phoenix.insurgrid.com/api/v1/forms`         |

Only the host differs — see [Environments](/guides/environments).

***

## 1. Create a draft

Every form belongs to one of your users. `externalUserId` says which, it's required, and it decides who can see the form from then on.

```js
const res = await fetch("https://phoenix-staging.insurgrid.com/api/v1/forms", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${process.env.INSURGRID_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    name: "Apartment Intake — Acme Agency",
    externalUserId: "user-8841",
    insurance_line: "commercial",
  }),
});

const form = await res.json();
console.log(form.id, form.public_id, form.status);   // "draft"
```

<details>

<summary>Python</summary>

```python
import os, requests

res = requests.post(
    "https://phoenix-staging.insurgrid.com/api/v1/forms",
    headers={"Authorization": f"Bearer {os.environ['INSURGRID_API_KEY']}"},
    json={
        "name": "Apartment Intake — Acme Agency",
        "externalUserId": "user-8841",
        "insurance_line": "commercial",
    },
)

form = res.json()
print(form["id"], form["public_id"], form["status"])   # "draft"
```

</details>

Forms are always created as drafts — you can't create one already live.

Because we left `steps` out, the form comes with a working default: a welcome step, a **contact info step** already collecting name, email and phone, and a confirmation step. That contact step matters more than it looks — see step 2.

| Field            | Required | Notes                                                                             |
| ---------------- | -------- | --------------------------------------------------------------------------------- |
| `externalUserId` | Yes      | Whose form this is. Missing → `422 external_user_required`.                       |
| `name`           | Yes      | Up to 255 characters.                                                             |
| `slug`           | No       | e.g. `home-intake`. Generated from `name` if omitted, with a short random suffix. |
| `insurance_line` | No       | `personal` (default) or `commercial`.                                             |
| `steps`          | No       | The pages and questions. Omit for the default above.                              |
| `redirect_url`   | No       | Where to send the customer after they submit.                                     |
| `branding`       | No       | Logo and colours.                                                                 |

### Starting from something

Rather than an empty form:

```bash
# Ready-made starter forms
curl 'https://phoenix-staging.insurgrid.com/api/v1/forms/templates' \
  -H 'Authorization: Bearer YOUR_API_KEY'

# ACORD forms and their questions
curl 'https://phoenix-staging.insurgrid.com/api/v1/forms/acord-catalog?line=personal' \
  -H 'Authorization: Bearer YOUR_API_KEY'
```

> ⚠️ **`line` is required on `acord-catalog`.** Without it you get `422 validation_error`, and an unrecognised value is a `400 invalid_request`. Use `personal` or `commercial`.

## 2. Give it questions

A form is a list of **steps** — the pages someone moves through — each holding **questions**.

```json
{
  "steps": [
    {
      "type": "custom",
      "name": "About your business",
      "segment": "before",
      "position": 0,
      "questions": [
        { "type": "short_text", "label": "Business name", "required": true, "position": 0 },
        { "type": "dropdown", "label": "Number of employees", "position": 1,
          "options": [{ "label": "1-10" }, { "label": "11-50" }, { "label": "50+" }] }
      ]
    }
  ]
}
```

On a step, `type`, `name`, `segment` and `position` are all required — `segment` is the one people miss, since it has no default. On a question, `type`, `label` and `position` are required. The full field tables are on the [Forms API](/intake-forms/forms) page.

**Step types:** `welcome`, `contact_info`, `custom`, `confirmation`, `complete`

**Question types:** `short_text`, `long_text`, `email`, `number`, `date`, `dropdown`, `single_choice`, `multi_choice`, `file_upload`, `address`, `data_table`

Send it with `PATCH`:

```bash
curl -X PATCH 'https://phoenix-staging.insurgrid.com/api/v1/forms/4412' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -H 'Content-Type: application/json' \
  -d '{ "steps": [ … ] }'
```

> ⚠️ **`steps` replaces, it doesn't merge.** Send the complete list — anything you leave out is deleted.

### Don't lose the contact block

That replacement includes the default **contact info** step you got in step 1 — and with it, the tags that populate the `contact` object on every submission webhook.

InsurGrid fills `contact.name`, `contact.email` and `contact.phone` from questions carrying a `field_mapping`, not from a question's type or label. Replace `steps` without tagging your own questions and every webhook from then on arrives with:

```json
"contact": { "name": null, "email": null, "phone": null }
```

Nothing errors, and the answers are still in `answers[]` — only `contact` goes empty, which is easy to miss until something downstream quietly stops working.

So tag them:

```json
{ "type": "short_text", "label": "Full name", "position": 0, "field_mapping": "FullName" },
{ "type": "email",      "label": "Email",     "position": 1, "field_mapping": "Email" },
{ "type": "short_text", "label": "Phone",     "position": 2, "field_mapping": "Phone" }
```

Or keep a `contact_info` step in your own structure and let it do the job.

The shape you send is exactly what `GET /forms/{id}` returns, so the quickest way to learn it is to build one form the way you want it **in the embedded builder**, fetch it, and work from that. Forms built in the InsurGrid dashboard won't do — this API can't see them.

`externalUserId` can't be changed after creation — it isn't a field on the update call. On `PATCH`, unknown fields are rejected, so a misspelled key is a `422`.

## 3. Publish it

```bash
curl -X PUT 'https://phoenix-staging.insurgrid.com/api/v1/forms/4412/publish' \
  -H 'Authorization: Bearer YOUR_API_KEY'
```

A **`PUT`**, not a `POST`. The form becomes `active` and its `shareUrl` — `null` until now — is filled in with the address people visit to fill it in.

Later, `PUT /{id}/archive` takes it out of use and `PUT /{id}/reactivate` brings it back.

> ⚠️ **Publishing replaces the live form in place.** Forms aren't versioned, so if you need to keep an older structure, copy it first with `POST /forms/{id}/duplicate`.

## 4. Put it in front of people

Your form is live at its `shareUrl`. Share it and start receiving submissions — that's [Quickstart B](/intake-forms/b-embed-form).

> Carrier-connected forms, which log into a carrier to pull declaration pages, can't be created here — the request is rejected with `422 carrier_flow_disabled`. Build those in the dashboard.

***

## Next steps

* [Share a form & receive submissions](/intake-forms/b-embed-form)
* [Forms API](/intake-forms/forms) — the full field, error and rate-limit reference
* [Embed the form builder](/intake-forms/builder-sessions) — let your users build visually instead
* [Environments](/guides/environments)


# Forms API

Create, edit and publish intake forms from your own code, on behalf of your own users.

Intake forms collect what you need from a prospect before you can quote them. This API lets you build and manage those forms from your own application, so your users never have to leave your product to do it.

Each form belongs to one of **your** users. They see their own forms and nobody else's — you don't create InsurGrid accounts for them, and you don't manage permissions. You tell InsurGrid which of your users a form belongs to, and it handles the separation.

If you'd rather your users built forms visually than through code, the same forms are available in an embeddable builder — see [Embed the form builder](/intake-forms/builder-sessions). The two work on the same data.

> **Submissions don't come back through this API.** When someone completes a form, the submission is delivered to your webhook endpoint. See [Submissions](/intake-forms/submissions).

***

## Before you start

### Your API key

Every request carries your key as a bearer token:

```
Authorization: Bearer YOUR_API_KEY
```

Create and revoke keys in the dashboard under **Settings → API keys**. Two things to know:

* **The key must belong to an agency admin**, otherwise you get `403 admin_required`.
* **Forms access must be enabled** for the agent who owns the key. If it isn't, every call returns `403 feature_disabled` — contact us to switch it on.

Revoking a key stops the API and any live embeds on their next request. See [Get your API credentials](/get-started/03-api-credentials).

### Environments

|                      | Staging                                            | Production                                 |
| -------------------- | -------------------------------------------------- | ------------------------------------------ |
| API base URL         | `https://phoenix-staging.insurgrid.com/api/v1`     | `https://phoenix.insurgrid.com/api/v1`     |
| Embed script         | `https://dashboard-staging.insurgrid.com/embed.js` | `https://dashboard.insurgrid.com/embed.js` |
| Dashboard (API keys) | `https://dashboard-staging.insurgrid.com`          | `https://dashboard.insurgrid.com`          |

Build against staging, then change the host and the key. The two are fully separate: a staging key works only on staging, and a form created on staging doesn't exist on production. More in [Environments](/guides/environments).

### Which forms this API can see

`externalUserId` is how you tell InsurGrid whose form this is — a stable id from your own system, such as your internal user id. Any string up to 255 characters. You set it when you create a form; it can't be changed afterwards, and it's what decides who can see the form from then on.

A form is tied to two things: that `externalUserId`, and **the key that created it**. Both have to match for a form to be visible, which has two consequences worth planning around:

* **Forms built in the InsurGrid dashboard are invisible here.** They have no `externalUserId`, so they never appear in `GET /forms`, and `GET /forms/{id}` returns `404`. Only forms created through this API or the embedded builder are reachable.
* **Keys don't share forms, even inside one agency.** A form created with one admin's key returns `404` to another admin's key. Use one key consistently for a given integration, and don't expect a replacement key to inherit the forms the old one made — if you rotate, you'll be starting a new set.

***

## Quick start

**Create a draft:**

```bash
curl -X POST https://phoenix-staging.insurgrid.com/api/v1/forms \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "externalUserId": "user-8841", "name": "Home insurance intake" }'
```

Leave out `steps` and the form starts with a working default: a welcome step, a **contact info step** that already collects name, email and phone, and a confirmation step. The response carries the form's `id`.

**Publish it:**

```bash
curl -X PUT https://phoenix-staging.insurgrid.com/api/v1/forms/123/publish \
  -H "Authorization: Bearer YOUR_API_KEY"
```

The response now includes `shareUrl` — the public link you send to customers, or embed. For a walkthrough that goes further, see [Quickstart C](/intake-forms/c-build-forms-api).

***

## Endpoints

| Method   | Path                         | What it does                                   |
| -------- | ---------------------------- | ---------------------------------------------- |
| `GET`    | `/forms`                     | List forms.                                    |
| `POST`   | `/forms`                     | Create a draft form.                           |
| `GET`    | `/forms/{id}`                | Get one form with all its steps and questions. |
| `PATCH`  | `/forms/{id}`                | Update a form.                                 |
| `DELETE` | `/forms/{id}`                | Delete a form.                                 |
| `POST`   | `/forms/{id}/duplicate`      | Copy a form for the same user.                 |
| `PUT`    | `/forms/{id}/publish`        | Publish a form and get its `shareUrl`.         |
| `PUT`    | `/forms/{id}/archive`        | Take a form out of use.                        |
| `PUT`    | `/forms/{id}/reactivate`     | Make an archived form active again.            |
| `GET`    | `/forms/templates`           | Starter forms you can build from.              |
| `GET`    | `/forms/acord-catalog?line=` | ACORD forms and questions. `line` is required. |
| `POST`   | `/embed-sessions`            | A short-lived token for the embedded builder.  |

### Listing forms

```
GET /forms?externalUserId=user-8841&limit=50&offset=0
```

Everything is optional. Omit `externalUserId` to list across all of your users. `limit` runs 1–100 and defaults to 50.

Each entry carries:

| Field                                             | What it is                                                   |
| ------------------------------------------------- | ------------------------------------------------------------ |
| `id`                                              | The form's id, used in the paths above.                      |
| `public_id`                                       | The public identifier used in form links and embeds.         |
| `name`, `slug`                                    | The name, and the readable part of its web address.          |
| `status`                                          | `draft`, `active` or `archived`.                             |
| `externalUserId`                                  | Which of your users owns it.                                 |
| `shareUrl`                                        | Where to send people to fill it in — `null` until published. |
| `acord_numbers`, `policy_types`, `is_acord_ready` | ACORD mapping, if any.                                       |
| `created_at`, `updated_at`                        | Timestamps.                                                  |

`GET /forms/{id}` returns all of that plus the form itself: `steps`, `insurance_line`, `branding`, `redirect_url`, `auto_response_enabled`, `tracking` and `carrier_flow_enabled`.

### Creating a form

| Field            | Required | Notes                                                                                                                                                                   |
| ---------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `externalUserId` | Yes      | Whose form this is.                                                                                                                                                     |
| `name`           | Yes      | Up to 255 characters.                                                                                                                                                   |
| `slug`           | No       | e.g. `home-intake`. Generated from `name` if omitted, with a short random suffix to keep it unique. Lower-case words separated by single hyphens, up to 100 characters. |
| `insurance_line` | No       | `personal` (default) or `commercial`.                                                                                                                                   |
| `steps`          | No       | The pages and questions. See below.                                                                                                                                     |
| `redirect_url`   | No       | Where to send the customer after they submit.                                                                                                                           |
| `branding`       | No       | Logo and colours.                                                                                                                                                       |

Forms are always created as drafts. Publishing is a separate call.

### Updating a form

`PATCH /forms/{id}` with only the fields you want to change. On update, unknown fields are rejected rather than ignored — a misspelled key is a `422`. (On create they're ignored, so a typo there passes silently.)

> ⚠️ **`steps` replaces rather than merges.** If you send it, send the complete list — any step you leave out is deleted, **including the default contact info step**. See [Keeping the contact block populated](#keeping-the-contact-block-populated).

### Statuses

| Status     | Meaning                               |
| ---------- | ------------------------------------- |
| `draft`    | Not live. `shareUrl` is `null`.       |
| `active`   | Published. `shareUrl` is set.         |
| `archived` | No longer live. `shareUrl` is `null`. |

***

## How a form is structured

A form is a list of **steps** — the pages someone moves through — and each step holds **questions**.

```json
{
  "externalUserId": "user-8841",
  "name": "Business intake",
  "steps": [
    {
      "type": "custom",
      "name": "About your business",
      "segment": "before",
      "position": 0,
      "questions": [
        { "type": "short_text", "label": "Business name", "required": true, "position": 0 },
        { "type": "dropdown", "label": "Number of employees", "position": 1,
          "options": [{ "label": "1-10" }, { "label": "11-50" }, { "label": "50+" }] }
      ]
    }
  ]
}
```

### Step fields

| Field       | Required | Notes                                                                                                                          |
| ----------- | -------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `type`      | Yes      | `welcome`, `contact_info`, `custom`, `confirmation` or `complete`. Only one of each non-`custom` type per form.                |
| `name`      | Yes      | Up to 255 characters.                                                                                                          |
| `segment`   | Yes      | Where the step sits in the flow: `before`, `carrier` or `after`. Easy to miss — it has no default, and omitting it is a `422`. |
| `position`  | Yes      | Order within the form, 0–1000.                                                                                                 |
| `enabled`   | No       | Defaults to `true`.                                                                                                            |
| `questions` | No       | The step's questions. Up to 200.                                                                                               |

A form must contain at least one `custom` or `contact_info` step — a form made only of welcome and confirmation pages collects nothing, and is rejected.

### Question fields

| Field              | Required             | Notes                                                                                                           |
| ------------------ | -------------------- | --------------------------------------------------------------------------------------------------------------- |
| `type`             | Yes                  | One of the question types below.                                                                                |
| `label`            | Yes                  | The question text, up to 500 characters.                                                                        |
| `position`         | Yes                  | Order within the step, 0–1000.                                                                                  |
| `required`         | No                   | Defaults to `false`.                                                                                            |
| `placeholder`      | No                   | Up to 500 characters.                                                                                           |
| `field_mapping`    | No                   | Tags this question as carrying a known value. **This is what fills the webhook's `contact` block** — see below. |
| `options`          | For choice questions | Up to 500. Required for `dropdown`, `single_choice` and `multi_choice`.                                         |
| `initially_hidden` | No                   | Hide the question until a rule reveals it.                                                                      |
| `rules`            | No                   | Conditional logic, up to 100 per question.                                                                      |

**Step types:** `welcome`, `contact_info`, `custom`, `confirmation`, `complete`

**Question types:** `short_text`, `long_text`, `email`, `number`, `date`, `dropdown`, `single_choice`, `multi_choice`, `file_upload`, `address`, `data_table`

A form can hold up to 50 steps.

Rather than assembling this by hand, start from `GET /forms/templates`, or build a form the way you want it **in the embedded builder** and fetch it with `GET /forms/{id}` — what comes back is exactly what you can send. (Forms built in the InsurGrid dashboard won't work for this; see [Which forms this API can see](#which-forms-this-api-can-see).)

> **Carrier-connected forms** — the ones that log into a carrier to pull declaration pages — can't be created or enabled through this API. Build those in the dashboard.

### Keeping the contact block populated

Every submission webhook carries a `contact` object with the submitter's name, email and phone. InsurGrid fills it from questions tagged with `field_mapping` — **not** from a question's type, label or position.

| `field_mapping` | Fills           |
| --------------- | --------------- |
| `FullName`      | `contact.name`  |
| `Email`         | `contact.email` |
| `Phone`         | `contact.phone` |
| `ServiceType`   | —               |
| `EventDate`     | —               |

A form created without `steps` gets a contact info step whose three questions are already tagged, so `contact` works out of the box. **The moment you send your own `steps`, that step is gone** — along with its tags — and every webhook from then on arrives with:

```json
"contact": { "name": null, "email": null, "phone": null }
```

Nothing errors, and the answers are still there in `answers[]`. Only `contact` goes empty, so it is easy to miss until something downstream stops working.

So when you build your own structure, tag the equivalent questions:

```json
{ "type": "email", "label": "Your email", "required": true,
  "position": 1, "field_mapping": "Email" }
```

If two questions share a mapping, the first non-empty answer wins.

***

## Errors

Every error has the same shape:

```json
{ "error": { "code": "not_found", "message": "Form not found." } }
```

| Status | Code                     | What went wrong                                    |
| ------ | ------------------------ | -------------------------------------------------- |
| 400    | `invalid_request`        | The request is malformed.                          |
| 401    | `invalid_api_key`        | The key is missing, wrong or revoked.              |
| 401    | `invalid_embed_token`    | The embed token is missing or expired.             |
| 403    | `admin_required`         | The key doesn't belong to an agency admin.         |
| 403    | `feature_disabled`       | Forms aren't enabled for the key's agent.          |
| 403    | `scope_required`         | The key isn't scoped for this API.                 |
| 404    | `not_found`              | No such form, or it isn't visible to this key.     |
| 409    | `conflict`               | That slug is already taken.                        |
| 422    | `validation_error`       | A field is invalid — see `details`.                |
| 422    | `external_user_required` | `externalUserId` missing on create.                |
| 422    | `carrier_flow_disabled`  | Carrier steps aren't supported here.               |
| 429    | `rate_limited`           | Too many requests. Wait for `Retry-After` seconds. |
| 503    | `auth_unavailable`       | A temporary problem. Try again.                    |

## Rate limits

* **300 requests per minute**, counted **per agency** and shared across every API key the agency holds.
* **60 embed tokens per minute**, counted per API key.

Calls the embedded builder makes with a token are counted separately, per end user, so they don't eat into the 300.

Over any of them you get `429` with a `Retry-After` header.

## Related

* [Quickstart C — build forms via the API](/intake-forms/c-build-forms-api)
* [Embed the form builder](/intake-forms/builder-sessions)
* [Submissions](/intake-forms/submissions)
* [Environments](/guides/environments)


# Embed the form builder

Let your users build and edit their own intake forms, inside your product, without an InsurGrid account.

Your users need to change their intake forms — add a question, reword one, publish a new version. You could build that editor yourself, or you can drop InsurGrid's into a page in your product.

It appears inside your own interface, and each signed-in user sees only their own forms. They never create an InsurGrid account, never see a login screen, and never leave your product.

> **This is the builder, not the form itself.** The form your *customers* fill in is a different embed with no login at all — see [Quickstart B](/intake-forms/b-embed-form), or [The two embeds](/guides/embeds) if you're deciding between them.

***

## How it fits together

The builder needs to know which of your users is looking at it. It can't ask you directly — it runs in the browser, and your API key must never go there. So a short-lived token stands in.

```
  Your page  ── asks your server for a token ──▶  Your server
                                                       │
                                                       │  calls InsurGrid
                                                       │  with your API key
                                                       ▼
  Your page  ◀────── token ─────────────────  InsurGrid
      │
      └─▶ the embed script opens the builder with that token
```

The token is good for 15 minutes and for one user. Your API key stays on your server throughout.

That means there are three things to add: a **snippet** on the page, a **route** on your server, and your **key** in configuration.

***

## 1. Put your key in configuration

Server-side environment variables — never shipped to the browser.

```bash
# Staging
INSURGRID_API_KEY=your-staging-api-key
INSURGRID_API_URL=https://phoenix-staging.insurgrid.com/api/v1

# Production
INSURGRID_API_KEY=your-production-api-key
INSURGRID_API_URL=https://phoenix.insurgrid.com/api/v1
```

Create each key in that environment's dashboard, under **Settings → API keys** — [staging](https://dashboard-staging.insurgrid.com) or [production](https://dashboard.insurgrid.com). The key must belong to an agency admin.

## 2. Add a route that hands out tokens

One endpoint on your backend. It has three jobs: check the user is signed in, tell InsurGrid who they are, and pass the answer back.

```js
app.get('/insurgrid-token', requireLogin, async (req, res) => {
  const response = await fetch(`${process.env.INSURGRID_API_URL}/embed-sessions`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.INSURGRID_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ externalUserId: String(req.user.id) }),
  });
  res.set('Cache-Control', 'no-store').status(response.status).json(await response.json());
});
```

<details>

<summary>Python (Flask)</summary>

```python
@app.get("/insurgrid-token")
@login_required
def insurgrid_token():
    response = requests.post(
        f"{os.environ['INSURGRID_API_URL']}/embed-sessions",
        headers={"Authorization": f"Bearer {os.environ['INSURGRID_API_KEY']}"},
        json={"externalUserId": str(current_user.id)},
        timeout=10,
    )
    return response.json(), response.status_code, {"Cache-Control": "no-store"}
```

</details>

<details>

<summary>PHP</summary>

```php
// This route must run after your application's login middleware.
$response = $http->post(getenv('INSURGRID_API_URL') . '/embed-sessions', [
    'headers' => ['Authorization' => 'Bearer ' . getenv('INSURGRID_API_KEY')],
    'json'    => ['externalUserId' => (string) $currentUser->id],
]);

return response($response->getBody(), $response->getStatusCode())
    ->header('Content-Type', 'application/json')
    ->header('Cache-Control', 'no-store');
```

</details>

InsurGrid replies with `201` and a token. Pass it back unchanged:

```json
{ "token": "emb_…", "expiresAt": "2026-09-30T12:15:00Z" }
```

Three things that matter here:

* **Take `externalUserId` from your login session**, never from a query string or request body. It decides whose forms appear.
* **Send `Cache-Control: no-store`**, so no browser or proxy keeps someone's token and serves it to the next person.
* **Keep the route behind your login.** It is the only thing standing between a visitor and a user's forms.

## 3. Add the snippet to the page

```html
<div id="insurgrid-forms"></div>
<script
  src="https://dashboard-staging.insurgrid.com/embed.js"
  data-token-url="/insurgrid-token">
</script>
```

Swap the host for `dashboard.insurgrid.com` in production. `data-token-url` is the route you just built.

> **The token route must be on the same origin as the page.** The script fetches it with same-origin credentials, which is how your login cookie reaches it — a route on another domain receives no cookie and can't tell who's asking. If your token endpoint has to live elsewhere, mount the embed yourself and fetch the token however you like; see [If your app is a single-page app](#if-your-app-is-a-single-page-app) below.

That's it. The builder appears, sized to its content, and refreshes its own token before each one expires.

### Adjusting it

| Attribute         | What it does                                                                  |
| ----------------- | ----------------------------------------------------------------------------- |
| `data-target`     | CSS selector for the container. Defaults to `#insurgrid-forms`.               |
| `data-view`       | `list` (default) shows their forms; `builder` opens the editor straight away. |
| `data-form-id`    | With `data-view="builder"`, which form to open.                               |
| `data-min-height` | Starting height in pixels. Defaults to 640.                                   |

***

## Reacting to what your users do

The embed raises ordinary browser events on the container, so you can refresh your own screen or record what happened. There's no `message` listener to write and no origin to check.

```js
document.querySelector('#insurgrid-forms')
  .addEventListener('insurgrid:form-published', (e) => {
    console.log(e.detail.formId, e.detail.shareUrl);
  });
```

| Event                      | Raised when                                     | `event.detail`         |
| -------------------------- | ----------------------------------------------- | ---------------------- |
| `insurgrid:ready`          | The builder has loaded and been handed a token. | —                      |
| `insurgrid:form-saved`     | They saved a draft.                             | `{ formId, shareUrl }` |
| `insurgrid:form-published` | They published a form.                          | `{ formId, shareUrl }` |
| `insurgrid:close`          | They backed out of the builder.                 | —                      |
| `insurgrid:error`          | Something went wrong.                           | `{ code }`             |

`insurgrid:ready` means the token has been handed over, not that it has been checked — a rejected token arrives moments later as `insurgrid:error`. If you hide a loading state on `ready`, handle `error` too.

## If your app is a single-page app

A React, Vue or Angular app — or any login that uses a bearer token rather than cookies, or a token endpoint on another origin — should mount the embed itself rather than relying on `data-token-url`:

```html
<script src="https://dashboard.insurgrid.com/embed.js"></script>
```

```js
const embed = InsurGrid.mount(document.querySelector('#insurgrid-forms'), {
  view: 'list',
  getToken: async () => {
    const response = await fetch('/insurgrid-token', {
      headers: { Authorization: `Bearer ${yourPortalToken}` },
    });
    if (!response.ok) throw new Error('Unable to start InsurGrid');
    return response.json();
  },
});

// when the component unmounts
embed.destroy();
```

`getToken` is yours — fetch from wherever you like, with whatever headers you need. Just return the `{ token, expiresAt }` InsurGrid gave your server, unchanged.

Call `destroy()` on unmount, or the embed keeps refreshing its token in the background.

***

## Going live

* [ ] It works end to end on staging — token route, builder loads, publishing succeeds.
* [ ] A production key exists and is stored on your server.
* [ ] `INSURGRID_API_URL` points at `https://phoenix.insurgrid.com/api/v1`.
* [ ] The script URL points at `https://dashboard.insurgrid.com/embed.js`.
* [ ] The token route is behind your login, and never returns your API key.

## Worth knowing

Your users can only ever see and edit their own forms. That comes from the token, which comes from your login session — nothing the browser sends can change it.

Carrier-connected forms, which log into a carrier to pull declaration pages, can't be created or switched on from the builder embed or the API. Those are built in the InsurGrid dashboard.

## Related

* [Forms API](/intake-forms/forms) — the same forms from your own code, with the full error and rate-limit reference.
* [The two embeds](/guides/embeds) — builder versus the form customers fill in.
* [Environments](/guides/environments)


# Submissions

Completed forms are delivered to you, not fetched by you.

When someone completes one of your forms, the submission is **delivered** to your webhook endpoint as a `form-submission` event. That event carries everything: the contact details, every answer, links to any uploaded files, and the filled ACORD forms where the form maps onto them.

> ⚠️ **The Forms API doesn't return submissions.** There is no route under `/api/v1/forms` to list them or fetch one by id — the webhook delivery is how a submission reaches your systems.

That shapes how you should build against it:

**Treat your handler as the front door.** It's where a submission enters your system, not a notification that something is waiting to be collected. Write it down when it arrives.

**Download files when they land.** The `url` on a `file_upload` answer, and `acord.archive_url`, are unsigned links to a public bucket — they don't expire, but anyone holding one can download the file without authenticating, and a `url` becomes `null` once the document is no longer available. Fetch the file, store it where you control access, and don't pass the link around.

**Make it safe to run twice.** Delivery is at-least-once, so the same event can arrive again. `data.submission_id` is the key to de-duplicate on.

Every submission also appears in your InsurGrid dashboard, where you can read the answers and download files by hand — useful for support lookups, but not a substitute for storing what you need at delivery.

The payload, the authentication options and a working handler are all in [Quickstart B](/intake-forms/b-embed-form).

## Related

* [Quickstart B — share a form & receive submissions](/intake-forms/b-embed-form)
* [Delivery & de-duplication](/guides/delivery-dedup)
* [Forms API](/intake-forms/forms) — the forms that produce submissions


# Tracking & analytics

Your intake form can tell your advertising and analytics tools what is happening on it — that someone opened the form, and that someone finished it. Switch it on and you can answer questions like:

* Which Facebook ad actually produced this week's leads — not just who clicked, but who completed a form.
* How many people open the form compared with how many finish it.
* Whether the landing page you are paying for sends you people who convert.

Without it, your ad platform only ever learns that someone clicked. It never finds out what happened next.

{% hint style="info" %}
Tracking is off until you turn it on, and it is set per form. A form with tracking off loads no advertising code at all.
{% endhint %}

***

## Where the setting lives

1. Open the form in the **form builder**.
2. Click **Tracking** in the row of buttons across the top, next to **Branding** and **Notifications**. A panel opens on the right.
3. Tick **Enable tracking**, fill in at least one of the three boxes, and save.

Only agency **admins and owners** see that button. Everyone else can open the builder but cannot change these settings.

You need at least one of the three: a Meta Pixel ID, a Google Tag Manager container ID, or a custom snippet. Ticking the box with all three empty will not save — you will see "Enabling tracking requires a tracking provider."

The settings apply to people who open the form after you save. If the form is still a draft, publish it.

***

## Meta Pixel — for Facebook and Instagram ads

A **Meta Pixel** is a small piece of code Meta gives you so that Facebook and Instagram can see what people do after they click one of your ads. Each pixel is identified by a number: the Pixel ID.

### Find your Pixel ID

1. Go to **business.facebook.com** and open **Events Manager**.
2. In the left sidebar, click **Data sources**.
3. Your pixel is in that list. Underneath its name is a long number — usually 15 or 16 digits, something like `1234567890123456`. That is the Pixel ID.

If the list is empty, whoever runs your ads has to create a pixel first. Ask them for the number rather than guessing at it.

### Put it in the form

Paste the number into **Meta Pixel ID** and save. Digits only — no spaces, no letters, no "Pixel" in front of it. If you paste anything else, red text appears under the box telling you the ID must be 1–30 digits, and the form will not save until you fix it.

### What InsurGrid then sends Meta

| When                                         | What Meta receives |
| -------------------------------------------- | ------------------ |
| Someone opens your form                      | `PageView`         |
| Someone fills in the first page and it saves | *nothing*          |
| Someone submits the finished form            | `Lead`             |

`Lead` is the event Meta's advertising tools use to count and optimise conversions, so your ad reporting starts showing completed forms rather than only clicks.

**What is never sent:** the answers. No names, no addresses, no dates of birth, no vehicle or property details, no uploaded files. All that travels with an event is the form's public ID — an identifier for the form itself, the same for everyone who fills it in. InsurGrid also switches off Meta's "automatic advanced matching", which would otherwise read what people type into the fields.

### Confirm it is working

Go back to **Events Manager → Data sources**, select your pixel, and open the **Test events** tab. Open your own form in another tab and submit it. One `PageView` should appear when the page loads and one `Lead` when you submit.

Events Manager is where you check this. There is no tracking report inside InsurGrid. The **Overview** tab can lag by several minutes; **Test events** shows events as they arrive.

***

## Google Tag Manager

**Google Tag Manager (GTM)** is a container that sits on a page and holds all of your other tracking tags inside it. Whoever handles your marketing adds and changes tags on Google's website, and those changes go live without anyone touching the form. If your marketing agency has asked you for "the GTM container ID", this is the box it goes in.

### Find your container ID

Sign in at **tagmanager.google.com** and open your container. The ID sits in the bar across the top of the screen, beside the container name, and looks like `GTM-ABC1234`. It always begins with `GTM-`.

### Put it in the form

Paste it into **GTM Container ID** and save. It has to be `GTM-` followed by 4 to 20 capital letters or numbers — lowercase letters or a stray space will be refused with red text under the box.

### What InsurGrid pushes into your container

InsurGrid loads your container on the form and announces three moments to it:

| Event name                 | Happens when                                   |
| -------------------------- | ---------------------------------------------- |
| `insurgrid_form_view`      | Someone opens the form.                        |
| `insurgrid_form_started`   | Someone completes the first page and it saves. |
| `insurgrid_form_completed` | Someone submits the finished form.             |

Inside GTM, your marketing person creates **Custom Event** triggers using those exact names and points whichever tags they like at them — a Google Ads conversion, GA4, LinkedIn, and so on. As with Meta, no answers are included; only the event name and the form's public ID.

The middle event is the one that tells you how many people begin the form and never finish it.

### Confirm it is working

In GTM, click **Preview** at the top right and enter your form's public link. A second window opens with your form in it, plus a Tag Assistant panel. Step through the form there. Events appear in a list down the left-hand side as they happen: `insurgrid_form_view` when the page loads, `insurgrid_form_completed` once you submit. Clicking an event shows which of your tags fired on it.

***

## Custom tracking snippet

**This box is for a developer.** If you are not one, do not fill it in yourself — the wrong code here runs on the page your real customers are using.

It exists for tracking tools that are neither Meta nor GTM: a call-tracking product, a different ad network, your own analytics. Those vendors hand you a snippet of code and tell you to install it on your website; this is where that snippet goes for an InsurGrid form.

**What to do:** send two things to whoever looks after your website — your developer, or your marketing agency. First, the snippet your vendor gave you. Second, a link to [Building a custom tracking script](/intake-forms/tracking-and-analytics/custom-tracking-script), which tells them exactly how to hook into the three events above. They can paste the finished snippet in themselves, or hand it back for you to paste.

What the box will and will not take, in plain terms:

* Only `<script>` tags. Other kinds of HTML are refused.
* Any address a script loads from must start with `https://`.
* 10,000 characters maximum — roughly two printed pages of code.
* A short list of unusual coding patterns is refused outright, which rules out a small number of vendor snippets. A developer can almost always work around it.

If what you paste is not acceptable, saving fails and an error explains what was rejected. Nothing is half-applied. That check is a safety rail rather than a review: the code still runs on a page where your customers are typing personal information, so only paste code from a vendor you trust.

***

## How do I know it is working?

1. Publish the form, if it is not published already.
2. Open its public link yourself, in an ordinary browser window.
3. Fill it in and submit it properly, all the way through to the confirmation screen. A half-finished form is not a completion and will not produce a `Lead`.
4. Check **Meta Events Manager → Test events**, or **GTM Preview** — whichever you set up.

{% hint style="info" %}
**Each event counts once per visit.** Reloading the page, or filling the form in again in the same browser window, will not produce a second event. That is deliberate, so one person cannot inflate your numbers. To test a second time, open a **new incognito or private window** — that starts a fresh session and the events fire again.
{% endhint %}

A few other things worth knowing while you test:

* On forms that hand the customer over to their insurance carrier to log in, the completion event fires when they come back to the confirmation page, not at the moment they leave.
* Ad blockers and stricter privacy settings block Meta and Google outright for some visitors. Nothing arrives for those people, and that is outside InsurGrid's control — expect your tracked totals to sit a little below your real submission count.
* If you change a Pixel ID or container ID later, the new setting applies to people who open the form after you save.

***

## Related

* [Building a custom tracking script](/intake-forms/tracking-and-analytics/custom-tracking-script) — for your developer, if you are using the custom snippet box.
* [Webhook events & payloads](/api-reference/webhook-events) — how the actual answers reach your systems.
* [Submissions API](/intake-forms/submissions) — pull submissions on demand.
* [Embed a form](/intake-forms/b-embed-form) — putting the form in your own website or app.


# Building a custom tracking script

This page is for the developer or marketing agency writing the snippet that goes in a form's **Custom tracking HTML** box. The non-technical side — where that box lives and who can edit it — is in [Tracking & analytics](/intake-forms/tracking-and-analytics).

***

## Do you actually need one?

If the tool you are wiring up is **Meta Pixel** or **Google Tag Manager**, you almost certainly don't. Both have their own field in the Tracking panel, and InsurGrid loads and fires them for you — Meta gets a `PageView` on open and a `Lead` on submission; a GTM container receives all three events below, pushed to the standard `window.dataLayer` under their `insurgrid_` names, ready to hang Custom Event triggers on.

A custom script is for everything else: a call-tracking product, an ad network that isn't Meta, your own analytics endpoint.

{% hint style="info" %}
If a GTM container is already configured on the form, prefer doing the work inside GTM. A tag triggered on `insurgrid_form_completed` in your own container is easier to change later than a snippet pasted into the form.
{% endhint %}

***

## What the box accepts

Worth reading before you write anything — these limits shape the snippet:

* **`<script>` tags only**, with either a `src` or inline JavaScript. At least one script is required.
* A `src` must be **HTTPS**.
* Allowed script attributes: `async`, `crossorigin`, `defer`, `id`, `integrity`, `referrerpolicy`, `src`, `type`. Everything else — including every `on*` handler — is rejected.
* **10,000 bytes** total.

Inline JavaScript is parsed (never executed) and refused if it uses `eval`, `Function`, `setTimeout`, `setInterval`, `atob`, `document.write`, `document.writeln`, `Worker`, `WebAssembly`, `with`, or `debugger`. Most vendor snippets pass. A few that self-schedule with `setTimeout` do not — load those from a `src` URL instead of inlining them.

Validation happens on save: a rejected snippet fails the whole save with an error naming what was refused, and nothing is partially applied.

**Load order is guaranteed.** A `<script src>` with neither `async` nor `defer` finishes loading before the next script is added, so a second script can rely on what the first one defined. Scripts marked `async` or `defer` get no such guarantee.

{% hint style="warning" %}
**`<noscript>` fallback pixels are not supported.** Many vendor snippets come in two halves — a `<script>` and a `<noscript><img></noscript>` tracking pixel for visitors without JavaScript. Paste only the `<script>` half. The `<noscript>` half will not run, and a pixel there records nothing.

That is not a limitation worth working around: the form is a JavaScript application and does not render at all with JavaScript disabled, so the only situation a `<noscript>` pixel exists for is one where nobody reaches the form in the first place.
{% endhint %}

{% hint style="warning" %}
This code runs on a page where consumers type names, addresses and policy details, and it has full access to that page. Only add HTML from a vendor you trust — the validation above is a guardrail, not an audit.
{% endhint %}

***

## The events you can hook into

The form publishes its lifecycle to a plain array at `window.insurgridDataLayer`. Three events, each **at most once per form, per browser session**:

| Event                      | Fires when                                                                                                            |
| -------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `insurgrid_form_view`      | The public form page loads.                                                                                           |
| `insurgrid_form_started`   | The consumer completes the first step and it saves successfully. A step that fails to save does not count as a start. |
| `insurgrid_form_completed` | The final step is accepted by the server — a real submission, not an attempt.                                         |

Every entry has the same two fields:

```json
{
  "event": "insurgrid_form_completed",
  "form_public_id": "kQ8vN2rT7xLpZ3mWyB6dCa"
}
```

Only the event name and the form's public ID — the same identifier that appears in the form's public URL, and the same for every visitor of that form. **Answers are never published**: no names, addresses, or uploaded files. If you need the submission data itself, that is server-to-server — the [`form-submission` webhook](/api-reference/webhook-events) or the [Submissions API](/intake-forms/submissions).

On forms that hand the consumer to a carrier login, `insurgrid_form_completed` fires on the confirmation page they return to, not at the moment they leave.

***

## Writing the script

The one thing to get right: **an event may already have fired before your script runs.** `insurgrid_form_view` is published in the same tick that installs your snippet, so anything fetched over the network arrives too late to witness it. A listener that only watches for future pushes will silently miss the view event on every single visit.

So do both — replay what is already in the array, then intercept later pushes:

```html
<script>
  (function () {
    var layer = (window.insurgridDataLayer = window.insurgridDataLayer || []);

    function handle(entry) {
      if (!entry || !entry.event) return;

      // Your code here.
      console.log('InsurGrid:', entry.event, entry.form_public_id);
    }

    layer.forEach(handle); // events that already fired

    var push = layer.push; // events still to come
    layer.push = function () {
      var result = push.apply(layer, arguments);
      for (var i = 0; i < arguments.length; i++) handle(arguments[i]);
      return result;
    };
  })();
</script>
```

That wrapper is the whole pattern. Everything below is what you put inside `handle`.

{% hint style="info" %}
When a GTM container is configured, the completion push carries an `eventCallback` with a 500ms `eventTimeout`, so the form holds navigation briefly for a tag that acknowledges it. A `sendBeacon` or an image ping has time to leave the browser; a slow `fetch` may be cut off by the page change. Don't put a long request on the completion path.
{% endhint %}

***

## A complete example

Two scripts: the vendor's tag, then your wiring. Because the first has a `src` and no `async`/`defer`, it is finished loading before the second is added — so `window.exampleTracker` exists by then. The guard is there for the case where the vendor's CDN fails, which lets loading continue rather than stalling the chain.

```html
<script src="https://cdn.example.com/tracker.js"></script>
<script>
  (function () {
    var layer = (window.insurgridDataLayer = window.insurgridDataLayer || []);

    function handle(entry) {
      if (!entry || !entry.event) return;
      if (!window.exampleTracker) return; // vendor script failed to load

      if (entry.event === 'insurgrid_form_view') {
        window.exampleTracker.page({ form: entry.form_public_id });
      }

      if (entry.event === 'insurgrid_form_completed') {
        window.exampleTracker.conversion({
          type: 'quote_request',
          form: entry.form_public_id,
        });
      }
    }

    layer.forEach(handle);

    var push = layer.push;
    layer.push = function () {
      var result = push.apply(layer, arguments);
      for (var i = 0; i < arguments.length; i++) handle(arguments[i]);
      return result;
    };
  })();
</script>
```

Posting a conversion to your own endpoint instead, with no vendor script at all:

```html
<script>
  (function () {
    var layer = (window.insurgridDataLayer = window.insurgridDataLayer || []);

    function handle(entry) {
      if (entry && entry.event === 'insurgrid_form_completed') {
        navigator.sendBeacon(
          'https://analytics.example.com/collect',
          JSON.stringify({ type: 'quote_request', form: entry.form_public_id }),
        );
      }
    }

    layer.forEach(handle);

    var push = layer.push;
    layer.push = function () {
      var result = push.apply(layer, arguments);
      for (var i = 0; i < arguments.length; i++) handle(arguments[i]);
      return result;
    };
  })();
</script>
```

Note there is no `setTimeout` anywhere in either example — it would be rejected on save. If your vendor's snippet retries or defers on a timer, host that logic in the file you load over `src` rather than inlining it.

***

## Testing

1. Publish the form and open its public link with the browser console open.
2. Inspect `window.insurgridDataLayer` — it lists what has fired so far.
3. Step through the form and submit it. You should see exactly one entry per event, and your own handler firing on each.

If an event never reaches your handler, the usual cause is the timing problem above: confirm your snippet replays the existing array rather than only wrapping `push`.

Because events deduplicate per session, a second run in the same window produces nothing. Use a **new incognito window** for each test pass, not a page refresh.

***

## Related

* [Tracking & analytics](/intake-forms/tracking-and-analytics) — setting tracking up, and the Meta and GTM fields.
* [Webhook events & payloads](/api-reference/webhook-events) — the `form-submission` event, which carries the answers.
* [Submissions API](/intake-forms/submissions) — pull submissions on demand.
* [Embed a form](/intake-forms/b-embed-form) — putting the form in your own app.


# Guides

One concept at a time, in plain language. Read the quickstart for your use case first, then dip into these as you need them.

* Authentication & tokens
* Register your endpoint & choose an auth type
* Verify a webhook signature
* Delivery & de-duplication
* Idempotency keys
* Pagination
* Errors & status codes
* Rate limits
* Ownership model (agency vs agent)
* Form structure & conditional logic
* The two embeds (builder vs fill form)


# Environments

Staging and production run the same API at different addresses.

There are two InsurGrid environments. They run the same code and accept the same requests — **only the addresses and the credentials differ**. Build against staging, prove it works, then change the host and the key.

## Addresses

| What                                       | Staging                                   | Production                        |
| ------------------------------------------ | ----------------------------------------- | --------------------------------- |
| API — everything under `/api/v1`           | `https://phoenix-staging.insurgrid.com`   | `https://phoenix.insurgrid.com`   |
| Embed script — `embed.js` in your snippet  | `https://dashboard-staging.insurgrid.com` | `https://dashboard.insurgrid.com` |
| Dashboard — where you create API keys      | `https://dashboard-staging.insurgrid.com` | `https://dashboard.insurgrid.com` |
| Forms people fill in — the `shareUrl` host | `https://link-staging.insurgrid.com`      | `https://link.insurgrid.com`      |

So a call that reads

```
POST https://phoenix.insurgrid.com/api/v1/embed-sessions
```

is the same call in staging with one word changed:

```
POST https://phoenix-staging.insurgrid.com/api/v1/embed-sessions
```

Paths, request bodies, response shapes and error codes are identical. Nothing in your code needs to change between the two beyond the host it points at and the key it sends — which is the point of having them.

## Keys don't cross over

A staging API key works **only** against staging, and a production key only against production. The same goes for webhook signing secrets: a staging secret will never validate a production event.

If you're getting `401` and the address looks right, check you aren't holding the other environment's key.

## So does `shareUrl`

A form's `shareUrl` is built for the environment you fetched it from — ask staging and you get a `link-staging.insurgrid.com` address. Use the value as given rather than rewriting the host by hand, and fetch the form from the environment you actually mean to use.

## Staging data is real, but disposable

Staging has its own database. Anything you create there is a genuine staging record: safe to break and re-create, never billed, and no real customer is ever contacted. A form created on staging does not exist on production.

## Related

* [Sandbox & testing](/testing/sandbox) — registering a staging webhook endpoint and firing test events.
* [Get your API credentials](/get-started/03-api-credentials)


# Authentication & tokens

InsurGrid integrations use **two separate credentials**, and mixing them up is the most common first-week mistake. This guide goes one level deeper than [Get your API credentials](/get-started/03-api-credentials) — read that page first if you haven't set anything up yet.

> **New term — credential:** a secret value (a token or key) that proves who's making a request. Anyone who has it can act as you, so it's handled like a password, not like configuration.

## The two credentials, side by side

| Credential                       | Direction       | Proves                                          | Where it goes                                                                       |
| -------------------------------- | --------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------- |
| **API token** (management token) | You → InsurGrid | You're allowed to call the InsurGrid API.       | `Authorization: Bearer <token>` header on every API request.                        |
| **Signing secret**               | InsurGrid → you | A webhook `POST` genuinely came from InsurGrid. | Never sent anywhere. Stored on your server; used to check each webhook's signature. |

They're not interchangeable. The API token authenticates *outbound* calls you make to `https://api.insurgrid.com/v2`. The signing secret authenticates *inbound* deliveries InsurGrid makes to your endpoint. If you find yourself trying to send the signing secret in a request header, stop — that's not what it's for.

Both live in the **API section** of your dashboard.

## Using the API token

Every call to the InsurGrid API carries the token as a bearer header:

```
Authorization: Bearer <token>
```

> **New term — bearer token:** a secret string that acts like a password for API calls. Whoever "bears" (holds) it can call the API as you — so it's never put in front-end code, a public repo, or a URL, and it's never logged.

**Node:**

```js
const res = await fetch("https://api.insurgrid.com/v2/forms", {
  headers: { Authorization: `Bearer ${process.env.INSURGRID_API_TOKEN}` },
});
```

**Python:**

```python
import os, requests

res = requests.get(
    "https://api.insurgrid.com/v2/forms",
    headers={"Authorization": f"Bearer {os.environ['INSURGRID_API_TOKEN']}"},
)
```

## Using the signing secret

The signing secret never travels over the network. You use it locally, on your server, to recompute the signature of an incoming webhook and compare it to the one InsurGrid sent. If a request came from InsurGrid, the two match; if it's spoofed, they don't.

The full walkthrough — with the exact HMAC computation in Node and Python — is in [Verify a webhook signature](/guides/signing-verification).

> ⚠️ **The signing secret is shown once**, at creation time. Copy it immediately into a secrets manager or your environment config. If you lose it, you can regenerate it — but that invalidates the old one, so update your server in the same change.

## Keep-secret hygiene

* Store both credentials in environment variables or a secrets manager — never hard-code them in source.
* Never expose either one in browser or front-end code. The API token lets someone call the API as you; the signing secret lets someone forge webhooks that look genuine.
* Don't commit either to a repo, even a private one, and don't paste them into logs, tickets, or chat.
* If a credential leaks, regenerate it in the **API section** right away.

## Staging vs production

InsurGrid issues **separate credentials per environment**. Your staging API token and signing secret are different from your production ones.

| Environment    | Use it for                                                       |
| -------------- | ---------------------------------------------------------------- |
| **Staging**    | Building and testing. Safe to break; no real customer data.      |
| **Production** | Live traffic, once your integration works end to end in staging. |

Build and verify everything against staging first, then switch to production credentials when you go live — see [Get access](/get-started/02-get-access) for how environments map to the URLs you register.

## Related

* [Get your API credentials](/get-started/03-api-credentials)
* [Register your endpoint & choose an auth type](/guides/register-endpoint-auth-types)
* [Verify a webhook signature](/guides/signing-verification)
* [Idempotency keys](/guides/idempotency)


# Register your endpoint & choose an auth type

Before InsurGrid can deliver anything to you, you tell us **where** to send it (a URL, per topic) and **how you want to authenticate that delivery** (one of five schemes). This guide covers both, and helps you pick the right auth scheme for your setup.

> **New term — topic:** the kind of event a webhook carries — for example `client-submission` (extracted policy data) or `form-submission` (a completed intake form). You register a URL per topic, so different events can go to different endpoints if you want.

## Register a URL

1. Open the **API section** of your dashboard.
2. Pick the topic you want to receive (for example `client-submission`).
3. Add the HTTPS URL InsurGrid should `POST` to. Add a **staging** URL and a **production** URL separately — see [Get access](/get-started/02-get-access).
4. Choose how you want the delivery authenticated (below). If you skip this, InsurGrid signs every request by default.

Once a URL is saved for a topic, InsurGrid starts delivering matching events to it.

## The five auth options

Every delivery to your endpoint is authenticated one of these five ways. Pick the one during setup that matches how your endpoint is built — you don't need to support more than one at a time.

| Option                            | How it works                                                                                                                           | Pick this when                                                                                                                                   |
| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| **InsurGrid signature** (default) | InsurGrid computes an HMAC-SHA256 of the raw request body with a signing secret we generate, and sends it in the `x-signature` header. | You're starting fresh and want the strongest guarantee that a request wasn't tampered with in transit — not just that it carries a known secret. |
| **HMAC-SHA256 (custom)**          | Same signature mechanism, but with a shared secret *you* supply instead of one InsurGrid generates.                                    | You already run other webhook providers under one shared-secret scheme and want InsurGrid to match it.                                           |
| **Basic auth**                    | InsurGrid sends a standard `Authorization: Basic <base64 username:password>` header.                                                   | Your endpoint (or the infrastructure in front of it, like a gateway) already gates on HTTP Basic auth.                                           |
| **Bearer token**                  | InsurGrid sends a static `Authorization: Bearer <token>` header with every delivery.                                                   | Your endpoint already expects a bearer token — for example if it's shared with other internal callers that authenticate that way.                |
| **API-key header**                | InsurGrid sends a static value in a header name you choose (for example `X-API-Key`).                                                  | Your endpoint, or something in front of it, is already set up to check a specific header.                                                        |

> **New term — HMAC:** short for "hash-based message authentication code" — a fingerprint of a message computed with a shared secret. Because only the two parties involved know the secret, a matching fingerprint proves the message is genuine and unaltered. It's the mechanism behind both the default signature and the custom HMAC-SHA256 option.

## Which one should you pick?

If you're not sure, **use the default**. The InsurGrid signature scheme needs no extra setup on your end beyond storing the secret, and unlike a static token or key, it changes with every request — so it can't be replayed against a different payload. It's covered step by step in [Verify a webhook signature](/guides/signing-verification).

Reach for one of the other four only when you have a concrete reason — usually that your endpoint (or a gateway/load balancer in front of it) already speaks one of those schemes and you'd rather not add signature verification code. A static credential (Basic, Bearer, or API key) is simpler to check, but it authenticates the *sender*, not the *contents* — it doesn't prove the body wasn't altered in transit the way an HMAC does.

## Related

* [Authentication & tokens](/guides/authentication)
* [Verify a webhook signature](/guides/signing-verification)
* [Delivery & de-duplication](/guides/delivery-dedup)
* [Get access](/get-started/02-get-access)


# Verify a webhook signature

Anyone can find your webhook URL and `POST` a fake payload to it. Signature verification is how your server tells a genuine InsurGrid delivery apart from an imposter — before you trust a single field in the body.

> **New term — signature verification:** recomputing a cryptographic fingerprint of an incoming request on your side, using a secret only you and the sender know, and checking it matches the one the sender attached. A match proves the request came from the sender and wasn't altered in transit.

This is the default auth scheme for every InsurGrid webhook (see [Register your endpoint & choose an auth type](/guides/register-endpoint-auth-types) if you've configured a different one — the rest of this page doesn't apply to those).

## How it works

Every delivery carries an `x-signature` header: an **HMAC-SHA256** of the **raw request body**, computed with your **signing secret** (from the **API section** — see [Authentication & tokens](/guides/authentication)), hex-encoded.

> **New term — HMAC-SHA256:** a hashing algorithm that mixes a secret key into the fingerprint it produces. Recomputing the same hash of the same bytes with the same secret always yields the same output — so if your recomputed value matches the header, the request is genuine.

The check has three parts:

1. Get the **exact raw bytes** InsurGrid sent — not the parsed-and-re-serialized JSON.
2. Compute `HMAC-SHA256(raw_body, signing_secret)`, hex-encoded.
3. Compare it to the `x-signature` header, using a **timing-safe** comparison. If it doesn't match, reject the request with `401`.

## ⚠️ The raw-bytes footgun

The single most common first-time bug: computing the HMAC over `JSON.stringify(parsedBody)` (or Python's `json.dumps(parsed)`) instead of the bytes as received.

Parsing JSON and re-serializing it does **not** reliably reproduce the original bytes — key order, whitespace, and number formatting can all differ. Even a one-byte difference produces a completely different hash, so the check fails even though the request is perfectly genuine.

**The fix:** capture the raw body *before* your framework parses it, and hash those bytes — never the parsed object.

## Verify it · *server*

**Node (Express):**

```js
const crypto = require("crypto");
const express = require("express");
const app = express();

// Keep the raw body — you need the exact bytes to verify the signature.
app.use(express.json({
  verify: (req, _res, buf) => { req.rawBody = buf; },
}));

function verify(rawBody, signature, secret) {
  const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(signature || "", "hex");
  // Lengths must match before timingSafeEqual — it throws on mismatched lengths.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

app.post("/hooks/insurgrid", (req, res) => {
  const ok = verify(req.rawBody, req.get("x-signature"), process.env.INSURGRID_SIGNING_SECRET);
  if (!ok) return res.sendStatus(401);   // not from InsurGrid — reject

  res.sendStatus(200);                   // acknowledge fast
  handle(req.body);                      // process after responding
});
```

**Python (Flask):**

```python
import hmac, hashlib, os
from flask import Flask, request

app = Flask(__name__)

def verify(raw_body: bytes, signature: str, secret: str) -> bool:
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature or "")

@app.post("/hooks/insurgrid")
def insurgrid_hook():
    raw = request.get_data()                  # exact bytes — before any parsing
    signature = request.headers.get("x-signature", "")

    if not verify(raw, signature, os.environ["INSURGRID_SIGNING_SECRET"]):
        return "", 401                         # not from InsurGrid — reject

    handle(request.get_json())                 # parse only after verifying
    return "", 200                             # acknowledge fast
```

Notice both handlers read the raw body **before** parsing JSON, verify first, and only trust `req.body` / `request.get_json()` after the check passes.

## Why timing-safe comparison

A plain `expected === signature` string comparison returns as soon as it finds the first mismatched character — which means it takes slightly longer to reject a guess that gets more characters right. An attacker who can measure that timing difference can, in theory, guess a valid signature one byte at a time. `crypto.timingSafeEqual` (Node) and `hmac.compare_digest` (Python) take the same amount of time regardless of where the mismatch is, closing that side channel. Always use them for this comparison instead of `===` or `==`.

## Reject on mismatch

If the signature doesn't match, return `401` and stop — don't process the body, don't log it as a near-miss with full contents, and don't fall back to trusting the request anyway. A mismatch means either the secret is wrong on one side (check you copied the current one) or the request isn't from InsurGrid.

## Using a different scheme instead

If signature verification doesn't fit your setup, you can configure your endpoint to authenticate deliveries a different way — HMAC-SHA256 with your own secret, Basic auth, a bearer token, or a static API-key header. See [Register your endpoint & choose an auth type](/guides/register-endpoint-auth-types) for all five options and when each makes sense. A static credential is simpler to check but, unlike an HMAC, it doesn't prove the body wasn't altered in transit.

## Related

* [Authentication & tokens](/guides/authentication)
* [Register your endpoint & choose an auth type](/guides/register-endpoint-auth-types)
* [Delivery & de-duplication](/guides/delivery-dedup)
* [Quickstart A — Receive policy data](/quickstarts/a-receive-policy-data)


# Delivery & de-duplication

InsurGrid's delivery is **at-least-once**, not exactly-once. That single fact shapes how you should write every webhook handler: it must be safe to run twice on the same event.

> **New term — at-least-once delivery:** a guarantee that an event will be delivered one time *or more* — never zero times — but occasionally more than once. The tradeoff for "we'll keep trying until it lands" is that your handler has to tolerate the same event arriving twice.

## Why duplicates happen

A duplicate isn't a bug on InsurGrid's side — it's the normal cost of guaranteeing nothing gets silently dropped. A common case: your endpoint finishes processing and is about to respond, but the response is slow to arrive or gets lost in transit. From InsurGrid's side, that looks identical to a failure, so the event is retried — even though you already handled it.

## Retries, backoff, and replay

If your endpoint doesn't return a `2xx` status, InsurGrid retries the delivery automatically, spacing attempts out over time rather than firing them all at once. Every attempt — successful or not — is recorded in a **delivery log** you can review in the **API section**, and a failed delivery can be **replayed** on demand once your endpoint is fixed.

In plain terms: you don't need to build your own retry or delivery-tracking system. InsurGrid keeps trying on your behalf and gives you visibility into what was sent and when. What you're responsible for is making sure a repeated delivery doesn't cause a repeated *effect* on your side.

## Make your handler idempotent

> **New term — idempotent:** an operation that produces the same end result no matter how many times it runs. Processing the same webhook once or three times should leave your system in the same state either way.

The standard pattern: derive a stable **de-dupe key** for the event, record every key you've successfully processed, and skip anything you've already seen. Which key to use depends on the event (see the next section).

```js
// server (sketch)
const crypto = require("crypto");
function dedupeKey(event) {
  // form-submission carries its own id; policy-data uses a content hash
  return event.data.submission_id
    ?? crypto.createHash("sha256").update(JSON.stringify(event.data)).digest("hex");
}
function handle(event) {
  const key = dedupeKey(event);
  if (alreadyProcessed(key)) return;   // skip duplicate
  markProcessed(key);
  // …save the data, trigger downstream work…
}
```

```python
# server (sketch)
import hashlib, json
def dedupe_key(event):
    # form-submission carries its own id; policy-data uses a content hash
    sid = event["data"].get("submission_id")
    if sid:
        return str(sid)
    return hashlib.sha256(json.dumps(event["data"], sort_keys=True).encode()).hexdigest()

def handle(event):
    key = dedupe_key(event)
    if already_processed(key):
        return  # skip duplicate
    mark_processed(key)
    # …save the data, trigger downstream work…
```

`alreadyProcessed` / `already_processed` can be as simple as a unique constraint on the id column in your database — an insert that violates the constraint *is* your duplicate check.

## What id to de-dupe on

| Event                                    | De-dupe on                                                                                                                                                           |
| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Form submissions** (`form-submission`) | The `submission_id` in the payload body — it identifies the underlying submission no matter how many times it's delivered. Use this directly.                        |
| **Policy data** (`client-submission`)    | The payload has no dedicated id field. De-dupe on a **hash of the payload contents** (`data`): compute a SHA-256 of the JSON and treat a repeat hash as a duplicate. |

> If your endpoint receives a delivery-id header from your webhook setup, you can use that instead — confirm the exact header for your account during onboarding. When in doubt, the content hash above always works.

## Ordering is not guaranteed

Don't assume events arrive in the order they happened. If you subscribe to multiple topics for related data (for example, a submission event and a per-file upload event), one may arrive before the other. Design your handler to tolerate out-of-order arrival — for example, upsert by id rather than assuming "this is the first thing I've seen for this record."

## Related

* [Idempotency keys](/guides/idempotency) — the equivalent safety net for the *writes you send to* InsurGrid.
* [Verify a webhook signature](/guides/signing-verification)
* [Register your endpoint & choose an auth type](/guides/register-endpoint-auth-types)
* [Quickstart A — Receive policy data](/quickstarts/a-receive-policy-data)


# Idempotency keys

Networks are unreliable. If a request to create or publish a form times out, you don't actually know whether it succeeded on InsurGrid's side before the connection dropped — so the safe move, retrying, risks doing the same operation twice. An idempotency key is how you retry safely: it tells InsurGrid "if you've already done this exact operation, just give me the same result again — don't do it a second time."

> **New term — idempotency key:** a unique value you generate and attach to a single write request. If InsurGrid sees the same key again, it returns the result of the original request instead of repeating the operation.

This is the write-side counterpart to [Delivery & de-duplication](/guides/delivery-dedup), which covers the same problem from the other direction — duplicate webhook *deliveries* landing on your server.

## When to send one

Send an `Idempotency-Key` header on every write call: creating a form, publishing it, archiving it — anything that changes state. Reads (`GET`) don't need one; they don't have a "did this already happen" problem.

## What the value should be

A **UUIDv4** — a randomly generated 128-bit identifier, formatted as a string. It just needs to be unique per operation; you don't need to coordinate it with InsurGrid ahead of time, and you don't need to store a registry of used keys — you generate a fresh one right before each write.

> **New term — UUIDv4:** a universally unique identifier built from random bits (version 4 of the UUID spec), typically written as 32 hex characters split by dashes, e.g. `3f1a9c7e-2b44-4e8a-9f21-6c0b8d5e4a12`. The chance of two independently generated UUIDv4s colliding is negligible, so it's a safe default for "give me something unique."

## Using it · *server*

**Node:**

```js
const { randomUUID } = require("crypto");

async function createForm(payload) {
  const res = await fetch("https://api.insurgrid.com/v2/forms", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.INSURGRID_API_TOKEN}`,
      "Content-Type": "application/json",
      "Idempotency-Key": randomUUID(),   // fresh key for this operation
    },
    body: JSON.stringify(payload),
  });
  return res.json();
}
```

**Python:**

```python
import os, uuid, requests

def create_form(payload: dict) -> dict:
    res = requests.post(
        "https://api.insurgrid.com/v2/forms",
        headers={
            "Authorization": f"Bearer {os.environ['INSURGRID_API_TOKEN']}",
            "Idempotency-Key": str(uuid.uuid4()),   # fresh key for this operation
        },
        json=payload,
    )
    return res.json()
```

## What happens on retry

| Situation                                                                      | What InsurGrid does                                              |
| ------------------------------------------------------------------------------ | ---------------------------------------------------------------- |
| First request with a given key                                                 | Performs the operation normally, returns the result.             |
| Same key sent again, same operation (e.g. your client retried after a timeout) | Returns the **original** result — the operation is not repeated. |
| A **new** key on a distinct operation (e.g. creating a second, different form) | Performs it as a new operation.                                  |

## The 24-hour window

A key is honored for **24 hours** from the original request. Retry with the same key within that window and you get the original result back safely. After 24 hours, the key is no longer recognized, and a request using it is treated as new.

## One key per operation

Generate a **fresh key for every distinct write** — don't reuse one key across unrelated create/publish/archive calls, or InsurGrid will treat the second one as a retry of the first and hand you back the first result instead of doing the second operation. Reuse the *same* key only when you're genuinely retrying the *same* attempt (your original request failed or timed out and you're trying again with identical payload).

A simple rule of thumb: generate the key at the same point in your code where you build the request, immediately before sending it — never earlier, and never share one across a loop of otherwise-different calls.

## Related

* [Delivery & de-duplication](/guides/delivery-dedup)
* [Authentication & tokens](/guides/authentication)
* [Register your endpoint & choose an auth type](/guides/register-endpoint-auth-types)


# Pagination (cursors)

Every list endpoint — `GET /v2/forms`, `GET /v2/submissions`, and others — returns results one page at a time using **cursor-based pagination**. This guide shows you how to follow the pages until you've read everything.

> **New term — cursor:** an opaque token that marks your place in a result set. You don't read or construct it — you just pass back exactly what InsurGrid gave you to get the next page.

## How it works

Two query params control paging:

| Param    | Type    | Meaning                                                                                | Default |
| -------- | ------- | -------------------------------------------------------------------------------------- | ------- |
| `limit`  | integer | How many results per page. 1–100.                                                      | 25      |
| `cursor` | string  | Opaque page marker from the previous response's `next_cursor`. Omit on the first call. | —       |

Every response carries `next_cursor`:

```json
{
  "data": [ /* … up to `limit` items … */ ],
  "next_cursor": "eyJpZCI6NTUyMzF9"
}
```

* If `next_cursor` is a string, there's more — pass it as `cursor` on your next call.
* If `next_cursor` is `null`, you've reached the end.

> Treat the cursor value as opaque. Don't decode it, store it long-term, or construct one yourself — InsurGrid may change its internal format at any time.

## Fetch every page

**Node:**

```js
// client.js — run with: node client.js
async function listAllForms(token) {
  const all = [];
  let cursor;

  do {
    const url = new URL("https://api.insurgrid.com/v2/forms");
    url.searchParams.set("limit", "100");
    if (cursor) url.searchParams.set("cursor", cursor);

    const res = await fetch(url, {
      headers: { Authorization: `Bearer ${token}` },
    });
    const page = await res.json();

    all.push(...page.data);
    cursor = page.next_cursor;
  } while (cursor);

  return all;
}
```

**Python:**

```python
# client.py
import requests

def list_all_forms(token: str) -> list[dict]:
    all_forms = []
    cursor = None

    while True:
        params = {"limit": 100}
        if cursor:
            params["cursor"] = cursor

        resp = requests.get(
            "https://api.insurgrid.com/v2/forms",
            headers={"Authorization": f"Bearer {token}"},
            params=params,
        )
        page = resp.json()

        all_forms.extend(page["data"])
        cursor = page.get("next_cursor")
        if not cursor:
            break

    return all_forms
```

## Which endpoints paginate

Any endpoint that returns a `data` array — for example `GET /v2/forms` and `GET /v2/submissions` — uses this same shape. Learn it once and it works everywhere.

## Related

* Errors & status codes
* Rate limits
* Forms API reference
* Submissions API reference


# Errors & status codes

When a request fails, InsurGrid returns one consistent JSON shape, no matter which endpoint you called. Learn this envelope once and you can handle every error the same way.

## The error envelope

```json
{
  "message": "Slug is already in use for this agency.",
  "code": "form_slug_conflict",
  "request_id": "req_7a1c9e2b4f",
  "errors": [
    { "field": "slug", "message": "must be unique" }
  ]
}
```

| Field        | Type   | Meaning                                                                                                     |
| ------------ | ------ | ----------------------------------------------------------------------------------------------------------- |
| `message`    | string | A human-readable description of what went wrong. Safe to log or show in an internal tool.                   |
| `code`       | string | A stable, machine-readable error code — branch on this in code, not on `message`, since wording can change. |
| `request_id` | string | A unique id for this specific request. Always log it.                                                       |
| `errors[]`   | array  | Present on validation failures (`422`). One entry per invalid field: `field` and `message`.                 |

> **New term — `request_id`:** every API call gets a unique id, returned on both success and failure. It's how InsurGrid support finds your exact request in their logs — quote it (or a webhook payload's `trace_id`) whenever you contact support.

## Status codes

| Status | Meaning                                                                                                       | What to do                                                                                 |
| ------ | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| `400`  | Malformed request — bad JSON, missing required parameter.                                                     | Fix the request shape.                                                                     |
| `401`  | Missing or invalid API token.                                                                                 | Check your `Authorization: Bearer <token>` header.                                         |
| `403`  | Authenticated, but not permitted (e.g. an ordinary agent trying to create a form).                            | Check the [ownership model](/guides/ownership) — the caller may need an owner/admin token. |
| `404`  | Not found, or outside your account's visibility.                                                              | Check the id, and that the resource belongs to your account.                               |
| `409`  | Conflict — e.g. a duplicate slug, or an invalid status transition (like publishing an already-archived form). | Read `message` for specifics; don't blindly retry.                                         |
| `422`  | Validation failed.                                                                                            | Walk `errors[]` and fix each field.                                                        |
| `429`  | Rate limited.                                                                                                 | Back off using `Retry-After` — see [Rate limits](/guides/rate-limits).                     |

## Always log `request_id`

Log `request_id` (and `trace_id`, when the payload has one — see Webhook events & payloads) alongside every failed call. If you need to contact InsurGrid support, quoting it lets them find the exact request instead of guessing from a timestamp.

**Node:**

```js
const res = await fetch("https://api.insurgrid.com/v2/forms", {
  headers: { Authorization: `Bearer ${token}` },
});

if (!res.ok) {
  const err = await res.json();
  console.error(`InsurGrid error [${err.code}] request_id=${err.request_id}: ${err.message}`);
}
```

**Python:**

```python
resp = requests.get(
    "https://api.insurgrid.com/v2/forms",
    headers={"Authorization": f"Bearer {token}"},
)

if not resp.ok:
    err = resp.json()
    print(f"InsurGrid error [{err['code']}] request_id={err['request_id']}: {err['message']}")
```

## Related

* [Rate limits](/guides/rate-limits)
* [Pagination (cursors)](/guides/pagination)
* [Ownership model (agency vs agent)](/guides/ownership)
* Troubleshooting
* Support


# Rate limits

InsurGrid limits how many API calls your account can make in a given window, to keep the API fast and fair for everyone. This guide covers how limits are communicated and how to back off cleanly when you hit one.

Limits are applied **per account**, across all your API tokens combined.

> Exact limits (requests per window) aren't printed here — they're shared with you during onboarding and can vary by account. Design your integration to handle `429` gracefully rather than hard-coding a number.

## When you're over the limit

A request over your limit returns `429 Too Many Requests` with the standard [error envelope](/guides/errors), plus these headers:

| Header                  | Meaning                                   |
| ----------------------- | ----------------------------------------- |
| `Retry-After`           | Seconds to wait before your next request. |
| `X-RateLimit-Limit`     | The ceiling for the current window.       |
| `X-RateLimit-Remaining` | Calls left in the current window.         |
| `X-RateLimit-Reset`     | When the window resets, as epoch seconds. |

> **New term — epoch seconds:** the number of seconds since January 1, 1970 UTC — a common way to represent a timestamp as a plain number. `new Date(epoch * 1000)` (Node) or `datetime.fromtimestamp(epoch)` (Python) converts it to a normal date.

## Back off using `Retry-After`

Don't retry immediately. Wait at least `Retry-After` seconds, then try again. A minimal retry wrapper:

**Node:**

```js
// client.js
async function callWithBackoff(url, options, maxAttempts = 5) {
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    const res = await fetch(url, options);

    if (res.status !== 429) return res;

    const retryAfter = Number(res.headers.get("Retry-After") ?? "1");
    console.warn(`Rate limited. Waiting ${retryAfter}s (attempt ${attempt}/${maxAttempts})`);
    await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000));
  }
  throw new Error("Exceeded max retry attempts after repeated 429s");
}
```

**Python:**

```python
# client.py
import time
import requests

def call_with_backoff(url, headers=None, max_attempts=5):
    for attempt in range(1, max_attempts + 1):
        resp = requests.get(url, headers=headers)

        if resp.status_code != 429:
            return resp

        retry_after = int(resp.headers.get("Retry-After", "1"))
        print(f"Rate limited. Waiting {retry_after}s (attempt {attempt}/{max_attempts})")
        time.sleep(retry_after)

    raise RuntimeError("Exceeded max retry attempts after repeated 429s")
```

## Tips

* Check `X-RateLimit-Remaining` proactively on high-volume jobs — like a full [pagination](/guides/pagination) sweep — and slow down before you hit `0`, rather than waiting for a `429`.
* If you're consistently hitting your limit, contact InsurGrid — limits can be raised for legitimate high-volume use.

## Related

* [Errors & status codes](/guides/errors)
* [Pagination (cursors)](/guides/pagination)
* Support


# Ownership model (agency vs agent)

Every form in InsurGrid is owned at one of two scopes: **agency** or **agent**. Ownership determines who can manage a form and how its submissions are attributed — get this right before you start creating forms via the API.

## The two scopes

| Scope      | Meaning                                                                                                    | Set at create time                                                 |
| ---------- | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
| **Agency** | Owned by the agency as a whole; shared across all its agents. Used for standard, agency-wide intake forms. | `owner: { "type": "agency", "agency_id": 1201 }`                   |
| **Agent**  | Owned by one specific agent within the agency.                                                             | `owner: { "type": "agent", "agency_id": 1201, "agent_id": 88213 }` |

You set `owner` when you create a form (`POST /v2/forms`), and it doesn't change afterward. Every form also records who created it and who last edited it, regardless of scope.

> **New term — scope:** which entity a resource belongs to. Here, a form is scoped either to an entire agency or to one agent inside it — scope controls both *who can manage it* and *who submissions are attributed to*.

## Who can do what

| Role           | Create / update / publish / archive forms | Share / embed existing forms | Receive submissions |
| -------------- | ----------------------------------------- | ---------------------------- | ------------------- |
| Owner / admin  | Yes (agency-wide)                         | Yes                          | Yes                 |
| Ordinary agent | No                                        | Yes                          | Yes                 |

A write call outside the caller's permission returns `403`; a form outside the caller's visibility (for example, another agency's form) returns `404`. See [Errors & status codes](/guides/errors).

## The public fill link is always per-agent

Regardless of a form's ownership scope, its public fill link is generated **per agent** — the `agentSlug` in the embed/share URL determines which agent a submission is attributed to. One agency-owned form can be shared under many different agent slugs, and each submission through it credits the right agent.

```
https://app.insurgrid.com/f/apartment-intake-acme?agentSlug=jane-smith&embed=1
```

Two agents sharing the *same* agency-owned form get *different* URLs (different `agentSlug`), and submissions through each are attributed accordingly — even though it's the same underlying form.

## Why this matters for your integration

* When you list forms with `GET /v2/forms`, filter or group by `owner` if you need to show "my forms" vs. "agency forms" in your UI.
* When you receive a `form-submission` webhook, the `agent` object in the payload tells you which agent the submission is attributed to — see Webhook events & payloads.
* If a write call your app makes returns `403`, don't retry — the token's role doesn't have permission. Check whether the caller should be using an owner/admin token instead.

## Related

* [Form structure & conditional logic](/guides/form-structure-logic)
* [The two embeds (builder vs fill form)](/guides/embeds)
* [Errors & status codes](/guides/errors)
* [Quickstart C — Build forms via the API](/intake-forms/c-build-forms-api)


# Form structure & conditional logic

A form is an ordered list of **steps**, each holding an ordered list of **questions**. This is the shape you send when creating or updating a form, and the same shape `GET /v2/forms/{id}` returns — one model, both directions.

## The shape

```json
{
  "steps": [
    {
      "type": "custom",
      "name": "Applicant",
      "segment": "before",
      "position": 2,
      "questions": [
        { "type": "short_text", "label": "Full name", "required": true, "field_mapping": "FullName" }
      ]
    }
  ]
}
```

| Step field    | Description                                                                                           |
| ------------- | ----------------------------------------------------------------------------------------------------- |
| `type`        | `welcome`, `custom`, `carrier_select`, or `confirmation`.                                             |
| `name`        | Display name for the step.                                                                            |
| `segment`     | Where the step sits relative to the (optional) carrier-connect flow: `before`, `carrier`, or `after`. |
| `position`    | Order among the form's steps.                                                                         |
| `questions[]` | The ordered questions in this step — see below.                                                       |

## Question fields

| Field                  | Description                                                                                                                                |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `type`                 | `short_text`, `long_text`, `email`, `number`, `date`, `dropdown`, `single_choice`, `multi_choice`, `file_upload`, `address`, `data_table`. |
| `label`, `placeholder` | Display text.                                                                                                                              |
| `required`             | Whether an answer is mandatory.                                                                                                            |
| `options`              | Choices, for `dropdown` / `single_choice` / `multi_choice`.                                                                                |
| `field_mapping`        | Maps the answer to a known field InsurGrid recognizes: `FullName`, `Email`, `Phone`, `ServiceType`, `EventDate`.                           |
| `validation`           | Per-question validation: `{ "min": 1, "max": 100 }` or `{ "pattern": "^[0-9]{5}$" }`.                                                      |
| `column_schema`        | Column definitions, for a `data_table` question.                                                                                           |
| `rules`                | Conditional logic — see below.                                                                                                             |

> **New term — `field_mapping`:** tells InsurGrid a question's answer corresponds to a well-known field (like the respondent's email), independent of however you worded the question's label. Use it so downstream consumers — like ACORD generation — know which answer is "the email," even if you labeled the question "Best way to reach you."

## Conditional logic (`rules`)

Any question can carry `rules` that show, hide, require, skip, or end the form based on an earlier answer.

| Rule field                              | Description                                                                                                              |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `condition_mode`                        | `all` (AND) or `any` (OR) across the rule's `conditions`.                                                                |
| `conditions[]`                          | Each: `{ source_question_id, operator, value }`.                                                                         |
| `operator`                              | `equals`, `notEquals`, `contains`, `notContains`, `isEmpty`, `isNotEmpty`, `greaterThan`, `lessThan`, `before`, `after`. |
| `action`                                | `show`, `hide`, `require`, `skip_to_step`, `end_form`, `continue`.                                                       |
| `target_question_id` / `target_step_id` | Where the action applies — for example, which step to skip to.                                                           |

### Worked example: show a question only when an earlier answer equals a value

Say your form asks whether a policy is personal or commercial, and you only want to ask for a legal entity name when the answer is "Commercial":

```json
{
  "type": "custom",
  "name": "Applicant",
  "segment": "before",
  "position": 2,
  "questions": [
    {
      "id": "q-policy-kind",
      "type": "single_choice",
      "label": "Policy kind",
      "required": true,
      "options": ["Personal", "Commercial"]
    },
    {
      "id": "q-entity-name",
      "type": "short_text",
      "label": "Legal entity name",
      "initially_hidden": true,
      "rules": [
        {
          "condition_mode": "all",
          "conditions": [
            {
              "source_question_id": "q-policy-kind",
              "operator": "equals",
              "value": "Commercial"
            }
          ],
          "action": "show"
        }
      ]
    }
  ]
}
```

Walking through it:

1. "Policy kind" is a `single_choice` question with two options.
2. "Legal entity name" starts hidden (`initially_hidden: true`).
3. Its `rules` say: when question `q-policy-kind`'s answer `equals` `"Commercial"`, `show` this question.
4. Pick "Personal" and the field stays hidden. Pick "Commercial" and it appears.

Combine multiple `conditions` with `condition_mode: "any"` to show a question when *any one* of several earlier answers matches, or chain `skip_to_step` rules to route respondents down different step sequences entirely.

## Consistency across create, update, and read

The exact same `steps` / `questions` / `rules` shape is used in `POST /v2/forms`, `PATCH /v2/forms/{id}`, and the response from `GET /v2/forms/{id}`. Implement the model once — parse it, build a form from it, or render a preview — and it works in every direction.

## Related

* [Ownership model (agency vs agent)](/guides/ownership)
* Forms API reference
* Webhook events & payloads
* [Quickstart C — Build forms via the API](/intake-forms/c-build-forms-api)


# The two embeds

Which embed you need depends on who's looking at it.

InsurGrid has two embeds, and they're easy to confuse because both put InsurGrid inside your product. The difference is who they're for.

One is for **your users** — the people who work for you, building and editing intake forms. The other is for **their customers** — the people filling those forms in. They authenticate differently, you install them differently, and they behave differently once they're running.

|                               | Builder embed                                                                        | Fill form embed                            |
| ----------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------ |
| Who uses it                   | Your signed-in users, building a form                                                | Their customers, filling one in            |
| Do they need to be logged in? | Yes — into **your** app                                                              | No. Anyone with the address can fill it in |
| How you install it            | A `<script>` snippet plus a route on your server                                     | A plain `<iframe>`                         |
| How it's opened               | The script fetches a short-lived token from your route                               | A stable public address with `?embed=1`    |
| What it tells your page       | Browser events — `insurgrid:ready`, `form-saved`, `form-published`, `close`, `error` | Nothing at all                             |
| Where the data goes           | Into the same forms your API sees                                                    | To your webhook                            |

***

## The builder embed

For the people on your side who maintain forms.

It's a snippet, not a URL you generate. You paste it into a page behind your login, and the script asks your own server for a short-lived token — that's how it knows which of your users it's showing forms to. Your API key never reaches the browser.

```html
<div id="insurgrid-forms"></div>
<script
  src="https://dashboard.insurgrid.com/embed.js"
  data-token-url="/insurgrid-token">
</script>
```

Each user sees only their own forms, and nothing the browser sends can change that — ownership comes from the token, which comes from your login session.

It reports back through ordinary browser events on the container, so there's no `message` listener to write and no origin to verify:

```js
document.querySelector('#insurgrid-forms')
  .addEventListener('insurgrid:form-published', (e) => {
    refreshFormList(e.detail.formId);
  });
```

Full walkthrough: [Embed the form builder](/intake-forms/builder-sessions).

***

## The fill form embed

For the prospect or customer completing the form.

No login, no token, no snippet — just an iframe pointed at the form's own address, which you get as `shareUrl` from the [Forms API](/intake-forms/forms).

```html
<iframe
  src="https://link.insurgrid.com/{agent-slug}/form/{public_id}/{slug}?embed=1"
  style="width: 100%; height: 800px; border: 0;">
</iframe>
```

`?embed=1` strips InsurGrid's header and page furniture so only the form shows. The first segment is the slug of the agent who created the form, and that's who the submission is credited to. `shareUrl` is `null` until the form is published, and nothing needs registering before you frame it.

> **This embed is silent.** It sends your page no events of any kind, so give the frame a fixed height that suits your layout rather than waiting for it to tell you its size.

Full walkthrough: [Quickstart B](/intake-forms/b-embed-form).

***

## Where the submission actually goes

Neither embed hands you the data through the browser. When someone completes a form, the whole submission — every answer, file links, and any filled ACORD forms — is delivered to your webhook endpoint as a `form-submission` event.

That keeps it off the browser, where it could be read or altered in transit. The Forms API doesn't return submissions either, so treat the webhook as the delivery and store what you need when it lands. See [Submissions](/intake-forms/submissions).

## Related

* [Embed the form builder](/intake-forms/builder-sessions)
* [Quickstart B — share a form & receive submissions](/intake-forms/b-embed-form)
* [Embed Loss Runs](/guides/embed-loss-runs) — the same snippet mechanism, for loss runs
* [Environments](/guides/environments)


# Embed Loss Runs

Run the Loss Runs workflow inside your product, with each of your users seeing only their own requests.

Requesting loss runs means chasing carriers and waiting. This embed puts that whole workflow inside your own product, so your users can raise a request and watch it progress without leaving your app — and without seeing anyone else's requests.

They don't need InsurGrid accounts. You tell InsurGrid which of your users is looking, and it keeps their histories apart.

**What you'll need**

* An InsurGrid account with Loss Runs enabled. If it isn't, the embed replies `403 feature_disabled` and nothing loads.
* An **API key** belonging to an agency admin ([where to get one](/get-started/03-api-credentials)), kept on your server.
* A page in your product that only signed-in users can reach.

|              | Staging                                   | Production                        |
| ------------ | ----------------------------------------- | --------------------------------- |
| Embed script | `https://dashboard-staging.insurgrid.com` | `https://dashboard.insurgrid.com` |
| API          | `https://phoenix-staging.insurgrid.com`   | `https://phoenix.insurgrid.com`   |

Only the host changes between them — see [Environments](/guides/environments).

***

## How it fits together

Three pieces, and it helps to know which is which:

1. **A snippet** you paste into your page. It draws the Loss Runs screen in a frame.
2. **A route on your own server** that hands that frame a short-lived pass.
3. **InsurGrid's API**, which your server calls to create the pass.

The browser never sees your API key — only a pass that expires after about 15 minutes and works for one of your users and nothing else.

> **New term — a pass (we call it an embed session).** A short string that stands in for "this particular signed-in user of yours, for the next few minutes". It carries no account details and can't do anything outside Loss Runs for that one user.

***

## 1. Paste the snippet into your page

Copy it from the **Embed** button on your Loss Runs dashboard, or use this:

```html
<div id="insurgrid-loss-runs"></div>
<script
  src="https://dashboard-staging.insurgrid.com/embed.js"
  data-product="loss-runs"
  data-token-url="/insurgrid-loss-runs-token">
</script>
```

Swap the host for `dashboard.insurgrid.com` in production.

That's the whole front-end job. The script finds the empty `<div>`, puts a frame inside it, and asks your `data-token-url` route for a pass. The frame grows on its own as the content changes, so there's nothing to measure.

| Attribute         | Required | What it does                                                               |
| ----------------- | -------- | -------------------------------------------------------------------------- |
| `data-product`    | yes      | Must be `loss-runs`. Leave it off and you get the forms embed instead.     |
| `data-token-url`  | yes      | The route on **your** server that returns a pass. Built next.              |
| `data-target`     | no       | A CSS selector, if you'd rather not use `#insurgrid-loss-runs`.            |
| `data-min-height` | no       | Starting height in pixels. Defaults to 640; anything under 320 is ignored. |

The script fetches `data-token-url` with same-origin credentials, so the route must live on the **same origin as the page**. A route on another domain receives no login cookie.

## 2. Add the token route to your server

When the script asks for a pass, the browser sends your own login cookie with the request — which is how your route knows who's asking. Put it behind the same login check as the rest of your app.

```js
app.get('/insurgrid-loss-runs-token', requireCustomerLogin, async (req, res, next) => {
  try {
    const response = await fetch(`${process.env.INSURGRID_API_ORIGIN}/api/v1/embed-sessions`, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.INSURGRID_API_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        externalUserId: String(req.user.id),
        product: 'loss_runs',
      }),
    });
    res.set('Cache-Control', 'no-store');
    res.status(response.status).json(await response.json());
  } catch (error) {
    next(error);
  }
});
```

Set `INSURGRID_API_ORIGIN` to the staging host while you build, production when you ship. Pass InsurGrid's reply straight back, unchanged:

```json
{ "token": "emb_8cPq…", "expiresAt": "2026-10-07T13:11:36Z" }
```

Four things that matter:

* **`product` is `loss_runs` here — with an underscore** — while the HTML attribute is `loss-runs` with a hyphen. Easy to mix up.
* **Send `Cache-Control: no-store`**, so no browser or proxy keeps a copy of someone's pass and hands it to the next person.
* **Your API key never leaves the server.** Not in the snippet, page scripts, a URL, or anything you return to the browser.
* **A staging key works only against staging.** Pointed at production it returns `401`.

> ⚠️ **Don't let the browser choose whose data it sees.** Take `externalUserId` from your own login session, never from a query string or form field. InsurGrid rejects a request that tries to set it from the browser, but the safe habit is not to read it from there at all.

## 3. Pick a stable id for each of your users

`externalUserId` is how InsurGrid keeps your users apart. Whatever you send is remembered: send the same value next week and that person sees the loss runs they requested today.

* Use something that never changes for a person — your own database id is ideal.
* **Capitalisation counts.** `User-42` and `user-42` are two different people.
* Don't reuse an id across people, and avoid anything that changes, like an email address they might update.

Each of your users sees only their own requests.

## 4. React to what happens in the frame

Optional, but useful for refreshing your own screen or recording the event. Listen on the element you pointed the snippet at:

```js
const mount = document.querySelector('#insurgrid-loss-runs');

mount.addEventListener('insurgrid:loss-run-created', (event) => {
  console.log('New request', event.detail.requestId);
});
```

| Event                        | Raised when                                  | `event.detail`  |
| ---------------------------- | -------------------------------------------- | --------------- |
| `insurgrid:ready`            | The embed has loaded and been handed a pass. | —               |
| `insurgrid:loss-run-created` | Your user finished creating a request.       | `{ requestId }` |
| `insurgrid:close`            | Your user backed out of a screen.            | —               |
| `insurgrid:error`            | Something went wrong — codes below.          | `{ code }`      |

These are for your interface only — a toast, a counter, a refresh. The Loss Runs screen itself is the record of what happened.

## 5. Refreshing, and what happens at sign-out

A pass lasts about 15 minutes, and roughly a minute before it runs out the script quietly calls your token route again. Your user notices nothing, even with the page open all afternoon. There's nothing for you to schedule.

When your user signs out of **your** app, your token route should stop issuing passes — but be aware of what that does and doesn't do:

* **The pass already in the frame keeps working until it expires**, up to 15 minutes. There is no way to revoke one early.
* **The script keeps retrying your route in the background**, backing off to once every 30 seconds. It doesn't give up, and it doesn't show an expiry message — the embed only surfaces an error once the pass has expired and a call fails.

So if you need the embed to stop the moment someone signs out, take it off the page yourself: navigate away, or — if you mounted it with `InsurGrid.mount` rather than the snippet — call `destroy()` on the handle it returned.

***

## When something doesn't work

| What you see                             | Usually means                                                                                                         |
| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| "Your session expired. Reload the page." | The **first** call to your token route failed. Check you're signed in and that the path matches `data-token-url`.     |
| `insurgrid:error` with `session_expired` | The same cause, reported to your code so you can handle it yourself.                                                  |
| `403 feature_disabled`                   | Loss Runs isn't enabled for the agent who owns the API key. Contact InsurGrid.                                        |
| `401`                                    | The key is wrong, sent without `Bearer`, belongs to the other environment, or isn't an agency admin's.                |
| `422` mentioning `product`               | `product` must be exactly `forms` or `loss_runs`. Check the underscore.                                               |
| The frame stays blank                    | Open your browser's network tab and look at the call to your token route — it's almost always a `401` or `404` there. |

## What stays private

* The pass is handed to the frame behind the scenes, never put in its web address — so it won't turn up in browser history, a shared link, or your server logs.
* Each pass is tied to one of your users, and everything it can do — listing, creating, authorizing, downloading — is scoped to that person's requests. Treat it like any other bearer credential: within its 15 minutes, whoever holds it can act as that user.
* Your users can't browse your client list through the embed. The insured name is a plain text box there, not the lookup your own staff get in the dashboard.
* Documents are downloaded through short-lived signed links, not public ones.

## Related

* [Environments](/guides/environments) — staging and production addresses.
* [Get your API credentials](/get-started/03-api-credentials)
* [The two embeds](/guides/embeds) — the equivalent for intake forms.


# API reference

Every endpoint, event, and field.

* Forms API
* Builder sessions
* Submissions API
* Webhook events & payloads


# Webhook events & payloads

InsurGrid pushes data to a URL you control — you never poll. There are **two events**, delivered as two distinct envelopes. Subscribe to either or both, on separate endpoint URLs if you like.

| Event (`topic`)                                             | Sent when                                           | `data` carries                                                                  |
| ----------------------------------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------- |
| [`client-submission`](#policy-data-event-client-submission) | A declaration page is extracted (policy-data flow). | A time-limited document link, the policy type, and the extracted policy fields. |
| [`form-submission`](#form-submission)                       | A consumer completes and submits an embedded form.  | The full form response — every answer, file links, and any filled ACORD forms.  |

Both share the same outer shape:

```json
{
  "topic": "client-submission",
  "data": { /* event-specific — see below */ }
}
```

Both are verified the same way and follow the same delivery rules — see [Verify signatures](/guides/signing-verification) and [Delivery & de-duplication](/guides/delivery-dedup). In short: every request carries an `x-signature` header (HMAC-SHA256 of the raw request body); delivery is **at-least-once**, so de-duplicate — form-submission carries `submission_id`; the policy-data body has no dedicated id, so de-duplicate on a hash of the payload contents.

***

## Policy-data event: `client-submission`

Fired when InsurGrid finishes extracting a submitted declaration page. `data` always carries `document_link` and `policy_type`, plus the extracted policy fields — which fields appear depends on `policy_type`.

> **Field sets vary by insurance line; the examples below are representative.** InsurGrid extracts over twenty insurance lines, from a handful of source formats, and the exact field set for a given document depends on what that document actually contains. Rather than a single frozen schema, this page leads with two fully-worked, real-shape examples — **auto** and **homeowners**, the two most common lines — with complete field tables, then gives a compact map of what to expect from every other line. Treat the field tables as "the shape you'll usually see," not a contract that every field is always present.

### Envelope

| Field                | Type           | Meaning                                                                                                                                                                                                                                                                                                                                                  | Example                                                   |
| -------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- |
| `topic`              | string         | The event type. Always `"client-submission"` for policy data.                                                                                                                                                                                                                                                                                            | `"client-submission"`                                     |
| `data.document_link` | string (URL)   | A **time-limited** link to the source declaration-page file. Fetch and store it promptly — see the warning below.                                                                                                                                                                                                                                        | `"https://files.insurgrid.com/dl/9f2a7b/declaration.pdf"` |
| `data.policy_type`   | string         | A **descriptive label** for the insurance line, set per document (e.g. `"Personal Auto"`, `"Commercial Auto"`, `"Homeowners"`, `"Umbrella"`, `"Other"`). It is **not a fixed code** — don't hard-match on exact strings; use it as a hint and branch on the fields actually present. See [Insurance line → field groups](#insurance-line--field-groups). | `"Personal Auto"`                                         |
| `data.*`             | object / array | The extracted policy fields. Set depends on `policy_type`.                                                                                                                                                                                                                                                                                               | —                                                         |

> **`document_link` expires.** It's a temporary download link. If you need the file, fetch and store it on your side promptly — don't save the link and expect it to work later.

***

### Example: an auto policy

```json
{
  "topic": "client-submission",
  "data": {
    "document_link": "https://files.insurgrid.com/dl/9f2a7b/declaration.pdf",
    "policy_type": "Personal Auto",
    "policy": {
      "provider": "Progressive",
      "policy_number": "912345678",
      "coverage_period": { "start_date": "2026-03-01", "end_date": "2027-03-01" },
      "policy_term": "12 months",
      "annual_policy_premium": 1842.00,
      "payment_schedule": "monthly",
      "payment_method": "EFT",
      "policy_status": "active"
    },
    "policyholder": {
      "entity_type": "individual",
      "first_name": "Jane",
      "last_name": "Roe",
      "full_name": "Jane Roe",
      "date_of_birth": "1989-04-12",
      "address": {
        "address_line_1": "12 Oak St",
        "city": "Austin",
        "state": "TX",
        "postal_code": "78701",
        "country": "US"
      },
      "phone": "(555) 010-1234",
      "email": "jane.roe@example.com"
    },
    "drivers": [
      {
        "name": "Jane Roe",
        "date_of_birth": "1989-04-12",
        "driver_license_number": "TX-01234567",
        "driver_license_state": "TX",
        "relationship_to_policyholder": "self",
        "gender": "F",
        "marital_status": "single",
        "assigned_vehicles": ["1"]
      }
    ],
    "vehicles": [
      {
        "vehicle_number": "1",
        "year": 2022,
        "make": "Toyota",
        "model": "RAV4",
        "vin": "JTMRFREV0ND123456",
        "usage": "commute",
        "annual_mileage": "9000",
        "garaging_address": {
          "address_line_1": "12 Oak St", "city": "Austin", "state": "TX", "postal_code": "78701"
        },
        "coverages_details": {
          "liability": {
            "bodily_injury_per_person": "$100,000",
            "bodily_injury_per_accident": "$300,000",
            "property_damage": "$100,000"
          },
          "comprehensive": { "deductible": "$500", "limit": "ACV" },
          "collision": { "deductible": "$500", "limit": "ACV" }
        }
      }
    ],
    "discounts": [
      { "type": "multi-policy", "description": "Bundled with homeowners", "percentage": 10 }
    ],
    "insurer_information": {
      "company_name": "Progressive",
      "company_phone": "1-800-776-4737",
      "claims_phone": "1-800-274-4499",
      "agent_name": "Sam Carter",
      "agent_phone": "(555) 220-9911"
    },
    "prior_carrier": {
      "name": "GEICO",
      "policy_number": "88213-A",
      "expiration_date": "2026-02-28"
    }
  }
}
```

#### Auto field reference

| Field                                                       | Type                     | Meaning                                                                                                                                              | Example                                        |
| ----------------------------------------------------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- |
| `policy.provider`                                           | string                   | Carrier that wrote the policy.                                                                                                                       | `"Progressive"`                                |
| `policy.policy_number`                                      | string                   | The policy number.                                                                                                                                   | `"912345678"`                                  |
| `policy.coverage_period.start_date` / `end_date`            | string (date)            | Policy effective and expiration dates.                                                                                                               | `"2026-03-01"`                                 |
| `policy.annual_policy_premium`                              | number                   | Total annual premium.                                                                                                                                | `1842.00`                                      |
| `policy.payment_schedule`                                   | string                   | How the premium is billed.                                                                                                                           | `"monthly"`                                    |
| `policy.policy_status`                                      | string                   | Lifecycle state of the policy as of extraction.                                                                                                      | `"active"`                                     |
| `policyholder.full_name`                                    | string                   | Named insured's full name.                                                                                                                           | `"Jane Roe"`                                   |
| `policyholder.address`                                      | object                   | Mailing address — `address_line_1`, `city`, `state`, `postal_code`, `country`.                                                                       | `{ "city": "Austin", "state": "TX" }`          |
| `policyholder.phone` / `email`                              | string                   | Contact details, when present on the declaration page.                                                                                               | `"jane.roe@example.com"`                       |
| `drivers[]`                                                 | array of object          | Every listed driver — `name`, `date_of_birth`, `driver_license_number`, `driver_license_state`, `relationship_to_policyholder`, `assigned_vehicles`. | —                                              |
| `vehicles[]`                                                | array of object          | Every scheduled vehicle.                                                                                                                             | —                                              |
| `vehicles[].vin`                                            | string                   | Vehicle identification number.                                                                                                                       | `"JTMRFREV0ND123456"`                          |
| `vehicles[].year` / `make` / `model`                        | number / string / string | Vehicle description.                                                                                                                                 | `2022`, `"Toyota"`, `"RAV4"`                   |
| `vehicles[].garaging_address`                               | object                   | Where the vehicle is principally kept.                                                                                                               | `{ "city": "Austin" }`                         |
| `vehicles[].coverages_details.liability`                    | object                   | Per-person / per-accident bodily injury and property damage limits.                                                                                  | `{ "bodily_injury_per_person": "$100,000" }`   |
| `vehicles[].coverages_details.comprehensive` / `.collision` | object                   | `deductible` and `limit` for each.                                                                                                                   | `{ "deductible": "$500" }`                     |
| `discounts[]`                                               | array of object          | Applied discounts — `type`, `description`, `amount` or `percentage`.                                                                                 | `{ "type": "multi-policy", "percentage": 10 }` |
| `insurer_information`                                       | object                   | Carrier contact details — `company_name`, `company_phone`, `claims_phone`, `agent_name`, `agent_phone`.                                              | `{ "agent_name": "Sam Carter" }`               |
| `prior_carrier`                                             | object                   | Previous carrier, when shown on the declaration page — `name`, `policy_number`, `expiration_date`.                                                   | `{ "name": "GEICO" }`                          |

Auto policies can also carry `additional_insured[]`, `surcharges[]`, `endorsements[]`, `exclusions[]`, `group_plan`, and `billing_information` when the source document includes them.

***

### Example: a homeowners policy

```json
{
  "topic": "client-submission",
  "data": {
    "document_link": "https://files.insurgrid.com/dl/4c1e9a/declaration.pdf",
    "policy_type": "Homeowners",
    "policy": {
      "provider": "State Farm",
      "policy_number": "HO-3345219",
      "coverage_period": { "start_date": "2026-05-15", "end_date": "2027-05-15" },
      "policy_term": "12 months",
      "annual_policy_premium": 2140.00,
      "payment_schedule": "annual",
      "policy_status": "active"
    },
    "policyholder": {
      "entity_type": "individual",
      "first_name": "Marcus",
      "last_name": "Lee",
      "full_name": "Marcus Lee",
      "address": {
        "address_line_1": "48 Birchwood Ln",
        "city": "Round Rock",
        "state": "TX",
        "postal_code": "78664",
        "country": "US"
      },
      "phone": "(555) 402-7788",
      "email": "marcus.lee@example.com"
    },
    "properties": [
      {
        "property_id": "1",
        "location": { "address_line_1": "48 Birchwood Ln", "city": "Round Rock", "state": "TX", "postal_code": "78664" },
        "ownership_status": "owned",
        "type": "single-family",
        "construction_type": "frame",
        "year_built": 2004,
        "square_footage": 2350,
        "roof_type": "composition shingle",
        "roof_year": 2019,
        "protection_class": "3",
        "coverages": [
          { "type": "Dwelling", "limit": 420000, "deductible": 2500, "replacement_cost": true },
          { "type": "Personal Property", "limit": 210000, "deductible": 2500 },
          { "type": "Loss of Use", "limit": 84000 },
          { "type": "Liability", "limit": 300000 }
        ],
        "deductibles": { "all_peril": 2500, "wind_hail": 5000 },
        "mortgagee": [
          { "name": "Wells Fargo Home Mortgage", "loan_number": "0099213456" }
        ]
      }
    ],
    "insurer_information": {
      "company_name": "State Farm",
      "company_phone": "1-800-782-8332",
      "claims_phone": "1-800-732-5246",
      "agent_name": "Priya Nair",
      "agent_phone": "(555) 617-2200"
    },
    "prior_carrier": {
      "name": "Allstate",
      "policy_number": "AS-77213",
      "expiration_date": "2026-05-14"
    }
  }
}
```

#### Homeowners field reference

| Field                                            | Type            | Meaning                                                                                                                                    | Example                                   |
| ------------------------------------------------ | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------- |
| `policy.provider` / `policy_number`              | string          | Carrier and policy number.                                                                                                                 | `"State Farm"`, `"HO-3345219"`            |
| `policy.coverage_period.start_date` / `end_date` | string (date)   | Policy effective and expiration dates.                                                                                                     | `"2026-05-15"`                            |
| `policy.annual_policy_premium`                   | number          | Total annual premium.                                                                                                                      | `2140.00`                                 |
| `policyholder.full_name`                         | string          | Named insured's full name.                                                                                                                 | `"Marcus Lee"`                            |
| `policyholder.address`                           | object          | Mailing address.                                                                                                                           | `{ "city": "Round Rock" }`                |
| `properties[]`                                   | array of object | Every insured property (usually one).                                                                                                      | —                                         |
| `properties[].location`                          | object          | The property's address.                                                                                                                    | `{ "address_line_1": "48 Birchwood Ln" }` |
| `properties[].ownership_status`                  | string          | `owned`, `rented`, `leased`, or `other`.                                                                                                   | `"owned"`                                 |
| `properties[].construction_type`                 | string          | Building construction.                                                                                                                     | `"frame"`                                 |
| `properties[].year_built` / `square_footage`     | number          | Building characteristics.                                                                                                                  | `2004`, `2350`                            |
| `properties[].roof_type` / `roof_year`           | string / number | Roof material and install/replacement year.                                                                                                | `"composition shingle"`, `2019`           |
| `properties[].coverages[]`                       | array of object | Each coverage line — `type` (e.g. `Dwelling`, `Personal Property`, `Loss of Use`, `Liability`), `limit`, `deductible`, `replacement_cost`. | `{ "type": "Dwelling", "limit": 420000 }` |
| `properties[].deductibles`                       | object          | Named deductibles — `all_peril`, `wind_hail`, `hurricane`, `earthquake`, `flood`.                                                          | `{ "all_peril": 2500 }`                   |
| `properties[].mortgagee[]`                       | array of object | Lienholder(s) on the property — `name`, `loan_number`.                                                                                     | `{ "name": "Wells Fargo Home Mortgage" }` |
| `insurer_information`                            | object          | Carrier contact details — `company_name`, `company_phone`, `claims_phone`, `agent_name`, `agent_phone`.                                    | `{ "agent_name": "Priya Nair" }`          |
| `prior_carrier`                                  | object          | Previous carrier, when shown on the declaration page.                                                                                      | `{ "name": "Allstate" }`                  |

Homeowners policies can also carry `additional_insured[]`, `policy_level_coverages[]`, and `billing_information` when the source document includes them.

***

### Insurance line → field groups

InsurGrid extracts these insurance lines. Every one shares the same envelope (`document_link`, `policy_type`, plus policy fields) — this table tells you which top-level groups to expect in `data` for each line, so you know what to look for even for a line not fully worked out above.

| Insurance line                                                                                                         | Top-level field groups you can expect                                                                                                                                                                                                                                                                         |
| ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Auto                                                                                                                   | `policy`, `policyholder`, `additional_insured`, `drivers`, `vehicles`, `discounts`, `surcharges`, `endorsements`, `exclusions`, `insurer_information`, `prior_carrier`, `billing_information` — see [full example](#example-an-auto-policy) above                                                             |
| Homeowners                                                                                                             | `policy`, `policyholder`, `additional_insured`, `properties`, `policy_level_coverages`, `insurer_information`, `billing_information`, `prior_carrier` — see [full example](#example-a-homeowners-policy) above                                                                                                |
| Condo · renters · landlord · pet · personal marine/watercraft · powersports · general liability · other/uncommon lines | `policy`, `policyholder`, `additional_insured`, `drivers`, `vehicles`, `discounts`, `surcharges`, `endorsements`, `exclusions`, `insurer_information` (auto-shaped groups apply only where relevant, e.g. `drivers`/`vehicles` on a marine or powersports policy describe the operator and the craft/vehicle) |
| Personal umbrella                                                                                                      | `policy_information`, `contacts`, `coverages`, `underlying_policies`, `scheduled_exposures`, `drivers`, `endorsements`, `premium_breakdown`, `exclusions`, `territorial_coverage`, `business_activities`, `prior_coverage`                                                                                    |
| Personal / small-business cyber                                                                                        | `policy_information`, `contacts`, `coverages`, `endorsements`, `monetary_values`                                                                                                                                                                                                                              |
| Flood                                                                                                                  | `named_insured`, `insurer_information`, `agent_information`, `insured_information`, `policy_details`, `property_information`, `coverage_details`, `renewal_payment_information`, `mortgagee_information`, `additional_interests`                                                                              |
| Directors & officers (D\&O)                                                                                            | `policy`, `policyholder`, `additional_insureds`, `coverages`, `limits_and_retentions`, `premium_details`, `endorsements`, `exclusions`, `broker_information`, `locations`, `expiring_policy`                                                                                                                  |
| Commercial package (CPP)                                                                                               | `policy_information`, `contacts`, `coverage_parts_summary`, `coverages`, `business_operations`, `site_locations`, `mortgageholders`, `loss_payees`, `excluded_persons`, `endorsements`, `premium_breakdown`, `vehicles`                                                                                       |
| Commercial auto                                                                                                        | policy info, premium info, coverage limits, vehicles, drivers, business info (FEIN/NAICS/SIC), hired & non-owned auto exposure, form type & covered autos, endorsements                                                                                                                                       |
| Business owner's policy (BOP)                                                                                          | policy info, premium info, property coverage (Section I: building/BPP/business income), liability coverage (Section II: the "Big 6" limits), built-in coverages, locations, additional insureds, endorsements                                                                                                 |
| Commercial general liability (CGL)                                                                                     | policy info, premium info, coverage limits (the CGL "Big 6"), deductibles, locations & classifications, additional insureds, endorsements                                                                                                                                                                     |
| Workers' compensation                                                                                                  | policy info, premium info, Part 1 (workers' comp coverage), Part 2 (employers liability), classifications & payroll, experience modification, locations, other-states coverage, administrative details, audit provisions, endorsements                                                                        |
| Commercial cyber                                                                                                       | policy info, premium info, coverage limits, aggregate limit, retention / self-insured retention, business info, endorsements                                                                                                                                                                                  |
| Commercial flood                                                                                                       | policy info, NFIP/program details, rating & pricing factors, premium info, coverage limits, increased cost of compliance (ICC), locations/buildings, mortgagees, claims history, endorsements                                                                                                                 |
| Commercial property (habitational)                                                                                     | policy info, premium info, coverage limits, property locations, additional info, endorsements                                                                                                                                                                                                                 |
| Commercial inland marine                                                                                               | policy info, premium info, coverages, scheduled items, additional insureds, endorsements                                                                                                                                                                                                                      |
| Commercial umbrella                                                                                                    | policy info, premium info, coverage limits, underlying policies, form type & additional info                                                                                                                                                                                                                  |

***

## `form-submission`

Fired when a consumer completes and submits an embedded form (see [Builder sessions → Embedding the fill form](/intake-forms/builder-sessions#embedding-the-fill-form)). Carries the entire response in one delivery — every answer, links to any uploaded documents, and filled ACORD forms. No follow-up API calls needed.

### Envelope

```json
{
  "topic": "form-submission",
  "data": { /* the full submission — see below */ }
}
```

### Example

```json
{
  "topic": "form-submission",
  "data": {
    "form": {
      "id": 4412,
      "public_id": "f_9c2a7b1e4d",
      "name": "Apartment Intake — Acme Agency",
      "insurance_line": "commercial"
    },
    "submission_id": 55231,
    "trace_id": "tr_5f3c9a2b7e14",
    "submitted_at": "2026-08-24T18:22:40Z",

    "agent": {
      "id": 88213,
      "name": "Jane Smith",
      "email": "jane@acme-agency.com"
    },
    "contact": {
      "name": "Jane Roe",
      "email": "jane.roe@example.com",
      "phone": "(555) 010-1234"
    },

    "answers": [
      { "question_id": "b1c2-…", "label": "Full name",
        "type": "short_text", "value": "Jane Roe" },

      { "question_id": "c3d4-…", "label": "Property address",
        "type": "address",
        "value": { "line1": "12 Oak St", "city": "Austin",
                    "state": "TX", "zip": "78701" } },

      { "question_id": "d4e5-…", "label": "Loss runs",
        "type": "file_upload",
        "value": [
          { "filename": "losses.pdf",
            "content_type": "application/pdf",
            "url": "https://files.insurgrid.com/…" }
        ] },

      { "question_id": "f6a7-…", "label": "Premises",
        "type": "data_table",
        "column_schema": [ { "key": "building", "type": "short_text" },
                           { "key": "units", "type": "number" } ],
        "value": [ { "building": "A", "units": 24 },
                    { "building": "B", "units": 18 } ] }
    ],

    "acord": {
      "is_acord_ready": true,
      "form_ids": ["125", "140", "126"],
      "archive_url": "https://files.insurgrid.com/…/acord-archive.zip"
    },

    "metadata": { "partner_ref": "your-correlation-id" }
  }
}
```

### Field reference

| Field           | Type              | Meaning                                                                                                                                      | Example                                          |
| --------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ |
| `form`          | object            | Which form was submitted — `id`, `public_id`, `name`, `insurance_line` (`personal` / `commercial`).                                          | `{ "id": 4412, "insurance_line": "commercial" }` |
| `submission_id` | integer           | Unique id for this submission. De-duplicate on this.                                                                                         | `55231`                                          |
| `trace_id`      | string            | Correlation id for this submission across InsurGrid systems — quote it when contacting support or matching logs.                             | `"tr_5f3c9a2b7e14"`                              |
| `submitted_at`  | string (ISO-8601) | When the submission completed, UTC.                                                                                                          | `"2026-08-24T18:22:40Z"`                         |
| `agent`         | object            | The agent this submission is attributed to — `id`, `name`, `email`.                                                                          | `{ "name": "Jane Smith" }`                       |
| `contact`       | object            | The consumer's contact details, when captured — `name`, `email`, `phone`.                                                                    | `{ "email": "jane.roe@example.com" }`            |
| `answers[]`     | array of object   | Every answer, each with the question's `question_id`, `label`, `type`, and submitted `value`. See [Answer value types](#answer-value-types). | —                                                |
| `acord`         | object            | Present when the form maps to ACORD forms. `is_acord_ready`, `form_ids`, `archive_url` (a zip of the filled ACORD PDFs).                     | `{ "form_ids": ["125", "140"] }`                 |
| `metadata`      | object            | Any correlation values you supplied when the form link was generated, echoed back.                                                           | `{ "partner_ref": "your-correlation-id" }`       |

### Answer value types

The `type` on each answer matches the question's `type` in [Forms API → Question fields](/intake-forms/forms#question-fields), and determines the shape of `value`:

| Question type                              | `value` shape                                                               | Example                                              |
| ------------------------------------------ | --------------------------------------------------------------------------- | ---------------------------------------------------- |
| `short_text`, `long_text`, `email`, `date` | string                                                                      | `"Jane Roe"`                                         |
| `number`                                   | number                                                                      | `24`                                                 |
| `dropdown`, `single_choice`                | string (the chosen option)                                                  | `"Commercial"`                                       |
| `multi_choice`                             | array of string                                                             | `["Fire", "Theft"]`                                  |
| `address`                                  | object: `line1`, `line2`, `city`, `state`, `zip`, `country`                 | `{ "city": "Austin", "state": "TX" }`                |
| `file_upload`                              | array of `{ filename, content_type, url }`                                  | `[{ "filename": "losses.pdf", "url": "https://…" }]` |
| `data_table`                               | array of row objects; columns described by the answer's own `column_schema` | `[{ "building": "A", "units": 24 }]`                 |

**File links.** Each `url` under `file_upload` is a time-limited download link, as is `acord.archive_url`. Fetch and store the file on your side promptly — the link expires. Re-fetching the submission via [`GET /v2/submissions/{submission_id}`](/intake-forms/submissions#get-one-submission) re-signs the links if you need them again later.

***

## Related

* [Delivery & de-duplication](/guides/delivery-dedup) — retry behavior and de-dupe patterns (content hash for policy-data, `submission_id` for form-submission).
* [Verify signatures](/guides/signing-verification) — the `x-signature` HMAC check, over the exact raw request bytes.
* [Register your endpoint & choose an auth type](/guides/register-endpoint-auth-types) — signature, Bearer, Basic, or API-key delivery auth.
* [Submissions API](/intake-forms/submissions) — re-fetch a `form-submission` payload on demand; same object shape.
* [Forms API](/intake-forms/forms) — question types and `field_mapping` referenced by `answers[]`.
* [Quickstart A — Receive policy data via webhook](/quickstarts/a-receive-policy-data)


# Testing & go-live

Build and verify your whole integration in the sandbox before touching production data.

* Sandbox & testing
* Integration checklist
* Troubleshooting: my webhook isn't arriving
* Migrating from the previous docs


# Sandbox & testing

**What it's for:** a separate staging environment where you build and prove out your entire integration — endpoint, signature verification, de-duplication, file handling — before it ever touches production data. Nothing you do in staging is billed, and no real customer is contacted.

> **New term — sandbox / staging environment:** a copy of the InsurGrid API and webhook delivery system that behaves exactly like production, but is wired to its own URLs, credentials, and test data. It's where "does my code actually work" gets answered safely.

Treat staging as a hard gate: don't add a production URL or switch to production credentials until staging works end to end, from a fired test event through your signature check to your de-dupe logic.

## Staging URLs & credentials

Every topic (`client-submission`, `form-submission`) has **separate URL slots for staging and production**, and separate credentials for each environment:

| Environment    | Endpoint URL                                                   | Credentials                                                      | Use it for                                                                       |
| -------------- | -------------------------------------------------------------- | ---------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| **Staging**    | The staging URL you register per topic in the **API section**. | Your **staging** API token and **staging** signing secret.       | Building and testing. Safe to break, redeploy, and re-test as often as you like. |
| **Production** | The production URL you register per topic.                     | Your **production** API token and **production** signing secret. | Real customer data, once staging works.                                          |

Staging and production credentials are **not interchangeable** — a staging signing secret will never validate a production webhook's signature, and vice versa. If your signature check is failing, confirm you're comparing against the secret for the environment the event actually came from.

See [Get access](/get-started/02-get-access) for where to add each URL, and [Get your API credentials](/get-started/03-api-credentials) for where each secret lives.

## Send a test event

Once your staging URL is saved for a topic, you don't have to wait for a real submission to test your handler.

1. Open the **API section** of your dashboard.
2. Find **Send test event** next to the topic you want to test (`client-submission` or `form-submission`).
3. Fire it. InsurGrid sends a realistic sample payload to your **staging** URL for that topic — the same shape, headers, and `x-signature` a live event would carry.
4. Confirm your server logged it, returned a `2xx`, and (if you've written the check yet) passed signature verification.

Re-run this as many times as you need while you build — there's no limit tied to testing.

## The delivery log & replay

Every delivery attempt to every URL you've registered — staging or production — is recorded in the **delivery log**, in the API section of your dashboard.

For each attempt you'll see:

| Field               | What it tells you                                                                                                                                                                                                |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Topic & environment | Which event type and which URL (staging/production) it was sent to.                                                                                                                                              |
| Timestamp           | When InsurGrid attempted delivery.                                                                                                                                                                               |
| Response status     | The HTTP status your endpoint returned — or `timeout` / `no response` if it never replied.                                                                                                                       |
| Delivery id         | An identifier for each delivery attempt, shown in the log for tracing a specific attempt (not part of the payload body). For de-duping your processing, see [Delivery & de-duplication](/guides/delivery-dedup). |

If an attempt failed (non-`2xx`, timeout, or connection error), you can **replay** it from the log — InsurGrid re-sends the exact same payload to your current URL, so you can confirm a fix without waiting for a new real event or firing another test event.

> The delivery log is your first stop whenever "the webhook never arrived" — before you start adding print statements. See [Troubleshooting](/testing/troubleshooting).

## Promoting to production

Once a topic's staging flow works end to end — event arrives, signature verifies, duplicates are handled, files are downloaded before they expire — promote it:

1. Deploy your endpoint to its real, publicly reachable HTTPS URL (not a tunnel — see [Expose your local endpoint](/get-started/05-local-development) for why tunnels are dev-only).
2. Add that URL as the **production** URL for the topic, in the API section.
3. Switch your server's configuration to your **production** API token and **production** signing secret.
4. Send a small amount of real traffic and watch the delivery log before assuming it's done.

The API and payload shapes are identical between staging and production — nothing about your handler code should need to change, only the URL and credentials it's pointed at.

Run through the [Integration checklist](/testing/integration-checklist) before flipping the switch.

## Related

* [Integration checklist](/testing/integration-checklist)
* [Troubleshooting: my webhook isn't arriving](/testing/troubleshooting)
* [Get access](/get-started/02-get-access)
* [Get your API credentials](/get-started/03-api-credentials)
* [Delivery & de-duplication](/guides/delivery-dedup)


# Integration checklist

**What it's for:** a final pass before you register a production URL. It covers both kinds of InsurGrid webhooks — policy data (`client-submission`) and form submissions (`form-submission`) — plus the API calls you make if you're building or reading forms directly. Skip items that don't apply to your integration.

Work through this in [sandbox](/testing/sandbox) first. Nothing here should be new if you followed a [quickstart](/quickstarts/a-receive-policy-data) — it's a checklist, not a tutorial.

## Endpoint

* [ ] Your endpoint is reachable over **HTTPS** (not plain HTTP, not `localhost`).
* [ ] It accepts a `POST` with a JSON body.
* [ ] It returns a **`2xx` status within a few seconds** — before doing any slow work (database writes, calls to other services). See [Set up a webhook endpoint](/get-started/04-webhook-endpoint).
* [ ] Your endpoint URL is saved in the **API section** for the right **topic** and the right **environment** (staging while testing, production before go-live).

## Signature verification

* [ ] You verify the `x-signature` header on **every** request before trusting the body.
* [ ] You hash the **exact raw request bytes** — not the parsed-and-re-serialized JSON. This is the most common first-integration bug; see [Verify a webhook signature](/guides/signing-verification).
* [ ] You use a **timing-safe comparison** to check the computed signature against the header.
* [ ] A signature mismatch returns `401` and the request is discarded, not processed.
* [ ] If you chose a non-default auth scheme (Bearer, Basic, API key), you verify that credential instead — see [Register your endpoint & choose an auth type](/guides/register-endpoint-auth-types).

## De-duplication

* [ ] Your handler is **idempotent** — processing the same event twice is harmless.
* [ ] You de-dupe every event — `data.submission_id` for form-submission, a content hash of `data` for policy-data.
* [ ] For `form-submission` events specifically, you also de-dupe on `data.submission_id`.
* [ ] You understand delivery is **at-least-once**: the same event can legitimately arrive more than once, and that's expected behavior, not a bug. See [Delivery & de-duplication](/guides/delivery-dedup).

## Files

* [ ] You download any file links (`data.document_link`, `answers[].value[].url`, `data.acord.archive_url`) **as soon as the webhook lands** — these links are time-limited and expire.
* [ ] You don't store the link itself as your long-term reference — you store the file (or your own re-uploaded copy of it).
* [ ] If a link has already expired, you re-fetch it via the submissions read API rather than treating it as permanently lost.

## Writes (if you're creating or managing forms via the API)

* [ ] You send an `Idempotency-Key` header (a fresh UUIDv4 per operation) on every write — create, update, publish, archive.
* [ ] You reuse the **same** key only when retrying the exact same operation, never across distinct operations.
* [ ] You understand keys are honored for a limited window (24 hours), not forever.

## Errors, pagination & rate limits

* [ ] Your code handles the standard error envelope (`message`, `code`, `request_id`, and `errors[]` on validation failures) rather than assuming success.
* [ ] You log `request_id` (or `trace_id`, on submissions/webhooks) for every failure — you'll need it if you contact support.
* [ ] List calls use **cursor pagination** correctly: you pass the `cursor` from `next_cursor` and stop when it's `null`, and you treat cursors as opaque values you never construct or parse yourself.
* [ ] You handle `429` by backing off for the number of seconds in `Retry-After`, not by retrying immediately.

## Before you flip to production

* [ ] You've fired a **test event** at your staging URL for every topic you use, and confirmed your handler processes it correctly end to end.
* [ ] You've checked the **delivery log** and seen successful (`2xx`) attempts, not just believed your own logs.
* [ ] You've deliberately triggered a duplicate (replay a delivery from the log) and confirmed your handler doesn't double-process it.
* [ ] You've swapped in your **production** API token, signing secret, and endpoint URL — and confirmed you're not still pointed at staging.

Once every relevant box is checked, you're ready to go live. See [Sandbox & testing → Promoting to production](/testing/sandbox#promoting-to-production).

## Related

* [Sandbox & testing](/testing/sandbox)
* [Troubleshooting: my webhook isn't arriving](/testing/troubleshooting)
* [Verify a webhook signature](/guides/signing-verification)
* [Delivery & de-duplication](/guides/delivery-dedup)


# Troubleshooting: my webhook isn't arriving

**What this is:** an ordered checklist for the single most common support question — "I registered an endpoint and nothing shows up." Work through it top to bottom; each step is more common than the one after it.

## 1. Is your public tunnel up?

If you're developing locally, InsurGrid can only reach you through a tunnel (ngrok or similar). Tunnels drop when:

* The tunnel process was stopped or your machine went to sleep.
* You're on a free plan whose URL **changes every restart** — and the dashboard still has the old one saved.

**Fix:** confirm the tunnel is running, copy its current `https://…` URL, and make sure it matches what's saved in the API section. See [Expose your local endpoint](/get-started/05-local-development).

## 2. Are you returning a 2xx — fast?

The single most common cause once your tunnel or deployment is confirmed up: your endpoint received the request but didn't acknowledge it correctly.

* Confirm your route returns `200` (or any `2xx`) on success — not `204` from a framework default you haven't checked, not a `500` from an unhandled exception in your handler.
* Confirm you're responding **before** doing slow work (database writes, downstream API calls). Do the slow work after you've already replied. See [Set up a webhook endpoint](/get-started/04-webhook-endpoint).
* Check your server's own error logs for exceptions thrown during the request — a crash before `res.send(...)` looks identical to "never responded."

## 3. Check the delivery log

Look at the **delivery log** in the API section. It records every attempt InsurGrid made, whether or not your server responded — it tells you where in the chain things actually broke, instead of guessing.

* **Nothing in the log at all** → InsurGrid never tried to deliver. Skip to step 4 (wrong URL/topic/environment) — this isn't a network or code problem yet.
* **An attempt is logged, but it shows a non-`2xx` status or a timeout** → confirms the issue is on your side; revisit step 2.
* **An attempt is logged with `200`** → delivery succeeded on InsurGrid's side. If you still don't see it in your own logs, the problem is downstream of your endpoint (a queue, a crash after the response, a logging gap) — not InsurGrid.

## 4. Is your endpoint URL saved for the right topic and environment?

A URL registered for `client-submission` never receives `form-submission` events, and a **staging** URL never receives events triggered against **production** (or vice versa). Double-check, in the API section:

* The URL is saved under the **topic** you expect (`client-submission` for policy data, `form-submission` for form submissions).
* You're looking at the right **environment** — a test event or a sandbox form submission goes to your **staging** URL, not production.
* The URL is typed correctly, including the path (`/hooks/insurgrid` or whatever route you chose) — a URL that's missing the path but otherwise resolves can silently 404.

## 5. Is your endpoint reachable at all?

* It must be **HTTPS**, not plain HTTP.
* If it sits behind a firewall, load balancer, or VPN, confirm InsurGrid's requests aren't being blocked before they reach your application code.
* Try `curl`-ing your own endpoint's URL from outside your network to confirm it's actually public.

***

## Signature check fails

You're receiving the webhook (it shows in the delivery log and your server logs a request), but your signature verification rejects it.

* **The #1 cause:** you're hashing the parsed-and-re-serialized JSON instead of the **exact raw bytes** InsurGrid sent. Re-serializing a parsed object almost never reproduces the original bytes exactly (whitespace, key order, number formatting can all differ), so the hash won't match even though the request is genuine. Capture the raw body *before* your framework parses it. See [Verify a webhook signature](/guides/signing-verification).
* Confirm you're using the **signing secret for the environment the event came from** — a staging secret never validates a production signature, and vice versa.
* Confirm you're comparing against the **`x-signature` header**, not a different header, and that nothing upstream (a proxy, a framework middleware) is modifying the body before your handler sees it.
* If you deliberately configured a different auth scheme (Bearer, Basic, API key) during setup, `x-signature` won't be present at all — check [Register your endpoint & choose an auth type](/guides/register-endpoint-auth-types) for what to verify instead.

## Getting duplicates

You're processing the same event more than once.

* This is **expected**, not a bug on InsurGrid's side: delivery is **at-least-once**, so the same event can legitimately arrive twice (for example, if a retry overlaps a slow success on your end).
* The fix lives in your handler, not in delivery: derive a **de-dupe key** and make reprocessing it a no-op — use `data.submission_id` for form submissions, and a **hash of `data`** for policy-data (which has no id field). See [Delivery & de-duplication](/guides/delivery-dedup).
* If you're seeing duplicates you can't collapse — form submissions with different `submission_id`s, or policy-data whose contents differ slightly — check whether your endpoint is registered twice (once directly, once through a proxy or a second dashboard entry); that produces two independent deliveries, not one retried delivery.

## Still stuck?

Have your `request_id` or `trace_id` from the delivery log ready and see [Support & contact](/support).

## Related

* [Sandbox & testing](/testing/sandbox)
* [Integration checklist](/testing/integration-checklist)
* [Verify a webhook signature](/guides/signing-verification)
* [Delivery & de-duplication](/guides/delivery-dedup)


# Migrating from the previous docs

**What happened:** the webhook and declaration-page documentation has been rebuilt and reorganized around task-based paths (get started → quickstarts → guides → API reference → testing) instead of a flat page list, and checked against what the webhooks actually do today. The existing REST-API and Zapier pages are **unchanged** and remain where they were. Use the table below to find the new home for the pages that moved.

## Redirect table

| Old page                                                                                                                                  | New location                                                                                                                  |
| ----------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `/readme` — "Insurgrid Developer Platform API" (site landing page)                                                                        | [Portal home](broken://pages/trLwwmumlaofzMKwR3fr)                                                                            |
| `/readme/insurgrid-zapier-guide` — "InsurGrid Zapier Guide"                                                                               | **Unchanged** — remains at its existing page.                                                                                 |
| `/readme/lease-compliance-checker-api` — "Lease Compliance Checker API"                                                                   | **Unchanged** — remains at its existing page.                                                                                 |
| `/policy-extraction-api` — "Policy Extraction API"                                                                                        | **Unchanged** — remains at its existing page.                                                                                 |
| `/policy-comparison-api` — "Policy Comparison API"                                                                                        | **Unchanged** — remains at its existing page.                                                                                 |
| `/data-webhook-page` and `/data-webhook-page/readme` — "Data Webhook Page" / "Welcome"                                                    | [What you can build](/get-started/01-what-you-can-build)                                                                      |
| `/data-webhook-page/quickstart` — "Login"                                                                                                 | [Get access](/get-started/02-get-access)                                                                                      |
| `/data-webhook-page/destination`, `/destination/create-destination`, `/destination/update-destination`, `/destination/delete-destination` | [Register your endpoint & choose an auth type](/guides/register-endpoint-auth-types)                                          |
| `/data-webhook-page/endpoint-logs` and `/endpoint-logs/logs-filter` — "EndPoint Logs" / "Logs - Filter"                                   | [Sandbox & testing → The delivery log & replay](/testing/sandbox#the-delivery-log--replay)                                    |
| `/data-webhook-page/webhooks` — "Webhooks" (overview)                                                                                     | [Set up a webhook endpoint](/get-started/04-webhook-endpoint) and [Delivery & de-duplication](/guides/delivery-dedup)         |
| `/data-webhook-page/webhooks/data-format/auto/overview` and `/auto/technical-reference` — "Auto"                                          | API reference — [Webhook events & payloads](/api-reference/webhook-events), auto insurance section                            |
| `/data-webhook-page/webhooks/data-format/homeowners/overview` and `/homeowners/technical-reference` — "Homeowners"                        | API reference — [Webhook events & payloads](/api-reference/webhook-events), homeowners insurance section                      |
| `/data-webhook-page/insurgrid-assistant` and `/insurgrid-assistant/privacy-policy`                                                        | Not part of developer documentation — this covered a browser extension's privacy policy, hosted separately from the API docs. |

## What's actually different, not just moved

* **Two payload envelopes, not one.** Policy data (`client-submission`) and form submissions (`form-submission`) are now documented as distinct events with distinct payload shapes, instead of being mixed together under one "Data Format" tree.
* **Sandbox is first-class.** The old site's "EndPoint Logs" page is now part of a full [Sandbox & testing](/testing/sandbox) flow that also covers test events and promoting to production.
* **The REST-API and Zapier pages are unchanged** — this rebuild covers the webhook and declaration-page docs; the existing API-reference and Zapier pages stay as they are.

## Related

* [Changelog](/changelog)
* [Portal home](broken://pages/trLwwmumlaofzMKwR3fr)
* [Support & contact](/support)


# Support & contact

**What this is for:** these docs are written so a working integration doesn't require asking anyone anything. But when you genuinely get stuck — an undocumented edge case, something that looks like a bug, a question about your account — here's the escape hatch.

## Before you reach out

Two pages solve most problems faster than a support ticket:

* [Troubleshooting: my webhook isn't arriving](/testing/troubleshooting) — the ordered checklist for the most common failure.
* [Integration checklist](/testing/integration-checklist) — a final pass that catches the usual gaps (signature over raw bytes, de-dupe, expiring file links).

## Contact InsurGrid

Email [**support@insurgrid.com**](mailto:support@insurgrid.com) with your question. Include a `request_id` or `trace_id` and a rough timestamp (see below) so we can find the exact request or event fast.

## Quote your request\_id or trace\_id

Every error response carries a `request_id`. Every submission and webhook delivery carries a `trace_id`. Both are correlation identifiers InsurGrid support can use to pull up the exact request or event you're asking about — without one, diagnosing an issue takes much longer, and sometimes isn't possible at all.

> **New term — correlation id:** a unique value attached to one specific request or event, so it can be looked up later without ambiguity. `request_id` identifies one API call; `trace_id` identifies one submission or webhook delivery.

Before contacting support:

1. Find the relevant `request_id` (in an error response body) or `trace_id` (in a submission or webhook payload, or the delivery log).
2. Include it verbatim in your message, along with the topic/endpoint and roughly when it happened.
3. If it's a webhook delivery issue, also check the **delivery log** in the API section first — it often already shows what went wrong.

A support request with a `request_id` or `trace_id` and a rough timestamp is almost always resolved faster than one without.

## Related

* [Troubleshooting: my webhook isn't arriving](/testing/troubleshooting)
* [Sandbox & testing](/testing/sandbox)
* [Glossary](/glossary)


# Glossary

**What this is:** every term in these docs is defined inline the first time it's used (look for a "New term" callout). This page is a quick backstop when you land on a page mid-way and need a definition without hunting for where it was first introduced.

| Term                       | Plain definition                                                                                                                                                                                                                                                    |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Webhook**                | An HTTP `POST` that InsurGrid sends to a URL you control when something happens — you don't ask us for the data, we push it to you.                                                                                                                                 |
| **Endpoint**               | The URL on your server that receives a webhook `POST`.                                                                                                                                                                                                              |
| **2xx**                    | Any HTTP status code from 200 to 299 (e.g. `200 OK`). To InsurGrid it means "received, thanks" — anything else, or a timeout, means delivery failed and gets retried.                                                                                               |
| **HMAC signature**         | A fingerprint of a webhook's raw body, computed with a shared secret. Because only you and InsurGrid know the secret, a matching fingerprint proves the request is genuine and unaltered. Sent in the `x-signature` header, computed as HMAC-SHA256.                |
| **Signing secret**         | The secret used to compute and verify the HMAC signature on webhooks. Shown once when generated; stays on your server, never sent to InsurGrid.                                                                                                                     |
| **Bearer token**           | A secret string sent as `Authorization: Bearer <token>` that authenticates an API call — whoever holds it can call the API as you.                                                                                                                                  |
| **Management token**       | The specific bearer token used for Forms API calls (creating, reading, and publishing forms and reading submissions) — issued server-to-server by InsurGrid.                                                                                                        |
| **Idempotency key**        | A value you generate (`Idempotency-Key` header) and send on a write request, so retrying the exact same request with the same key never performs the operation twice.                                                                                               |
| **Cursor**                 | An opaque value returned by a list endpoint (`next_cursor`) that you pass back to fetch the next page. Don't construct or parse it yourself.                                                                                                                        |
| **At-least-once delivery** | InsurGrid's delivery guarantee for webhooks: an event will be delivered, but may occasionally be delivered more than once. Your handler must be safe to run twice on the same event.                                                                                |
| **Topic**                  | The kind of event a webhook carries — `client-submission` for extracted policy data, `form-submission` for a completed intake form. You register a URL per topic.                                                                                                   |
| **Agency vs. agent**       | A form (and its submissions) is owned either by an **agency** (shared across everyone in it) or by a specific **agent** within that agency. Every form's public fill link is per agent, regardless of ownership scope — that's who a submission gets attributed to. |
| **ACORD**                  | The standardized insurance industry forms (e.g. ACORD 125, 140) that InsurGrid can auto-fill from a completed intake form and return to you as PDFs.                                                                                                                |

## Related

* [What you can build](/get-started/01-what-you-can-build)
* [Support & contact](/support)


# Changelog

**What this is:** a dated record of changes to these docs and to the underlying API/webhook behavior they document. Each entry is dated so you can tell whether something you're relying on has changed since you last read a page — check here if behavior seems different from what you remember, or from what a saved copy of a page says.

***

## 2025-10-13 — Developer portal rebuilt

The InsurGrid webhook and declaration-page documentation has been rebuilt and reorganized.

* **Reorganized around your task**: [Get started](/get-started/01-what-you-can-build) → [Quickstarts](/quickstarts/a-receive-policy-data) → [Guides](/guides/authentication) → API reference → [Testing & go-live](/testing/sandbox).
* **Policy data and form submissions are now documented as two distinct webhook events** (`client-submission` and `form-submission`), each with its own payload reference, instead of being mixed together.
* **Added a full sandbox flow**: staging URLs and credentials, a send-a-test-event action, and a delivery log with replay — see [Sandbox & testing](/testing/sandbox).
* **The existing REST-API and Zapier pages are unchanged** and remain where they were. See [Migrating from the previous docs](/testing/migrating) for the moved-page mapping.

## Related

* [Migrating from the previous docs](/testing/migrating)
* [Support & contact](/support)


# Policy Extraction API

Send one policy PDF, get back its contents as structured data: policy details, coverages, drivers, vehicles, premiums and more.

A policy PDF holds everything you need — limits, deductibles, drivers, vehicles, premiums — but not in a form your systems can use. This API does the reading for you.

Upload a policy and say what kind it is. InsurGrid reads the document and returns its contents as structured JSON, ready to drop into your CRM, quoting tool or review workflow.

It works on personal and commercial lines, from auto and homeowners through to workers' compensation, cyber and commercial packages.

**You'll need:** an agent API key, and the policy document as a PDF.

***

## How it works

Reading a policy takes longer than a web request should, so the work happens in the background:

1. **Submit** the PDF in one request.
2. **Get an id back** straight away, while processing continues.
3. **Poll** with that id every few seconds.
4. **Stop** when the status is `completed` or `error`.

A typical extraction finishes in under a minute.

## Before you start

Every request carries your API key as a bearer token:

```
Authorization: Bearer YOUR_API_KEY
```

This is the same key the [Policy Comparison API](/policy-comparison-api) uses — create and revoke it in the agent dashboard. A key belongs to the agent who made it, and only that agent can read the extractions it creates.

Keys are server-side credentials. Keep them out of browser code, mobile apps, public repositories and logs.

| Environment | Base URL                                |
| ----------- | --------------------------------------- |
| Staging     | `https://phoenix-staging.insurgrid.com` |
| Production  | `https://phoenix.insurgrid.com`         |

Everything below lives under `/api/v1`. Only the host changes between environments — paths, fields and errors are identical, and a key works in one environment only. See [Environments](/guides/environments).

***

## Quick start

**Submit an extraction.** The PDF goes in the same request — there's no separate upload step.

```bash
curl -X POST 'https://phoenix-staging.insurgrid.com/api/v1/policy-extractions' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -F 'policy_type=auto_policy' \
  -F 'file=@policy.pdf;type=application/pdf'
```

You get a `202 Accepted` and an id:

```json
{
  "id": "09342f6c-3c68-4747-90d9-2090599748b0",
  "status": "processing",
  "createdAt": "2026-10-14T11:53:39Z"
}
```

**Then poll for the result**, every 3–5 seconds:

```bash
curl 'https://phoenix-staging.insurgrid.com/api/v1/policy-extractions/09342f6c-3c68-4747-90d9-2090599748b0' \
  -H 'Authorization: Bearer YOUR_API_KEY'
```

While it's working you'll see `"status": "processing"`. When it's done, the same response carries a `result`.

***

## Submitting an extraction

`POST /api/v1/policy-extractions`, as `multipart/form-data`.

| Field         | Required | What it is                                         |
| ------------- | -------- | -------------------------------------------------- |
| `policy_type` | Yes      | Which kind of policy this is. See the table below. |
| `file`        | Yes      | The policy PDF. Exactly one file per request.      |

No other fields are accepted. To extract several policies, send one request per PDF.

### Policy types

| Personal                   | Commercial                      |
| -------------------------- | ------------------------------- |
| `auto_policy`              | `commercial_auto_policy`        |
| `home_policy`              | `commercial_home_policy`        |
| `condo_policy`             | `commercial_bop`                |
| `renters_policy`           | `commercial_general_liability`  |
| `landlord_policy`          | `commercial_worker_comp_policy` |
| `umbrella_policy`          | `commercial_umbrella`           |
| `flood_policy`             | `commercial_flood`              |
| `cyber_policy`             | `commercial_cyber`              |
| `marine_policy`            | `commercial_marine`             |
| `power_sports_policy`      | `commercial_directors_officers` |
| `pet_policy`               | `commercial_package`            |
| `general_liability_policy` | `commercial_other`              |
| `other_policy`             |                                 |

Anything else returns `422 unsupported_policy_type`. Pick the type that matches the document — it decides which fields the extraction looks for.

### Document requirements

PDFs only — sent with a `.pdf` extension and the `application/pdf` content type. Password-protected, corrupted or unreadable files may fail during processing.

| Limit            | Value |
| ---------------- | ----- |
| PDFs per request | 1     |
| File size        | 75 MB |
| Pages per PDF    | 400   |

Large scanned PDFs are compressed automatically before they're read, so you don't need to shrink them yourself.

***

## Reading the result

`GET /api/v1/policy-extractions/{id}`, using a key belonging to the agent that created it.

| Status       | Done? | What it means                             |
| ------------ | ----- | ----------------------------------------- |
| `processing` | No    | Still reading the document. Keep polling. |
| `completed`  | Yes   | The extracted data is in `result`.        |
| `error`      | Yes   | Processing failed. Stop polling.          |

A completed response, trimmed:

```json
{
  "id": "09342f6c-3c68-4747-90d9-2090599748b0",
  "status": "completed",
  "policyType": "auto_policy",
  "filename": "policy.pdf",
  "createdAt": "2026-10-14T11:53:39Z",
  "result": {
    "document_id": "09342f6c-3c68-4747-90d9-2090599748b0",
    "policy_type": "auto_policy",
    "structured_data": {
      "policy": {
        "provider": "Sample Mutual Insurance Company",
        "policy_number": "TEST-AUTO-0000001",
        "policy_term": "6 Months"
      },
      "policyholder": { "name": "Testy McTestface", "address": {} },
      "drivers": [
        { "name": "Testy McTestface", "date_of_birth": "1980-01-01", "relationship_to_policyholder": "Self" }
      ],
      "vehicles": [
        {
          "year": 2020,
          "make": "Toyota",
          "model": "Camry",
          "coverages": [
            { "type": "Bodily Injury Liability", "limits": "$100,000 / $300,000", "premium": 412.0, "deductible": 0 }
          ]
        }
      ],
      "discounts": [],
      "surcharges": [],
      "billing_information": {},
      "insurer_information": {}
    }
  }
}
```

The extracted data is in `result.structured_data`. Its sections follow the policy type — an auto policy has `drivers` and `vehicles`, a homeowners policy has the dwelling and its coverages, and commercial policies carry their own sections. Common ones include:

| Section                      | What it gives you                                                  |
| ---------------------------- | ------------------------------------------------------------------ |
| `policy`                     | Carrier, policy number, term, dates and premium totals.            |
| `policyholder`               | Named insured, address and contact details.                        |
| `drivers`, `vehicles`        | Auto only: who is covered, what is covered, per-vehicle coverages. |
| `discounts`, `surcharges`    | What's been applied to the premium.                                |
| `endorsements`, `exclusions` | Changes to and limits of the standard coverage.                    |
| `billing_information`        | Payment status and due dates.                                      |
| `insurer_information`        | The carrier and the agent on the policy.                           |

What appears depends on the policy type and on what the document actually contains — fields can be absent, empty or `null`.

> **Check the result against the policy document.** The data is generated from the PDF you supplied. Confirm limits, exclusions, deductibles and premiums against the original before relying on them.

### When it fails

```json
{
  "id": "09342f6c-3c68-4747-90d9-2090599748b0",
  "status": "error",
  "policyType": "auto_policy",
  "filename": "policy.pdf",
  "createdAt": "2026-10-14T11:53:39Z",
  "error": {
    "code": "extraction_failed",
    "message": "The extraction could not be completed."
  }
}
```

No further polling is needed. You can submit the same PDF again.

### If it never finishes

An extraction should end in `completed` or `error`. If one is still `processing` after **10 minutes** (**20 minutes** for `commercial_package`), treat it as failed and submit the PDF again.

***

## Errors

Errors always arrive in the same shape:

```json
{
  "error": {
    "code": "invalid_request",
    "message": "Required multipart fields are missing or malformed."
  }
}
```

When the failure relates to an extraction that was already created, the body also carries `error.extractionId`.

| Status | Code                           | What went wrong                                                                         |
| ------ | ------------------------------ | --------------------------------------------------------------------------------------- |
| 400    | `invalid_request`              | A field is missing, duplicated, unexpected or malformed — including more than one file. |
| 401    | `invalid_api_key`              | The key is missing, invalid, revoked, or from the other environment.                    |
| 404    | `not_found`                    | No such extraction on this key's agent.                                                 |
| 413    | `file_too_large`               | Over the size or page limit.                                                            |
| 415    | `pdf_required`                 | Something that isn't a readable PDF.                                                    |
| 422    | `unsupported_policy_type`      | `policy_type` isn't one of the values above.                                            |
| 429    | `rate_limited`                 | Too many requests. Wait for `Retry-After` seconds.                                      |
| 500    | `extraction_submission_failed` | The extraction couldn't be submitted. Try again.                                        |
| 503    | `service_unavailable`          | Temporarily unavailable. Try again.                                                     |

## Rate limits

**60 requests per minute per agency**, counting both submissions and reads. Going over returns `429` with a `Retry-After` header telling you how many seconds to wait.

Extractions aren't metered — there are no credits to run out of, and this limit is the only throttle. It's separate from the Policy Comparison API's: extraction calls don't use up your comparison allowance, and the other way round.

## Keeping it safe

Policy documents contain personal information, so:

* call the API from your server, never the browser;
* store keys in a secrets manager or encrypted environment variable;
* use a separate key per agent or integration, and revoke immediately if one may have leaked;
* avoid logging uploaded documents or whole API responses.

## Related

* [Policy Comparison API](/policy-comparison-api) — compare a current and a proposed policy.
* [Environments](/guides/environments) — staging and production addresses.
* [Authentication & tokens](/guides/authentication)


# Policy Comparison API

Send a client's current and proposed policy PDFs, get back a structured breakdown of what changed.

When you put a new policy in front of a client, the hard part is explaining what actually changed. This API does that reading for you.

Upload the policy they have today and the one you're proposing. InsurGrid reads both documents and returns a structured comparison: coverage that moved, how the premium differs, drivers and vehicles added or dropped, and plain-language talking points you can take into the conversation.

It works on personal and commercial lines, from auto and homeowners through to workers' compensation, cyber and commercial packages.

**You'll need:** an agent API key, and the policy documents as PDFs.

***

## How it works

Reading two policies takes longer than a web request should, so the work happens in the background:

1. **Submit** both sets of PDFs in one request.
2. **Get an id back** straight away, while processing continues.
3. **Poll** with that id every few seconds.
4. **Stop** when the status is `completed` or `error`.

A typical comparison finishes in well under a minute.

## Before you start

Every request carries your API key as a bearer token:

```
Authorization: Bearer YOUR_API_KEY
```

Create and revoke keys in the agent dashboard. A key belongs to the agent who made it, and only that agent can read the comparisons it creates.

Keys are server-side credentials. Keep them out of browser code, mobile apps, public repositories and logs.

| Environment | Base URL                                |
| ----------- | --------------------------------------- |
| Staging     | `https://phoenix-staging.insurgrid.com` |
| Production  | `https://phoenix.insurgrid.com`         |

Everything below lives under `/api/v1`. Only the host changes between environments — paths, fields and errors are identical, and a key works in one environment only. See [Environments](/guides/environments).

***

## Quick start

**Submit a comparison.** The PDFs go in the same request — there's no separate upload step.

```bash
curl -X POST 'https://phoenix-staging.insurgrid.com/api/v1/policy-comparisons' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -F 'prospect=Acme Corporation' \
  -F 'policy_type=auto_policy' \
  -F 'current_policy_files=@current-policy.pdf;type=application/pdf' \
  -F 'proposed_policy_files=@proposed-policy.pdf;type=application/pdf'
```

You get a `202 Accepted` and an id:

```json
{
  "id": "ef23d8f5-0dc3-46f2-b70d-6e0aef3ec6ae",
  "status": "processing",
  "createdAt": "2026-09-29T12:32:11Z"
}
```

**Then poll for the result**, every 3–5 seconds:

```bash
curl 'https://phoenix-staging.insurgrid.com/api/v1/policy-comparisons/ef23d8f5-0dc3-46f2-b70d-6e0aef3ec6ae' \
  -H 'Authorization: Bearer YOUR_API_KEY'
```

While it's working you'll see `"status": "processing"`. When it's done, the same response carries a `result`.

***

## Submitting a comparison

`POST /api/v1/policy-comparisons`, as `multipart/form-data`.

| Field                   | Required | What it is                                                         |
| ----------------------- | -------- | ------------------------------------------------------------------ |
| `prospect`              | Yes      | The client or prospect name, 1–200 characters.                     |
| `policy_type`           | Yes      | Which kind of policy these are. See the table below.               |
| `current_policy_files`  | Yes      | The policy they have now. Repeat the field to attach several PDFs. |
| `proposed_policy_files` | Yes      | The policy you're proposing. Repeat the field to attach several.   |

For a policy that spans several documents, repeat the field rather than zipping them together:

```bash
  -F 'current_policy_files=@current-property.pdf;type=application/pdf' \
  -F 'current_policy_files=@current-liability.pdf;type=application/pdf' \
```

### Policy types

| Personal          | Commercial                      |
| ----------------- | ------------------------------- |
| `auto_policy`     | `commercial_auto_policy`        |
| `home_policy`     | `commercial_home_policy`        |
| `umbrella_policy` | `commercial_bop`                |
|                   | `commercial_general_liability`  |
|                   | `commercial_worker_comp_policy` |
|                   | `commercial_umbrella`           |
|                   | `commercial_cyber`              |
|                   | `commercial_marine`             |
|                   | `commercial_package`            |

Anything else returns `422 unsupported_policy_type`.

### Document requirements

PDFs only — sent with a `.pdf` extension and the `application/pdf` content type. Password-protected, corrupted or unreadable files may fail during processing.

| Limit                | Value  |
| -------------------- | ------ |
| PDFs per side        | 10     |
| Combined upload size | 200 MB |
| Pages per PDF        | 1,000  |

***

## Reading the result

`GET /api/v1/policy-comparisons/{id}`, using a key belonging to the agent that created it.

| Status       | Done? | What it means                              |
| ------------ | ----- | ------------------------------------------ |
| `processing` | No    | Still reading the documents. Keep polling. |
| `completed`  | Yes   | The comparison is in `result`.             |
| `error`      | Yes   | Processing failed. Stop polling.           |

A completed response, trimmed:

```json
{
  "id": "ef23d8f5-0dc3-46f2-b70d-6e0aef3ec6ae",
  "status": "completed",
  "prospect": "Acme Corporation",
  "policyType": "auto_policy",
  "createdAt": "2026-09-29T12:32:11Z",
  "completedAt": "2026-09-29T12:32:52Z",
  "result": {
    "comparison": {
      "provider_changed": true,
      "key_changes": [
        "Liability limits increased from $100,000/$300,000 to $300,000/$500,000.",
        "The collision deductible increased from $1,000 to $2,500."
      ],
      "talking_points": [
        "The proposed policy provides higher liability protection."
      ],
      "premium_comparison": {
        "annual": { "old": 1200, "new": 1450, "difference": 250, "percent_change": 20.83 }
      }
    },
    "current_or_old_policy": { "policy": {}, "drivers": [], "vehicles": [], "discounts": [], "surcharges": [] },
    "new_policy":            { "policy": {}, "drivers": [], "vehicles": [], "discounts": [], "surcharges": [] }
  }
}
```

`result` has three parts:

* **`current_or_old_policy`** and **`new_policy`** — what was read out of each document: policy details, drivers, vehicles, discounts and surcharges.
* **`comparison`** — the differences between them.

Inside `comparison`:

| Field                         | What it gives you                                        |
| ----------------------------- | -------------------------------------------------------- |
| `key_changes`                 | The headline differences, written as short sentences.    |
| `talking_points`              | Phrasing you can use with the client.                    |
| `premium_comparison`          | Old and new premiums, the difference and the percentage. |
| `coverage_comparison_table`   | Coverage by coverage, old limit against new.             |
| `coverage_changes_by_vehicle` | The same, broken down per vehicle.                       |
| `driver_changes`              | Drivers added, removed or amended.                       |
| `vehicle_changes`             | Vehicles added, removed, replaced or amended.            |
| `premium_difference_reasons`  | Why the premium moved.                                   |
| `provider_changed`            | Whether the carrier is different.                        |

What appears depends on the policy type and on what the documents actually contain — fields can be absent, empty or `null`.

> **Check the result against the policy documents.** The comparison is generated from the PDFs you supplied. Confirm limits, exclusions, deductibles and premiums against the originals before advising a client on them.

### When it fails

```json
{
  "id": "ef23d8f5-0dc3-46f2-b70d-6e0aef3ec6ae",
  "status": "error",
  "error": {
    "code": "comparison_failed",
    "message": "The comparison could not be completed."
  }
}
```

No further polling is needed.

***

## Errors

Errors always arrive in the same shape:

```json
{
  "error": {
    "code": "invalid_request",
    "message": "Required multipart fields are missing or malformed."
  }
}
```

When the failure relates to a comparison that was already created, the body also carries `error.comparisonId`.

| Status | Code                           | What went wrong                                                      |
| ------ | ------------------------------ | -------------------------------------------------------------------- |
| 400    | `invalid_request`              | A required field is missing, duplicated or malformed.                |
| 401    | `invalid_api_key`              | The key is missing, invalid, revoked, or from the other environment. |
| 404    | `not_found`                    | No such comparison on this key's agent.                              |
| 413    | `too_many_files`               | More than 10 PDFs on one side.                                       |
| 413    | `file_too_large`               | Over the size or page limit.                                         |
| 415    | `pdf_required`                 | Something that isn't a readable PDF.                                 |
| 422    | `unsupported_policy_type`      | `policy_type` isn't one of the values above.                         |
| 429    | `rate_limited`                 | Too many requests. Wait for `Retry-After` seconds.                   |
| 500    | `comparison_submission_failed` | The comparison couldn't be submitted.                                |
| 503    | `service_unavailable`          | Temporarily unavailable. Try again.                                  |

## Rate limits

**60 requests per minute per agency**, counting both submissions and reads. Going over returns `429` with a `Retry-After` header telling you how many seconds to wait.

Comparisons aren't metered — there are no credits to run out of, and this limit is the only throttle.

## Keeping it safe

Policy documents contain personal information, so:

* call the API from your server, never the browser;
* store keys in a secrets manager or encrypted environment variable;
* use a separate key per agent or integration, and revoke immediately if one may have leaked;
* avoid logging uploaded documents or whole API responses.

## Related

* [Environments](/guides/environments) — staging and production addresses.
* [Authentication & tokens](/guides/authentication)


# Data Webhook Page


# Welcome

## Insurgrid Webhook Service

Welcome to the Insurgrid Webhook Service! This service allows you to create custom webhooks and integrate with various platforms to retrieve real-time data from Insurgrid.

### Key Features

* **Custom Webhooks**: Easily create and manage your webhooks.
* **Real-time Integration**: Seamlessly connect with multiple services to fetch live data.
* **Flexible Configuration**: Tailor the webhook settings to suit your integration needs.

For more information on setting up and configuring your webhooks, please refer to our documentation.


# Login

<figure><img src="https://3825213797-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FjBgAFiZ99Eug1CmRq2QO%2Fuploads%2F3vZ2W4ilsWobK8ic7nst%2FScreenshot%202025-08-20%20at%2011.35.02%E2%80%AFPM.png?alt=media&amp;token=64163221-e075-4a05-b398-cb398fd5085c" alt="login_screen"><figcaption></figcaption></figure>

{% hint style="info" %}
To log in, simply enter your registered email address, and the system will identify you, sending an OTP to your email for verification.
{% endhint %}

### OTP Verification

{% hint style="info" %}
To log in, enter the email address registered with the system. You will receive a 6-digit OTP to complete the login process.
{% endhint %}

<div data-full-width="false"><figure><img src="https://3825213797-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FjBgAFiZ99Eug1CmRq2QO%2Fuploads%2FOpJTtmYTskb2pum5jELZ%2FScreenshot%202025-08-20%20at%2011.37.27%E2%80%AFPM.png?alt=media&amp;token=2237274d-dc64-4dfd-97ef-9957dd5edd9a" alt="otp_verification_screen"><figcaption></figcaption></figure></div>

### Resend OTP

{% hint style="info" %}
The OTP will expire in 10 minutes. You can resend the OTP by clicking on "Resend OTP". The maximum number of OTPs you can request is 10 per hour. If you entered the wrong email address, click on "Back to Email Address" to re-enter your email.
{% endhint %}


# Destination

#### Destination - Home Page

* Once successfully logged in by entering your correct email and OTP, you will be directed to the system admin panel home page, which serves as the destination. This page allows you to register, list, update, and delete endpoints.

<figure><img src="https://3825213797-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FjBgAFiZ99Eug1CmRq2QO%2Fuploads%2FubDkWd8IvFyMBZaolca4%2FDestination.png?alt=media&amp;token=b38c9f07-5848-4de4-aeb1-c9beff38e0f3" alt="destination"><figcaption></figcaption></figure>


# Create Destination

### Register Destination(Endpoint)&#x20;

To register an endpoint, click on the "Register" button. Currently, the system allows only one endpoint to be created at a time. Once an endpoint is created, the "Register" button will disappear. Upon clicking "Register," a popup will appear, requiring you to enter the following details.

{% stepper %}
{% step %}
URL

Ensure the entered HTTPS URL is valid and ready to receive data via the POST method.
{% endstep %}

{% step %}

#### Auth Type

When registering a new endpoint, choose the authentication type that aligns with your backend capabilities. Rest assured, your secret keys and tokens will be securely stored to ensure data protection and privacy.
{% endstep %}
{% endstepper %}

<figure><img src="https://3825213797-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FjBgAFiZ99Eug1CmRq2QO%2Fuploads%2FzQNi0L8aB1YRKRUDmDHo%2Fregister_endpint.png?alt=media&amp;token=dcbaa351-a9f8-400f-9f0a-927e11f09e20" alt=""><figcaption></figcaption></figure>

Explore Different Authentication Methods

1. Default
2. Custom SHA-256 HMAC Signature
3. Basic Auth
4. Bearer Token
5. Api Key

***

#### Default Type

Selecting the default type means there is no authentication. Our system will call your API without any authentication but will include our default headers.

***

#### Custom SHA-256 HMAC Signature

When selecting the custom SHA-256 HMAC Signature, you are opting for a more secure form of authentication. You need to define your header key and specify the signing secret that your backend supports. This method ensures that the communication between your system and the API is encrypted and authenticated, protecting it from tampering and unauthorized access. Ensure that both the client and server agree on the signing secret and header key to successfully implement this authentication type.

<figure><img src="https://3825213797-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FjBgAFiZ99Eug1CmRq2QO%2Fuploads%2FrHKo3eLxyIPZYGFGEG3K%2Fhmac.png?alt=media&amp;token=a00955b8-290d-4a49-a008-eda8b0114436" alt=""><figcaption></figcaption></figure>

***

Basic Auth

When you select Basic Auth, you will need to enter both a username and a password. Your password will be securely stored and should be kept confidential to prevent unauthorized access. It's advisable to use a strong password, combining letters, numbers, and special characters, to enhance security.

<figure><img src="https://3825213797-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FjBgAFiZ99Eug1CmRq2QO%2Fuploads%2FgjfExO9Hh9K7nWIxa0jV%2Fbasic_auth.png?alt=media&amp;token=f5a7af7f-ac32-4d9b-8d9e-46a94ef6331a" alt=""><figcaption></figcaption></figure>

***

#### Bearer Token

When you select Bearer Token, you must provide the token value without prefacing it with **"Bearer"**. The request will be sent with the Authorization header properly formatted to include the Bearer token automatically. Ensure the token is kept secure and never shared inappropriately.

<figure><img src="https://3825213797-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FjBgAFiZ99Eug1CmRq2QO%2Fuploads%2Fyp92ImVjM4F2aOBTiMSd%2Fbearer_token.png?alt=media&amp;token=c3b2378b-d649-417e-8758-1db63d794447" alt=""><figcaption></figcaption></figure>

***

#### Api Key&#x20;

To select an API Key, include both the key and its corresponding value. Also, specify where to include the key, either in the header or as a query parameter.

<figure><img src="https://3825213797-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FjBgAFiZ99Eug1CmRq2QO%2Fuploads%2FW1QXAuk7KyTPL5RxhFO2%2Fapi_key.png?alt=media&amp;token=0ae24369-9f17-44b6-9443-614f84f11b24" alt=""><figcaption></figcaption></figure>


# Update Destination

Once you register successfully, you will be able to view a list of endpoints. This list includes various details such as the endpoint URL, associated topic, authentication type, creation date, and available actions for updating or deleting the endpoint.

<figure><img src="https://3825213797-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FjBgAFiZ99Eug1CmRq2QO%2Fuploads%2FGklHyA6Ds1FZHp7f4bRa%2Flist.png?alt=media&amp;token=4340d0bd-2a46-433d-8dce-51f4c9c038af" alt=""><figcaption></figcaption></figure>

### Update Action

Once you click on the edit icon (pencil icon), the system will prompt you with a pre-filled form displaying the current endpoint details. Here, you can update fields such as the endpoint URL or the authentication type according to your requirements. Ensure to review the changes before saving to keep the configuration accurate.

<figure><img src="https://3825213797-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FjBgAFiZ99Eug1CmRq2QO%2Fuploads%2FwkgYIokbrwuXPekhogcL%2Fedit.png?alt=media&amp;token=e13af870-83b9-494b-82f0-4fdde4988934" alt=""><figcaption></figcaption></figure>


# Delete Destination

* Navigate to the endpoint list where all available endpoints are displayed.
* In the action column, you'll find two options for each endpoint: **Edit** and **Delete**.
* If you click on **Delete**, a confirmation prompt will appear to ensure you want to proceed with the deletion.
* You have the option to either confirm the deletion or cancel the action.
* Selecting **Confirm Delete** will permanently remove the endpoint, while choosing **Cancel** will close the confirmation prompt without making changes.

<figure><img src="https://3825213797-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FjBgAFiZ99Eug1CmRq2QO%2Fuploads%2Fp3qpSzi7FuKA6R7QA1Na%2Fdelete.png?alt=media&amp;token=f1b08705-3c47-4c1d-8268-66d6860f652e" alt=""><figcaption></figcaption></figure>


# EndPoint Logs

#### Logs

The Logs page provides a comprehensive view of all recorded events, enabling administrators to dispatch events directly to your endpoint. This feature allows you to effortlessly monitor and analyze event summaries, ensuring you have access to vital information for effective management and troubleshooting.

<figure><img src="https://3825213797-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FjBgAFiZ99Eug1CmRq2QO%2Fuploads%2FHg1NuQTQBGYtz6Q5EDgE%2Flogs.png?alt=media&amp;token=0c945503-c560-467e-aeb2-5ba57ebff5b5" alt="destination"><figcaption></figcaption></figure>


# Logs - Filter


# Webhooks

## Webhook Callbacks — Overview

This section documents outgoing webhook payloads we send to client endpoints when declaration page is uploaded. Current supported payloads:

* **Auto**
* **Homeowners**

### Delivery contract

* **Method:** `POST`
* **Content-Type:** `application/json`
* **Retry policy:** Exponential backoff, 3 retries by default. See Retry Behavior.
* **Idempotency:** Each webhook contains `policy.policy_type` and `policy.policy_number` entries to correlate events.

### Best practices for integrators

1. Always respond `200 OK` quickly. If temporary failure occurs, respond with `5xx` or timeout; we will retry.
2. Store `policy.policy_type`  and `policy.policy_number` as your primary key for mapping.
3. Be tolerant of extra fields — payloads may add fields in future versions.
4. Use the downloadable JSON Schema for strict validation.


# Data Format


# Auto


# Overview

## Auto Insurance Webhook

This webhook delivers **auto insurance policy data** when new declaration page is uploaded.\
It provides details about the policy, insured drivers, covered vehicles, coverages, discounts, billing, and insurer contact information.

***

### 🔑 Key Sections in the Payload

#### Document Link

Includes a secure link to the client-uploaded document. The link expires within one hour in accordance with security requirements.

#### Policy Information

* Provider and policy number
* Policy type, term, and effective dates
* Premium and billing details
* Change history (e.g., premium adjustments)

#### Drivers

* Policyholder and listed drivers
* Date of birth and relationship to policyholder

#### Vehicles

* Vehicle details (make, model, year, VIN)
* Usage and location
* Coverage types, limits, premiums, and deductibles

#### Discounts

* Safe driver, multi-policy, early quote, etc.

#### Endorsements

* Special provisions, liability, medical payments, uninsured motorist coverage, etc.

#### Policyholder

* Name and contact details
* Address
* Rating information (e.g., accident-free years)

#### Insurer Information

* Company name and contact numbers
* Agent details (if available)
* Claims and service phone numbers

***

### 📦 Example Payload

```json
{
  "topic": "client-submission",
  "data": {
    "document_link": "https://s3.us-east-2.amazonaws.com/xxxx",
    "policy": {
      "provider": "TRAVELERS PROPERTY CASUALTY INSURANCE COMPANY",
      "policy_number": "607695134 203 1",
      "policy_type": "Auto",
      "policy_term": "12 months",
      "coverage_period": {
        "start_date": "2021-10-16",
        "end_date": "2022-10-16"
      },
      "annual_policy_premium": 1007
    },
    "drivers": [
      {
        "name": "CECILY",
        "date_of_birth": "1958-06-12",
        "relationship_to_policyholder": "Self"
      }
    ],
    "vehicles": [
      {
        "vin": "YV4952DL2E2549538",
        "make": "VOLVO",
        "year": 2014,
        "model": "XC60 3.2",
        "usage": "Pleasure",
        "coverages": [
          {
            "type": "Bodily Injury",
            "limits": "250000/500000",
            "premium": 471,
            "deductible": 0
          }
        ]
      }
    ],
    "discounts": [
      {
        "type": "Safe Driver Discount",
        "description": "5 Years Accident and Violation Free"
      }
    ],
    "policyholder": {
      "name": "CECILY SAPP CARTER SAPP",
      "address": {
        "street": "4286 CABRETTA DR SE",
        "city": "SMYRNA",
        "state": "GA",
        "zip": "30080-6479"
      }
    },
    "insurer_information": {
      "company_name": "TRAVELERS PROPERTY CASUALTY INSURANCE COMPANY",
      "claims_phone": "1.800.252.4633",
      "service_phone": "1.800.842.5075"
    }
  }
}
```


# Technical Reference

## 📄 Auto Insurance Webhook (Technical Reference)

This page provides a detailed **field-by-field breakdown** of the Auto Insurance Webhook payload.

***

### Root Object

The webhook payload is a JSON object with the following top-level keys:

* `document_link`
* `policy`
* `drivers`
* `vehicles`
* `discounts`
* `exclusions`
* `group_plan`
* `surcharges`
* `endorsements`
* `policyholder`
* `prior_carrier`
* `additional_info`
* `available_options`
* `important_notices`
* `additional_insured`
* `billing_information`
* `insurer_information`
* `important_policy_info`
* `forms_and_endorsements`

***

### Policy Object

| Field                        | Type          | Description                         | Example                                                   |
| ---------------------------- | ------------- | ----------------------------------- | --------------------------------------------------------- |
| `provider`                   | string        | Insurance company name              | `"TRAVELERS PROPERTY CASUALTY INSURANCE COMPANY"`         |
| `policy_number`              | string        | Unique identifier of the policy     | `"607695134 203 1"`                                       |
| `policy_type`                | string        | Type of insurance policy            | `"Auto"`                                                  |
| `policy_term`                | string        | Duration of policy coverage         | `"12 months"`                                             |
| `coverage_period.start_date` | string (date) | Policy effective date               | `"2021-10-16"`                                            |
| `coverage_period.end_date`   | string (date) | Policy expiration date              | `"2022-10-16"`                                            |
| `annual_policy_premium`      | number        | Total premium for the policy term   | `1007`                                                    |
| `policy_change_history`      | array         | List of changes with premium impact | `[{"change_date": "2022-04-26", "premium_impact": -543}]` |

***

### Drivers Array

| Field                          | Type          | Description                  | Example        |
| ------------------------------ | ------------- | ---------------------------- | -------------- |
| `name`                         | string        | Driver’s full name           | `"CECILY"`     |
| `date_of_birth`                | string (date) | Driver’s date of birth       | `"1958-06-12"` |
| `relationship_to_policyholder` | string        | Relationship to policyholder | `"Self"`       |

***

### Vehicles Array

| Field       | Type   | Description                                              | Example               |
| ----------- | ------ | -------------------------------------------------------- | --------------------- |
| `vin`       | string | Vehicle Identification Number                            | `"YV4952DL2E2549538"` |
| `make`      | string | Vehicle make                                             | `"VOLVO"`             |
| `model`     | string | Vehicle model                                            | `"XC60 3.2"`          |
| `year`      | number | Year of manufacture                                      | `2014`                |
| `usage`     | string | Vehicle usage category                                   | `"Pleasure"`          |
| `coverages` | array  | List of coverages with limits, premiums, and deductibles | see below             |

**Coverage Object**

| Field        | Type   | Description               | Example           |
| ------------ | ------ | ------------------------- | ----------------- |
| `type`       | string | Type of coverage          | `"Bodily Injury"` |
| `limits`     | string | Coverage limits           | `"250000/500000"` |
| `premium`    | number | Premium for this coverage | `471`             |
| `deductible` | number | Deductible amount         | `0`               |

***

### Discounts Array

| Field         | Type   | Description             | Example                                 |
| ------------- | ------ | ----------------------- | --------------------------------------- |
| `type`        | string | Discount type           | `"Safe Driver Discount"`                |
| `description` | string | Explanation of discount | `"5 Years Accident and Violation Free"` |

***

### Policyholder Object

| Field            | Type   | Description              | Example                     |
| ---------------- | ------ | ------------------------ | --------------------------- |
| `name`           | string | Policyholder’s full name | `"CECILY SAPP CARTER SAPP"` |
| `address.street` | string | Street address           | `"4286 CABRETTA DR SE"`     |
| `address.city`   | string | City                     | `"SMYRNA"`                  |
| `address.state`  | string | State code               | `"GA"`                      |
| `address.zip`    | string | ZIP code                 | `"30080-6479"`              |

***

### Insurer Information Object

| Field             | Type   | Description                  | Example                                                                               |
| ----------------- | ------ | ---------------------------- | ------------------------------------------------------------------------------------- |
| `company_name`    | string | Name of the insurer          | `"TRAVELERS PROPERTY CASUALTY INSURANCE COMPANY"`                                     |
| `claims_phone`    | string | Claims reporting number      | `"1.800.252.4633"`                                                                    |
| `service_phone`   | string | Customer service number      | `"1.800.842.5075"`                                                                    |
| `company_address` | object | Company headquarters address | `{ "street": "ONE TOWER SQUARE", "city": "HARTFORD", "state": "CT", "zip": "06183" }` |

***

### Sample Payload

{% file src="/files/xJB32eJeq7agJfFhawI4" %}


# Homeowners


# Overview

## Homeowners Insurance Webhook

This webhook delivers **homeowners insurance policy data** when new declaration page is uploaded.\
It includes policy details, property characteristics, coverages, discounts, mortgagee information, billing, and insurer contacts.

***

### 🔑 Key Sections in the Payload

#### Document Link

Includes a secure link to the client-uploaded document. The link expires within one hour in accordance with security requirements.

#### Policy Information

* Provider and policy number
* Policy type, term, and effective dates
* Premium and billing details
* Change history

#### Property

* Property type (single-family, condo, etc.)
* Address and construction details
* Roof type, year built, square footage, occupancy

#### Coverages

* Dwelling, personal property, other structures
* Loss of use, medical payments, liability
* Additional coverages (e.g., water backup, replacement cost protection)

#### Discounts

* Multi-policy, loss free, good payer, protective devices

#### Mortgagee

* Mortgage company information (name, address, loan number)

#### Exclusions & Deductibles

* Excluded risks (e.g., flood, earthquake)
* Deductibles for all perils and special cases

#### Endorsements

* Special provisions and additional policy benefits

#### Policyholder

* Name and contact details
* Address

#### Insurer Information

* Company name and contact numbers
* Claims and service phone numbers

***

### 📦 Example Payload

```json
{
  "topic": "client-submission",
  "document_link": "https://s3.us-east-2.amazonaws.com/xxxx",
  "data": {
    "policy": {
      "provider": "Travelers Personal Insurance Company",
      "policy_number": "612196357 633 1",
      "policy_type": "Homeowners",
      "policy_term": "12 months",
      "coverage_period": {
        "start_date": "2022-07-22",
        "end_date": "2023-07-22"
      },
      "annual_policy_premium": 1363,
      "coverage_level": "Travelers Protect Plus®"
    },
    "property": {
      "type": "Single Family",
      "location": {
        "street": "2429 ROBIN HOOD PL NE",
        "city": "MARIETTA",
        "state": "GA",
        "zip": "30068-2240"
      },
      "year_built": 1963,
      "square_footage": 1504,
      "roof_type": "Metal"
    },
    "coverages": {
      "dwelling": {
        "limit": 333000,
        "deductible": 1000
      },
      "personal_property": {
        "limit": 150000,
        "deductible": 1000
      },
      "personal_liability": {
        "limit": 500000
      }
    },
    "discounts": [
      {
        "type": "Multi-Policy"
      },
      {
        "type": "Loss Free"
      }
    ],
    "mortgagee": {
      "name": "NATIONSTAR MORTGAGE LLC ISAOA/ATIMA",
      "loan_number": "6870081281"
    },
    "policyholder": {
      "name": "CECILY SAPP",
      "email": "CEESAPP@GMAIL.COM",
      "address": {
        "street": "2429 ROBIN HOOD PL NE",
        "city": "MARIETTA",
        "state": "GA",
        "zip": "30068-2240"
      }
    },
    "insurer_information": {
      "company_name": "Travelers Personal Insurance Company",
      "claims_phone": "1.800.252.4633",
      "service_phone": "1.800.842.5075"
    }
  }
}
```


# Technical Reference

## 📄 Homeowners Insurance Webhook (Technical Reference)

This page provides a detailed **field-by-field breakdown** of the Homeowners Insurance Webhook payload.

***

### Root Object

The webhook payload is a JSON object with the following top-level keys:

* `document_link`
* `policy`
* `property`
* `coverages`
* `discounts`
* `mortgagee`
* `exclusions`
* `surcharges`
* `deductibles`
* `endorsements`
* `loss_history`
* `policyholder`
* `prior_carrier`
* `available_options`
* `important_notices`
* `premium_breakdown`
* `additional_insured`
* `hazard_information`
* `billing_information`
* `insurer_information`
* `forms_and_endorsements`
* `inspection_information`
* `special_property_features`

***

### Policy Object

| Field                        | Type          | Description                       | Example                                  |
| ---------------------------- | ------------- | --------------------------------- | ---------------------------------------- |
| `provider`                   | string        | Insurance company name            | `"Travelers Personal Insurance Company"` |
| `policy_number`              | string        | Unique identifier of the policy   | `"612196357 633 1"`                      |
| `policy_type`                | string        | Type of insurance policy          | `"Homeowners"`                           |
| `policy_term`                | string        | Duration of policy coverage       | `"12 months"`                            |
| `coverage_period.start_date` | string (date) | Policy effective date             | `"2022-07-22"`                           |
| `coverage_period.end_date`   | string (date) | Policy expiration date            | `"2023-07-22"`                           |
| `annual_policy_premium`      | number        | Total premium for the policy term | `1363`                                   |
| `coverage_level`             | string        | Product/coverage level            | `"Travelers Protect Plus®"`              |

***

### Property Object

| Field             | Type   | Description                         | Example                   |
| ----------------- | ------ | ----------------------------------- | ------------------------- |
| `type`            | string | Property type                       | `"Single Family"`         |
| `location.street` | string | Street address                      | `"2429 ROBIN HOOD PL NE"` |
| `location.city`   | string | City                                | `"MARIETTA"`              |
| `location.state`  | string | State                               | `"GA"`                    |
| `location.zip`    | string | Postal code                         | `"30068-2240"`            |
| `year_built`      | number | Year property was built             | `1963`                    |
| `square_footage`  | number | Size of the property in square feet | `1504`                    |
| `roof_type`       | string | Type of roofing material            | `"Metal"`                 |

***

### Coverages Object

**Dwelling Coverage**

| Field        | Type   | Description       | Example  |
| ------------ | ------ | ----------------- | -------- |
| `limit`      | number | Coverage limit    | `333000` |
| `deductible` | number | Deductible amount | `1000`   |

**Personal Property**

| Field              | Type    | Description                    | Example  |
| ------------------ | ------- | ------------------------------ | -------- |
| `limit`            | number  | Coverage limit                 | `150000` |
| `deductible`       | number  | Deductible amount              | `1000`   |
| `replacement_cost` | boolean | Replacement cost coverage flag | `true`   |

**Personal Liability**

| Field   | Type   | Description              | Example  |
| ------- | ------ | ------------------------ | -------- |
| `limit` | number | Liability coverage limit | `500000` |

(Other coverage types follow the same format)

***

### Discounts Array

| Field         | Type   | Description                | Example          |
| ------------- | ------ | -------------------------- | ---------------- |
| `type`        | string | Discount type              | `"Multi-Policy"` |
| `description` | string | Explanation (if available) | `""`             |

***

### Mortgagee Object

| Field           | Type   | Description          | Example                                 |
| --------------- | ------ | -------------------- | --------------------------------------- |
| `name`          | string | Mortgage lender name | `"NATIONSTAR MORTGAGE LLC ISAOA/ATIMA"` |
| `loan_number`   | string | Loan identifier      | `"6870081281"`                          |
| `address.city`  | string | Mortgagee city       | `"SPRINGFIELD"`                         |
| `address.state` | string | Mortgagee state      | `"OH"`                                  |
| `address.zip`   | string | ZIP code             | `"45501-7729"`                          |

***

### Exclusions Array

| Field | Type   | Description   | Example          |
| ----- | ------ | ------------- | ---------------- |
| item  | string | Excluded risk | `"Flood damage"` |

***

### Deductibles Object

| Field        | Type   | Description                     | Example |
| ------------ | ------ | ------------------------------- | ------- |
| `all_peril`  | number | Standard deductible             | `1000`  |
| `hurricane`  | number | Deductible for hurricane damage | `0`     |
| `earthquake` | number | Deductible for earthquake       | `0`     |

***

### Policyholder Object

| Field            | Type   | Description              | Example                   |
| ---------------- | ------ | ------------------------ | ------------------------- |
| `name`           | string | Policyholder’s full name | `"CECILY SAPP"`           |
| `email`          | string | Email address            | `"CEESAPP@GMAIL.COM"`     |
| `address.street` | string | Street address           | `"2429 ROBIN HOOD PL NE"` |
| `address.city`   | string | City                     | `"MARIETTA"`              |
| `address.state`  | string | State code               | `"GA"`                    |
| `address.zip`    | string | Postal code              | `"30068-2240"`            |

***

### Insurer Information Object

| Field                    | Type   | Description             | Example                                  |
| ------------------------ | ------ | ----------------------- | ---------------------------------------- |
| `company_name`           | string | Name of the insurer     | `"Travelers Personal Insurance Company"` |
| `claims_phone`           | string | Claims reporting number | `"1.800.252.4633"`                       |
| `service_phone`          | string | Customer service number | `"1.800.842.5075"`                       |
| `company_address.street` | string | Company mailing address | `"PO BOX 2906"`                          |
| `company_address.city`   | string | City                    | `"HARTFORD"`                             |
| `company_address.state`  | string | State                   | `"CT"`                                   |
| `company_address.zip`    | string | Postal code             | `"06104-9900"`                           |

***

### Sample Payload

{% file src="/files/6YSubTcSkuH0gr1BosSh" %}


# Insurgrid Assistant


# Privacy Policy

InsurGrid Assistant ("the Extension") is committed to protecting your privacy. This Privacy Policy explains how we collect, use, store, and protect information when you use our Chrome extension.


# InsurGrid Developer Platform

Build insurance workflows on InsurGrid. Collect a customer's information once, and InsurGrid sends you structured insurance data — extracted policy details, completed intake forms, and filled ACORD forms — pushed straight to your app.

You don't poll us. You give us a URL you control, and we `POST` the data to it the moment it's ready.

## What you can build

* **Receive policy data** — when a declaration page is submitted and processed, we send you the extracted policy fields (coverages, premiums, named insured, and more) as a webhook.
* **Embed an intake form** — drop an InsurGrid form into your app with one iframe. When someone completes it, we send you the full submission — every answer, links to uploaded files, and the filled ACORD PDFs.
* **Build and manage forms from your backend** — create, update, and publish forms entirely through the API.

> **New term — webhook:** a webhook is just an HTTP `POST` that *we* send to *your* server when something happens, instead of you having to ask us repeatedly.

## Pick your path

| I want to…                              | Start here                             |
| --------------------------------------- | -------------------------------------- |
| Receive policy data from a submission   | Quickstart A — Receive policy data     |
| Embed an intake form in my app          | Quickstart B — Embed a form            |
| Create and manage forms from my backend | Quickstart C — Build forms via the API |

New to InsurGrid? Read **Get started** first — it walks you from account to first webhook in order.


# InsurGrid Zapier Guide

Insurgrid Zapier Guide for integrating with various CRMs etc

**Automate your agency workflows using InsurGrid and Zapier — no code, no API keys required.**

Connect InsurGrid to Google Sheets, EZLynx, HawkSoft, AMS360, BetterAgency, HubSpot, Slack and 5,000+ other apps to streamline submission intake, document tracking, policy extraction, and renewal workflows.

***

## What You Can Do With Zapier

* Push new client submissions into CRMs and Agency Management Systems
* Automatically log extracted policy data into spreadsheets
* Create tasks for missing documents
* Trigger follow-up emails and Slack alerts
* Organize renewal timelines and identify cross-sell opportunities
* Build reports using Google Sheets or BI tools
* Automate workflows without engineering or API setup

***

## How to Connect InsurGrid to Zapier

InsurGrid uses OAuth authentication — no API keys needed.

{% stepper %}
{% step %}

### Connection Steps

* In Zapier, go to **My Apps**
* Search for **"InsurGrid"**
* Click **Connect**
* Log in using:
  * **Email and password**, or
  * **Continue with Google**

That's it — you're connected.
{% endstep %}
{% endstepper %}

***

## Available Triggers

Real-time events Zapier can listen to:

| Trigger                   | When It Fires                             |
| ------------------------- | ----------------------------------------- |
| **New Client Submission** | Client completes an InsurGrid Link        |
| **New Document Uploaded** | Insured uploads PDFs or images            |
| **New Policy Extracted**  | AI extraction engine processes a document |
| **Client Updated**        | Client information changes in InsurGrid   |

***

## Popular Zap Templates

{% stepper %}
{% step %}

### Send Extracted Policies to Google Sheets

Trigger: New Policy Extracted\
Action: Google Sheets → Create Row

Use cases:

* Renewal tracking
* Cross-sell sheets
* Daily activity logs
* Carrier premium reports
  {% endstep %}

{% step %}

### Create EZLynx Leads&#x20;

Trigger: New Client Submission\
Action: Email by Zapier → Send to EZLynx Zapier APP
{% endstep %}

{% step %}

### Sync Clients to HubSpot

Trigger: New Client Submission\
Actions:

* HubSpot → Create/Update Contact
* HubSpot → Create Deal
* Add Tag "InsurGrid Intake"
  {% endstep %}

{% step %}

### Notify Team on Slack

Trigger: New Document Uploaded\
Action: Slack → Send Channel Message

Example message:

```
📄 John Doe uploaded 3 documents. Policy extraction complete.
```

{% endstep %}
{% endstepper %}

***

## Integration Guides by Platform

### Google Sheets (Most Popular)

**Use Cases:**

* Policy logs
* Renewal pipelines
* Commission reporting
* Daily dashboards

{% stepper %}
{% step %}

### Setup

* Create a Google Sheet with column headers
* Trigger: InsurGrid → New Policy Extracted
* Action: Google Sheets → Create Row
* Map fields:
  * Name, Email, Phone
  * Policy Type, Carrier, Premium
  * Effective Date, Expiration Date
  * Document URLs
    {% endstep %}

{% step %}

### Pro Tip

{% hint style="info" %}
Use "Find Row + Update Row" to maintain a single row per client.
{% endhint %}
{% endstep %}
{% endstepper %}

***

### AMS360

*AMS360 also lacks a Zapier app. Agencies use:*

{% stepper %}
{% step %}

### Email Intake

InsurGrid → Zapier → Email → AMS360 inbound mailbox
{% endstep %}

{% step %}

### CSV Import

InsurGrid → Google Sheets → CSV → AMS360 Import Manager

Commonly mapped fields:

* Contact details
* Renewal dates
* Personal lines policies
* Carrier + premium data
  {% endstep %}
  {% endstepper %}

***

### BetterAgency

BetterAgency has native Zapier support for clean syncing.

**Setup:**

* Trigger: InsurGrid → New Client Submission
* Action: BetterAgency → Create/Update Contact

Fields to map:

* Client name, email, phone
* Line of business (LOB)
* Renewal date, premium
* Lead source: "InsurGrid"

***

### HubSpot

Common Workflow:

1. Trigger: New Client Submission
2. Actions:
   * HubSpot → Create/Update Contact
   * HubSpot → Create Deal
   * Add tags/stages:
     * Intake
     * New Submission
     * Renewal
     * Cross-sell

Optional: Attach InsurGrid document URLs inside Deal Activity.

***

### Slack

Example Workflow:

* Trigger: New Document Uploaded
* Action: Slack → Send message
* Channel: `#submissions` or `#renewals`

Message template:

```
John Doe added Homeowners dec page. Policy extraction complete.
```

***

## Field Mapping Reference

| InsurGrid Field   | Description                    |
| ----------------- | ------------------------------ |
| `client_name`     | Full name                      |
| `email`           | Client email                   |
| `phone`           | Phone number                   |
| `address`         | Full address                   |
| `policy_type`     | Auto / Home / Umbrella / Other |
| `carrier`         | Carrier name                   |
| `premium`         | Annual premium                 |
| `effective_date`  | Policy start date              |
| `expiration_date` | Policy end date                |
| `documents`       | URLs to uploaded files         |
| `producer_name`   | Assigned agent/CSR             |

***

## Troubleshooting

### Zap not triggering?

* Ensure the InsurGrid Link has a completed submission
* For policy triggers, confirm document extraction is complete
* Re-authenticate InsurGrid inside Zapier (My Apps → InsurGrid → Reconnect)

### Google Sheets errors

* Sheet must have column headers
* Avoid merged cells
* Worksheet name must match exactly in Zapier action

{% stepper %}
{% step %}

### Duplicate clients

Use Zapier's built-in logic:

* Search Step → "Find Contact"
* Then: "Create Contact if Not Found"
  {% endstep %}
  {% endstepper %}

***

## Best Practices

{% hint style="success" %}

* Name Zaps clearly — Example: "InsurGrid → Sheets (Auto Policies)"
* Add filters to route by policy type — Example: Only trigger if `policy_type` = "Auto"
* Use Google Sheets as a staging area — Sync to Sheets first, then push to AMS to reduce errors
* Link to documents instead of sending files — Use InsurGrid's document URLs rather than attaching files
  {% endhint %}

***

## Support

**Need help with Zapier integration?**

* Email: <support@insurgrid.com>
* Docs: <https://docs.insurgrid.com/integrations/zapier>
* Zapier Template Library: <https://zapier.com/apps/insurgrid/integrations>


# Lease Compliance Checker API

## Overview

The Lease Compliance Checker API analyzes lease agreements and insurance policies to determine if the insurance coverage meets the lease requirements. It extracts key data from both documents and provides a detailed compliance comparison.

## Base URL

```
https://api.insurgrid.com/api
```

## Authentication

All API requests require an API key passed in the request header.

| Header      | Value        |
| ----------- | ------------ |
| `X-API-Key` | Your API key |

Example:

```
X-API-Key: ig_live_abc123xyz789
```

### API Key Format

* Production keys: `ig_live_` prefix
* Test keys: `ig_test_` prefix

## Endpoints

### Analyze Lease & Insurance Compliance

Compares a lease document against an insurance policy to identify coverage gaps.

```
POST /analyze
```

Request

Content-Type: `multipart/form-data`

| Field       | Type | Required | Description                 |
| ----------- | ---- | -------- | --------------------------- |
| `lease`     | file | Yes      | Lease agreement PDF         |
| `insurance` | file | Yes      | Insurance policy/binder PDF |

cURL Example

{% code title="cURL" %}

```bash
curl --location 'https://api.insurgrid.com/api/analyze' \
  --header 'X-API-Key: ig_live_abc123xyz789' \
  --form 'lease=@"lease_agreement.pdf"' \
  --form 'insurance=@"insurance_policy.pdf"'
```

{% endcode %}

JavaScript Example

{% code title="JavaScript (fetch)" %}

```javascript
const formData = new FormData();
formData.append('lease', leaseFile);
formData.append('insurance', insuranceFile);

const response = await fetch('https://api.insurgrid.com/api/analyze', {
  method: 'POST',
  headers: {
    'X-API-Key': 'ig_live_abc123xyz789'
  },
  body: formData
});

const result = await response.json();
```

{% endcode %}

Python Example

{% code title="Python (requests)" %}

```python
import requests

url = "https://api.insurgrid.com/api/analyze"
headers = {"X-API-Key": "ig_live_abc123xyz789"}

files = {
    "lease": open("lease_agreement.pdf", "rb"),
    "insurance": open("insurance_policy.pdf", "rb")
}

response = requests.post(url, headers=headers, files=files)
result = response.json()
```

{% endcode %}

## Response Schema

Success Response (200 OK)

```json
{
  "lease": { ... },
  "insurance": { ... },
  "comparison": [ ... ],
  "summary": { ... },
  "recommendations": [ ... ],
  "extractionNotes": [ ... ],
  "_raw": { ... }
}
```

### `lease` Object

Extracted information from the lease document.

| Field                  | Type    | Description                             |
| ---------------------- | ------- | --------------------------------------- |
| `propertyAddress`      | string  | Full property address                   |
| `propertyAddress_page` | integer | Page number where found                 |
| `landlord`             | string  | Landlord or management company name     |
| `landlord_page`        | integer | Page number where found                 |
| `tenant`               | string  | Tenant name(s)                          |
| `tenant_page`          | integer | Page number where found                 |
| `leaseStart`           | string  | Lease start date (e.g., "Sep 10, 2024") |
| `leaseStart_page`      | integer | Page number where found                 |
| `leaseEnd`             | string  | Lease end date (e.g., "Sep 30, 2025")   |
| `leaseEnd_page`        | integer | Page number where found                 |

Example:

```json
{
  "propertyAddress": "742 Evergreen Terrace, Unit 4B, Austin, TX 78701",
  "propertyAddress_page": 1,
  "landlord": "Sunrise Property Management LLC",
  "landlord_page": 1,
  "tenant": "John A. Smith, Sarah M. Smith",
  "tenant_page": 1,
  "leaseStart": "Jan 01, 2025",
  "leaseStart_page": 1,
  "leaseEnd": "Dec 31, 2025",
  "leaseEnd_page": 1
}
```

### `insurance` Object

Extracted information from the insurance document.

| Field                 | Type            | Description                |
| --------------------- | --------------- | -------------------------- |
| `carrier`             | string          | Insurance company name     |
| `carrier_page`        | integer         | Page number where found    |
| `policyNumber`        | string          | Policy number              |
| `policyNumber_page`   | integer         | Page number where found    |
| `insured`             | string          | Name(s) of insured parties |
| `insured_page`        | integer         | Page number where found    |
| `effectiveDate`       | string          | Policy effective date      |
| `effectiveDate_page`  | integer         | Page number where found    |
| `expirationDate`      | string \| null  | Policy expiration date     |
| `expirationDate_page` | integer \| null | Page number where found    |

Example:

```json
{
  "carrier": "Acme Insurance Company",
  "carrier_page": 1,
  "policyNumber": "HO-2025-78432",
  "policyNumber_page": 1,
  "insured": "JOHN A SMITH SARAH M SMITH",
  "insured_page": 1,
  "effectiveDate": "Jan 15, 2025",
  "effectiveDate_page": 1,
  "expirationDate": "Jan 15, 2026",
  "expirationDate_page": 1
}
```

### `comparison` Array

Array of compliance check results for each coverage type.

| Field                    | Type            | Description                                               |
| ------------------------ | --------------- | --------------------------------------------------------- |
| `field`                  | string          | Coverage type being compared                              |
| `leaseRequired`          | string          | What the lease requires                                   |
| `leaseRequired_page`     | integer \| null | Page in lease document                                    |
| `insuranceCoverage`      | string          | What the policy provides                                  |
| `insuranceCoverage_page` | integer \| null | Page in insurance document                                |
| `status`                 | string          | Compliance status: `pass`, `fail`, or `warning`           |
| `gap`                    | string \| null  | Description of the gap (if status is `fail` or `warning`) |

Status Values

| Status    | Meaning                                                     |
| --------- | ----------------------------------------------------------- |
| `pass`    | Insurance meets or exceeds lease requirement                |
| `fail`    | Insurance does not meet lease requirement - action required |
| `warning` | Potential issue that needs manual verification              |

Coverage Fields Checked

| Field Name                   | Description                               |
| ---------------------------- | ----------------------------------------- |
| `Renters Insurance Required` | Whether any renters insurance is required |
| `General Liability`          | Personal liability coverage amount        |
| `Property Damage`            | Property damage liability coverage        |
| `Workers Compensation`       | Workers comp (commercial leases)          |
| `Umbrella/Excess Liability`  | Additional liability coverage             |
| `Auto Liability`             | Vehicle liability coverage                |
| `Professional Liability`     | E\&O or professional coverage             |
| `Additional Insured`         | Landlord listed as additional insured     |
| `Interested Party`           | Landlord listed as interested party       |
| `Certificate Holder`         | Landlord as certificate holder            |
| `Waiver of Subrogation`      | Waiver of subrogation endorsement         |
| `Primary & Non-Contributory` | Primary coverage endorsement              |
| `Property Address`           | Address matches leased property           |
| `Policy Period`              | Policy dates cover lease term             |
| `Policy Status`              | Policy is currently active                |

Example (pass):

```json
{
  "field": "General Liability",
  "leaseRequired": "$100,000",
  "leaseRequired_page": 5,
  "insuranceCoverage": "$500,000",
  "insuranceCoverage_page": 1,
  "status": "pass",
  "gap": null
}
```

Example (with gap):

```json
{
  "field": "Interested Party",
  "leaseRequired": "Sunrise Property Management LLC",
  "leaseRequired_page": 1,
  "insuranceCoverage": "Not listed on policy",
  "insuranceCoverage_page": null,
  "status": "fail",
  "gap": "Landlord/property management must be listed as Interested Party on the insurance policy"
}
```

### `recommendations` Array

Array of actionable recommendations based on the compliance analysis.

Type: `string[]`

Example:

```json
[
  "Add Sunrise Property Management LLC as Interested Party on the policy",
  "Ensure policy effective date covers from lease start (Jan 01, 2025)",
  "Ensure policy covers entire lease term through Dec 31, 2025",
  "Verify the insurance policy covers the property at 742 Evergreen Terrace, Unit 4B, Austin, TX 78701"
]
```

### `extractionNotes` Array

Additional context about the documents that may affect compliance interpretation.

Type: `string[]`

Example:

```json
[
  "Lease requires tenant to maintain coverage for the entire lease term",
  "No specific limits for personal property coverage stated in lease",
  "Document type: Renters Policy Declaration Page",
  "Policy includes automatic renewal clause"
]
```

### `_raw` Object

Contains the complete extracted data from both documents including all coverage details. Useful for debugging or building custom comparison logic.

Full Response Example

{% code title="Full response example (JSON)" %}

```json
{
  "lease": {
    "propertyAddress": "742 Evergreen Terrace, Unit 4B, Austin, TX 78701",
    "propertyAddress_page": 1,
    "landlord": "Sunrise Property Management LLC",
    "landlord_page": 1,
    "tenant": "John A. Smith, Sarah M. Smith",
    "tenant_page": 1,
    "leaseStart": "Jan 01, 2025",
    "leaseStart_page": 1,
    "leaseEnd": "Dec 31, 2025",
    "leaseEnd_page": 1
  },
  "insurance": {
    "carrier": "Acme Insurance Company",
    "carrier_page": 1,
    "policyNumber": "HO-2025-78432",
    "policyNumber_page": 1,
    "insured": "JOHN A SMITH SARAH M SMITH",
    "insured_page": 1,
    "effectiveDate": "Jan 15, 2025",
    "effectiveDate_page": 1,
    "expirationDate": "Jan 15, 2026",
    "expirationDate_page": 1
  },
  "comparison": [
    {
      "field": "Renters Insurance Required",
      "leaseRequired": "Yes - Required",
      "leaseRequired_page": 5,
      "insuranceCoverage": "Yes - Policy exists",
      "insuranceCoverage_page": 1,
      "status": "pass",
      "gap": null
    },
    {
      "field": "General Liability",
      "leaseRequired": "$100,000",
      "leaseRequired_page": 5,
      "insuranceCoverage": "$300,000",
      "insuranceCoverage_page": 1,
      "status": "pass",
      "gap": null
    },
    {
      "field": "Interested Party",
      "leaseRequired": "Sunrise Property Management LLC",
      "leaseRequired_page": 1,
      "insuranceCoverage": "Not listed on policy",
      "insuranceCoverage_page": null,
      "status": "fail",
      "gap": "Landlord/property management must be listed as Interested Party on the insurance policy"
    },
    {
      "field": "Policy Period",
      "leaseRequired": "Jan 01, 2025 - Dec 31, 2025",
      "leaseRequired_page": 1,
      "insuranceCoverage": "Jan 15, 2025 - Jan 15, 2026",
      "insuranceCoverage_page": 1,
      "status": "warning",
      "gap": "Policy effective date (Jan 15, 2025) is after lease start date (Jan 01, 2025). Gap of 14 days without coverage."
    },
    {
      "field": "Property Address",
      "leaseRequired": "742 Evergreen Terrace, Unit 4B, Austin, TX 78701",
      "leaseRequired_page": 1,
      "insuranceCoverage": "742 Evergreen Terrace, Unit 4B, Austin, TX 78701",
      "insuranceCoverage_page": 1,
      "status": "pass",
      "gap": null
    }
  ],
  "summary": {},
  "recommendations": [
    "Add Sunrise Property Management LLC as Interested Party on the policy",
    "Request policy backdated to Jan 01, 2025 or obtain proof of prior coverage",
    "Verify landlord receives certificate of insurance"
  ],
  "extractionNotes": [
    "Lease requires tenant to maintain coverage for the entire lease term",
    "No specific limits for personal property coverage stated in lease",
    "Document type: Renters Policy Declaration Page"
  ]
}
```

{% endcode %}

## Error Responses

<details>

<summary>400 Bad Request</summary>

Missing or invalid files.

```json
{
  "error": "bad_request",
  "message": "Both lease and insurance files are required"
}
```

</details>

<details>

<summary>401 Unauthorized</summary>

Invalid or missing API key.

```json
{
  "error": "unauthorized",
  "message": "Invalid API key"
}
```

</details>

<details>

<summary>413 Payload Too Large</summary>

Files exceed size limit (10MB per file).

```json
{
  "error": "payload_too_large",
  "message": "File size exceeds 10MB limit"
}
```

</details>

<details>

<summary>422 Unprocessable Entity</summary>

Could not extract data from the provided documents.

```json
{
  "error": "extraction_failed",
  "message": "Unable to extract data from lease document",
  "details": "PDF appears to be image-only without extractable text"
}
```

</details>

<details>

<summary>429 Too Many Requests</summary>

Rate limit exceeded.

```json
{
  "error": "rate_limited",
  "message": "Rate limit exceeded. Try again in 60 seconds",
  "retry_after": 60
}
```

</details>

<details>

<summary>500 Internal Server Error</summary>

```json
{
  "error": "internal_error",
  "message": "An unexpected error occurred"
}
```

</details>

## Rate Limits

| Plan       | Requests/minute | Requests/day |
| ---------- | --------------- | ------------ |
| Free       | 5               | 50           |
| Pro        | 30              | 1,000        |
| Enterprise | Custom          | Custom       |

Rate limit headers are included in all responses:

```
X-RateLimit-Limit: 30
X-RateLimit-Remaining: 29
X-RateLimit-Reset: 1699574400
```

## Supported Document Types

| Type  | Extensions     | Notes                                                    |
| ----- | -------------- | -------------------------------------------------------- |
| PDF   | `.pdf`         | Recommended. Supports text and scanned documents via OCR |
| Image | `.jpg`, `.png` | Single page documents only                               |

## Changelog

| Version | Date    | Changes         |
| ------- | ------- | --------------- |
| 1.0.0   | 2024-11 | Initial release |

## Support

* Email: <support@insurgrid.com>
* Documentation: <https://docs.insurgrid.com>
* Status Page: <https://status.insurgrid.com>


# PL Rating

{% hint style="info" %}
**For insurance agents.** This page covers which policies you can rate with PL Rating and what to do if a rating doesn't go through.
{% endhint %}

PL Rating pre-fills a quote from a client's submitted policy, so you can skip re-keying their information by hand.

## Which policies PL Rating supports

From InsurGrid, PL Rating can pre-fill:

* Personal Auto
* Homeowners (including renters and condo)

These are the policy types you can open in PL Rating from InsurGrid. Other policy types can't be opened in PL Rating from InsurGrid.

## What PL Rating can't rate

Within an auto policy, PL Rating can't rate the following:

* Trailers
* Fifth-wheels
* Motorhomes
* Motorcycles
* Watercraft

For an auto policy that includes one of these, the **Open in PL Rating** button still appears — the policy type (auto) is supported — but the rating can't complete for the unsupported risk.

## If a rating doesn't go through

* Don't change the client's actual policy just to get a quote to rate.
* If one of the unsupported risks above is involved, quote that risk separately through your agency's usual workflow for it.
* For any other problem, note the message shown on screen and contact <support@insurgrid.com> with a description of the issue. Don't include full policy documents or client-sensitive details unless support asks for them.


