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

# Serviceability Integration

> Expose one endpoint and Offergrid shows address-level availability and live pricing for your offers

## The whole thing in five sentences

1. You publish offers on Offergrid, each covering a list of ZIP codes.
2. ZIP codes are too coarse when availability is really decided building by building, so you expose one endpoint that answers *"can you serve this exact address, and at what price?"*
3. We call it the moment a shopper types a full address, and use your answer to confirm or hide your offers and to show your real price.
4. We call it once more at checkout, so nobody orders something you can't actually deliver.
5. The order lands in your Offergrid dashboard and, if you want, as a signed webhook to your own system.

<Tip>
  **In plain words:** someone knocks on your door holding an address. You say yes or no. If yes, you say what you can sell them and for how much. That's the entire integration.
</Tip>

```mermaid theme={null}
sequenceDiagram
    participant C as Shopper
    participant O as Offergrid
    participant Y as Your side

    Note over C,Y: While the shopper is browsing
    C->>O: Enters a full street address<br/>line1 · city · state · ZIP
    O->>Y: POST the address
    Y-->>O: serviceable + products
    Note over O: Answer cached 24h per address.<br/>Each offer matched by product key.
    O-->>C: Offer cards, priced per offer<br/>your real price, not the list price

    Note over C,Y: At checkout — the same endpoint, cache bypassed
    C->>O: Places the order
    O->>Y: Re-check the same address
    Y-->>O: Final yes / no + price
    Note over O: A gated "no" stops the order here.
    O->>O: Order created<br/>your verified price stored on it
    O->>Y: Signed webhook to your fulfillment system
```

The whole integration. Everything in the **Your side** lane is what you build — one endpoint, plus whatever already receives your orders. Note that the same endpoint is called twice: once while the shopper is browsing, once at checkout with the cache deliberately bypassed.

## Why ZIP codes aren't enough

Most offers are listed against a ZIP-code footprint: if the customer's ZIP is in your service area, the offer shows. That is coarse for services where availability is decided at the individual address — wired internet, fixed wireless, anything with a physical network path to the building.

A ZIP code can contain 20,000 homes when you only reach 6,000 of them. Listing the offer across the whole ZIP shows it to people you can't serve; listing it nowhere hides it from people you can. Both cost you orders. A **serviceability integration** is how you get out of that trade.

<Tip>
  **In plain words:** the ZIP code is the neighborhood. The endpoint is the actual house.
</Tip>

<Note>
  This is optional. Offers without a serviceability source work exactly as they always have — ZIP-level availability and list pricing.
</Note>

### What you set up first

Three things come before serviceability matters at all. All are ordinary product setup in the dashboard, and none need engineering.

| What              | What it is                                                                                                                           | Who does it           |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------ | --------------------- |
| **Offers**        | One per thing a customer can buy — "Fiber 1 Gig", "Fiber 500". Name, category, list price, promo price, contract terms, install fee. | You, in the dashboard |
| **Service areas** | Where the offer is broadly available, as ZIP codes, states, or a drawn map area. This is the coarse footprint.                       | You, in the dashboard |
| **Visibility**    | Who can sell it: every reseller on Offergrid, your preferred partners only, or a hand-picked list.                                   | You, per offer        |

## Three ways to onboard

<CardGroup cols={3}>
  <Card title="1. Build to this spec" icon="circle-check">
    You expose an endpoint shaped like the contract below. Offergrid-side setup is one integration record and one secret. **Fastest path, no mapping work.**
  </Card>

  <Card title="2. We map your existing API" icon="arrows-left-right">
    Your API stays exactly as it is. We describe it in the integration's config — which fields carry the address, where the products live in your response. No code on either side.
  </Card>

  <Card title="3. Custom adapter" icon="wrench">
    For APIs that can't be expressed as a field map (multi-step handshakes, session tokens, non-JSON payloads). Engineering-scoped — talk to us early.
  </Card>
</CardGroup>

Tier 2 covers most existing serviceability APIs, so **you do not need to build anything new to integrate**. Tier 1 exists because it is the cheapest to stand up and the easiest to support: if you're building the endpoint from scratch anyway, build it to this shape and the entire integration is configuration.

The rest of this page describes the tier-1 contract. [Mapping an existing API](#mapping-an-existing-api-tier-2) at the end covers tier 2.

## The contract

### Request

Offergrid sends a JSON `POST` from its servers — never from the shopper's browser, so your endpoint is never exposed to end users. If it requires source-IP allow-listing, contact support before you build, so we can confirm what we can commit to.

```http theme={null}
POST /serviceability HTTP/1.1
Host: api.example.com
Content-Type: application/json
Authorization: <your static secret>
```

```json theme={null}
{
  "line1": "1600 Pennsylvania Ave NW",
  "line2": "Apt 4",
  "city": "Washington",
  "state": "DC",
  "zipCode": "20500",
  "country": "US"
}
```

| Field     | Type   | Notes                                                                                                                 |
| --------- | ------ | --------------------------------------------------------------------------------------------------------------------- |
| `line1`   | string | Street address. Always sent.                                                                                          |
| `line2`   | string | Unit/apt. **Omitted from the body entirely when the shopper left it blank** — treat absent as empty, not as an error. |
| `city`    | string | Always sent.                                                                                                          |
| `state`   | string | Two-letter US state code. Always sent.                                                                                |
| `zipCode` | string | 5-digit ZIP. Always sent.                                                                                             |
| `country` | string | ISO country code, typically `US`. Omitted when unknown.                                                               |

<Note>
  We only call once the address is complete enough to be worth asking about — by default `line1`, `city`, `state`, and `zipCode` must all be present. On ZIP-only surfaces (catalog browse, map search) the integration stays dormant and your offers fall back to their ZIP footprint. That threshold is configurable per integration.
</Note>

**Authentication** is a single static header: you name the header, we send a secret value in it. The value is sent verbatim, so if your scheme needs a prefix (`Bearer …`, `Token …`), include it in the secret itself. The secret is held as a platform environment variable on our side and is **never stored in our database** or shown in the dashboard.

`POST` is required. The connector sends the address in the request body, so a `GET`-only endpoint has no way to receive it.

### Response

Return `200` with this JSON:

```json theme={null}
{
  "serviceable": true,
  "products": [
    {
      "key": "fiber-1g",
      "name": "Fiber 1 Gig",
      "technology": "fiber",
      "monthlyPrice": "79.99",
      "promoPrice": "59.99",
      "installFee": "0",
      "contractLength": "12 months"
    },
    {
      "key": "fiber-500",
      "name": "Fiber 500",
      "technology": "fiber",
      "monthlyPrice": "59.99",
      "promoPrice": "49.99",
      "installFee": "0",
      "contractLength": "12 months"
    }
  ]
}
```

<ResponseField name="serviceable" type="boolean" required>
  Whether you can serve this address. This is the answer that gates or confirms availability.
</ResponseField>

<ResponseField name="products" type="array">
  What is purchasable at this address. Empty (or omitted) when `serviceable` is `false`.

  <Expandable title="product fields">
    <ResponseField name="key" type="string" required>
      Stable, opaque identifier for the product in your system. This is what ties a product to an Offergrid offer — each offer stores the `key` of the product it represents. Matched case-insensitively and trimmed, but it must not change over time: a renamed key silently detaches the offer from its pricing.
    </ResponseField>

    <ResponseField name="name" type="string">
      Plan name as you'd like it displayed, e.g. `Fiber 1 Gig`.
    </ResponseField>

    <ResponseField name="technology" type="string">
      Delivery technology, e.g. `fiber`, `cable`, `fixed_wireless`.
    </ResponseField>

    <ResponseField name="monthlyPrice" type="string | number">
      Standard monthly rate. Plain decimal, no currency symbol — `79.99` or `"79.99"`.
    </ResponseField>

    <ResponseField name="promoPrice" type="string | number">
      Promotional monthly rate, if any. Displayed in preference to `monthlyPrice`.
    </ResponseField>

    <ResponseField name="installFee" type="string | number">
      One-time installation charge. `0` for free install.
    </ResponseField>

    <ResponseField name="contractLength" type="string">
      Term commitment, displayed verbatim — `12 months`, `No contract`.
    </ResponseField>
  </Expandable>
</ResponseField>

Extra fields are fine and ignored. Fields you can't supply should be omitted rather than sent as empty strings.

### Rules

<AccordionGroup>
  <Accordion title="An unserviceable address is a 200, not a 404">
    Return `200` with `"serviceable": false` and an empty `products` array. A non-2xx status means *we failed to get an answer*, which we handle very differently (see below) from *the answer is no*.
  </Accordion>

  <Accordion title="Errors fail soft — but they cost you">
    On a non-2xx status, a timeout, or unparsable JSON, Offergrid falls back to the most recent cached answer for that address; with no cached answer, the integration goes quiet and your offers behave as if they had no serviceability source (ZIP-level availability, list pricing). Nothing breaks and no order is lost — but nothing is confirmed either. Prefer `200` with `serviceable: false` over an error whenever you actually know the answer.
  </Accordion>

  <Accordion title="Answer in under 2 seconds">
    The call runs while a shopper waits for offer cards to render. Sub-second is ideal. If your upstream is slow, cache on your side — the request is the same normalized address every time.
  </Accordion>

  <Accordion title="Be idempotent and side-effect free">
    We may call the same address more than once: on browse, when the cached answer expires, and again at checkout. The call must not create a lead, consume a quota, or otherwise mutate state on your side.
  </Accordion>

  <Accordion title="Keep `serviceable` and `products` consistent">
    `serviceable: false` with a non-empty `products` array is contradictory, and different parts of the platform may read either field. When you can't serve the address, say so and return no products.
  </Accordion>
</AccordionGroup>

## What Offergrid does with the answer

**Caching.** Answers are cached per (integration, address) with a TTL — 24 hours by default, configurable per integration. Within a single page render, one address costs exactly one call to you no matter how many of your offers are on screen.

**Coverage: confirm or gate.** When you attach the integration to a service area you choose one of two behaviors:

| Choice                                                 | Behavior                                                                                                                                                                                                              |
| ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Show in my areas, confirm at the address** (default) | Offers appear across the service area's ZIP footprint. Once a full address is entered, your answer decorates the card with the real price and an availability indicator, but it never hides the offer.                |
| **Only show where the source confirms availability**   | Your `serviceable: false` removes the offer from results for that address. If no answer is available (ZIP-only search, or your endpoint is down), the offer falls back to its ZIP footprint rather than disappearing. |

<Tip>
  **In plain words:** confirm means "show it, then tell them the truth". Gate means "don't even show it unless I say yes". Gate is honest but unforgiving; confirm sells more but shows offers you may have to decline.
</Tip>

**Pricing, per offer.** This is the part people get wrong, so here it is concretely. You return two products in one response; on Offergrid you have two offers. Each offer stores the `key` of the product it represents — the field labelled **"Product identifier in your system"** in the offer editor.

| Offergrid offer  | Product identifier | Price shown |
| ---------------- | ------------------ | ----------- |
| Acme Fiber 1 Gig | `fiber-1g`         | \$79.99     |
| Acme Fiber 500   | `fiber-500`        | \$59.99     |

One call to you, two cards, two correct prices. An offer with no key — or a key that isn't in the response — falls back to the first product in the array, which is usually not what you want, so set them all.

**Checkout re-verification.** At order submit, Offergrid calls you again, bypassing the cache, against the order's service address:

* On-net → the fresh price, plan, technology, and install fee are recorded on the order alongside the price the customer agreed to, so fulfillment (and any dispute) can compare the two.
* Off-net on a **gating** service area → the order is rejected at review with a clear message. The customer never places an order you'd have to cancel.
* No answer (your endpoint is unreachable) → the order proceeds and is flagged as unverified. Serviceability is never a payment gate.

Offers with no serviceability source record nothing here, which stays distinguishable from "we asked and got nothing back".

## How the order reaches you

Orders arrive from resellers placing them on a customer's behalf, from a public link a reseller shared, or from Offergrid's consumer storefront. However it started, it lands the same way.

| Channel            | What you get                                                                                                                                                                                                      |
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Dashboard**      | Every order on your Orders page, with the customer, service address, items, and the verified price snapshot.                                                                                                      |
| **Email & in-app** | Notifications on new orders and status changes, to your team's members.                                                                                                                                           |
| **Webhooks**       | Signed callbacks to your system: `order.created`, `order.item.created`, `order.item.status_changed`, `order.cancelled`. Every delivery attempt is logged and queryable, so "did it arrive?" is always answerable. |

You move each item along as you fulfill it, from the dashboard or through the API. Resellers and customers are notified automatically at the points that matter to them.

```text Order item lifecycle theme={null}
pending → submitted_to_provider → accepted → scheduled → in_progress → completed → active

exits: rejected · cancelled · failed
```

<Tip>
  **In plain words:** you told us who you can serve. We found them, quoted your price, and checked with you again before taking the order. Now it's a normal order in your queue.
</Tip>

## Connecting it

<Steps>
  <Step title="Publish the endpoint">
    Deploy it and confirm it answers a known-serviceable and a known-unserviceable address correctly.
  </Step>

  <Step title="Send us the secret">
    Share the auth header value with support through a secure channel. We set it as a platform environment variable and reference it by name — it never enters the database.
  </Step>

  <Step title="Create the integration">
    In your dashboard, go to **Integrations** → **New integration**, pick a **Source key** (a short, permanent identifier for this connection, e.g. `acme-serviceability` — it can't be changed later, because your service areas point at it), and paste the config below. It's validated on save, so typos surface as field errors rather than silent no-ops.
  </Step>

  <Step title="Test it">
    Use **Test lookup** on the integration to run a real address through the live endpoint, bypassing the cache. It reports the on-net answer, the projected price fields, whether the result came from cache or live, and the upstream error verbatim if the call failed.
  </Step>

  <Step title="Attach it to coverage">
    Either add a **Serviceability** area to a service area (picking the integration and the confirm-or-gate choice), or — for internet offers — do it inline from the offer's **Service areas** step, which writes the ZIP footprint and the serviceability connection together.
  </Step>

  <Step title="Set each offer's product identifier">
    In the offer editor, set **Product identifier in your system** to the product's `key`. Do this for every offer that should price from this integration.
  </Step>
</Steps>

### Config for a spec-conformant endpoint

Because the request fields and response shape already match, the config is near-identity — it declares the endpoint, the auth header, and the (1:1) mapping:

```json theme={null}
{
  "endpoint": {
    "method": "POST",
    "url": "https://api.example.com/serviceability"
  },
  "auth": {
    "header": "Authorization",
    "secretEnv": "ACME_SERVICEABILITY_KEY"
  },
  "requiredAddressFields": ["line1", "city", "state", "zipCode"],
  "request": {
    "body": {
      "line1": { "field": "line1" },
      "line2": { "field": "line2" },
      "city": { "field": "city" },
      "state": { "field": "state" },
      "zipCode": { "field": "zipCode" },
      "country": { "field": "country" }
    }
  },
  "response": {
    "onNetWhenAnyNonEmpty": ["serviceable"],
    "technologyPath": "products.0.technology"
  },
  "display": {
    "planNamePath": "products.0.name",
    "monthlyPricePath": "products.0.monthlyPrice",
    "promoPricePath": "products.0.promoPrice",
    "installFeePath": "products.0.installFee",
    "contractLengthPath": "products.0.contractLength",
    "technologyPath": "products.0.technology"
  },
  "products": {
    "path": "products",
    "keyPath": "key",
    "display": {
      "planNamePath": "name",
      "monthlyPricePath": "monthlyPrice",
      "promoPricePath": "promoPrice",
      "installFeePath": "installFee",
      "contractLengthPath": "contractLength",
      "technologyPath": "technology"
    }
  },
  "ttlMs": 86400000
}
```

What each block does:

* **`request.body`** — how address fields become your request body. Here the names are identical on both sides.
* **`response.onNetWhenAnyNonEmpty`** — the paths that decide the on-net answer: on-net when any of them holds a truthy value or a non-empty array. For this contract the `serviceable` boolean answers it directly. (`["products"]` is equivalent for a conformant endpoint, since an unserviceable address returns no products.)
* **`display`** — where to read price/plan fields when an offer has no product identifier. Points at the first product.
* **`products`** — where the product list lives (`path`), which field identifies a product (`keyPath`, matched against the offer's product identifier), and where to read each display field **inside** the matched entry.
* **`ttlMs`** — how long a cached answer stays fresh. 24 hours here.

## Mapping an existing API (tier 2)

If your serviceability API already exists and can't change shape, the same config describes it — only the values differ:

* **Different field names?** `"address1": { "field": "line1" }` sends our `line1` as your `address1`. Constants your API requires (a partner ID, a promo code) are declared inline: `"clientName": { "const": "offergrid" }`.
* **No `serviceable` boolean?** Point `onNetWhenAnyNonEmpty` at the arrays that imply availability, e.g. `["products", "fixedWirelessProducts"]` — on-net when any is non-empty.
* **Products nested elsewhere?** `products.path` is a dot-path: `"data.availablePlans"` works. `keyPath` can be any stable identity field in an entry — `"serviceId"`, `"planCode"`, even `"name"`.
* **Prices in a nested object?** Every display path is a dot-path relative to the matched product entry: `"monthlyPricePath": "pricing.monthly.amount"`.

The mapping language is deliberately small — field copies, constants, dot-paths, and one on-net predicate. It has no conditionals, expressions, or templating, which is what keeps a new integration a support conversation rather than an engineering project. An API that genuinely can't be expressed this way is tier 3.

<Tip>
  **In plain words:** send us a sample request and a sample response from whatever you already have, and we'll tell you which tier you're in.
</Tip>

## What's automated today

So you can plan operations around what exists rather than what's described above in the abstract.

<AccordionGroup>
  <Accordion title="Partial — checkout re-verification runs on storefront orders">
    The fresh call at submit is live for orders placed through Offergrid's consumer storefront. Reseller-placed and shared-link orders are checked against your endpoint while the reseller browses — including the gate — but don't yet make a second call at submit. Extending it is in progress.
  </Accordion>

  <Accordion title="Manual — your secret is installed by us">
    Auth values are held as platform environment variables, so a new secret needs us to install it before your integration goes live. That's deliberate — it keeps credentials out of the database — but it makes step 2 a short back-and-forth rather than self-serve.
  </Accordion>

  <Accordion title="Not yet — order handoff is notification, not order entry">
    Orders reach you as signed webhooks and in the dashboard. There is no structured submission into your order-entry system: no passing your quote or session identifier back, no install-window selection, no payment details. Fulfillment starts from the order we hand you.
  </Accordion>

  <Accordion title="Internet first — inline coverage setup">
    The one-step "set coverage inside the offer" flow exists for internet offers. Other categories set the same thing up from the **Service areas** page — same engine, one more click.
  </Accordion>
</AccordionGroup>

## Checklist

<Check>Endpoint accepts `POST` with a JSON body and returns `200` JSON</Check>
<Check>Unserviceable addresses return `200` with `serviceable: false` and no products</Check>
<Check>Every product carries a stable `key` that won't change</Check>
<Check>Prices are plain decimals with no currency symbol</Check>
<Check>Missing `line2`/`country` are tolerated (absent, not empty string)</Check>
<Check>Responses land in under 2 seconds</Check>
<Check>Repeat calls for the same address are safe and side-effect free</Check>
<Check>Auth is a single static header whose value we can hold as a secret</Check>
<Check>Every offer that should price from the endpoint has its product identifier set</Check>
<Check>Each service area is explicitly set to confirm or gate — chosen, not defaulted</Check>

Questions, or an API that doesn't fit? Email [support@offergrid.io](mailto:support@offergrid.io) with a sample request and response and we'll tell you which tier you're in.
