# ICON Developer Center (all pages) Source: https://developers.iconroute.io --- # Welcome > Public documentation for ICON's affiliate API: search, results, submit, duplicate pre-check, standardized responses. ICON is a lead-routing platform for education offers. Partners send ICON a lead; ICON finds the offers that lead qualifies for, checks duplicates, caps, eligibility and consent, and answers in real time. These docs are public. Using the API needs an API key, issued by ICON to your affiliate account; see [Authentication and API keys](https://developers.iconroute.io/authentication/). ## Integration options - **[Affiliate API](https://developers.iconroute.io/affiliate-api/)** (Search → results → submit): Search every offer on your campaign with one lead, poll for results as they arrive, then submit the lead to the result the consumer chose. Each result carries its logo, consent statement, programs, questions and your payout. - **[Direct lead posting](https://developers.iconroute.io/api/leads/#direct-posting)** (POST a lead to one offer): Already know the offer? Post the lead straight to it with your campaign and offer ids. Same endpoint, same standardized answer. - **[Duplicate pre-check](https://developers.iconroute.io/api/dupe-check/)** (Optional, server to server): Ask whether ICON would call a lead a duplicate before you post it. Send raw fields or SHA-256 hashes of the published normalized forms. - **[Standard responses](https://developers.iconroute.io/responses/)** (status · reason · code): Every answer carries the same three fields, from one catalog: a lower-case status, a sentence-case reason and a stable code with a retryable flag. **Also good to know** - Other integration types: ask your ICON contact. - ICON does not send postbacks to affiliates today: the submit answer is the outcome. ## How a lead flows 1. **Search.** `POST /api/v1/engine/search` with the lead, your campaign id and your affiliate id. ICON answers at once with its first results and a search id (`icon_lead_id`). 2. **Results.** `GET /api/v1/engine/results/{icon_lead_id}` until `processing_done` is `true`. More results arrive while partner offers answer. 3. **Submit.** `POST /api/v1/engine/leads` with the `icon_result_id` the consumer chose, your search id, the program, the answers to that result's questions and the consumer's consent. The lead comes from your search: no need to send it again. Every answer carries `status`, `reason` and `code`. See [Standard responses](https://developers.iconroute.io/responses/). ## Start here - [Quickstart](https://developers.iconroute.io/quickstart/): a test lead from search to submit, with curl. - [Endpoints](https://developers.iconroute.io/affiliate-api/): requests, responses and examples. - [Field dictionary](https://developers.iconroute.io/fields/): the lead fields ICON reads and the values it stores. - [Consent and TCPA](https://developers.iconroute.io/consent/): what to show and what to send. - [Testing](https://developers.iconroute.io/testing/): test leads run the whole pipeline and are never delivered. ## For tools and AI assistants Every page is also published as Markdown: add `.md` to its path (for example [/quickstart.md](https://developers.iconroute.io/quickstart.md)). The index is [llms.txt](https://developers.iconroute.io/llms.txt), all pages in one file are [llms-full.txt](https://developers.iconroute.io/llms-full.txt), and the API description is [openapi.json](https://developers.iconroute.io/openapi.json). API base URL: `https://iconroute.io` --- # Quickstart > Send a test lead through search, results and submit in five minutes with curl. You need an API key, your affiliate id and a campaign id (see [Authentication](https://developers.iconroute.io/authentication/)). Every call below sets `icon_is_test_lead: true`, so the lead runs the whole pipeline and **no buyer ever receives it** (see [Testing](https://developers.iconroute.io/testing/)). ```bash export ICON_API_KEY='' ``` ## 1. Search ```bash curl -X POST 'https://iconroute.io/api/v1/engine/search' \ -H "Authorization: Bearer $ICON_API_KEY" \ -H 'Content-Type: application/json' \ -d '{ "icon_campaign_id": "6f1c2d3e-4a5b-4c6d-8e9f-0a1b2c3d4e5f", "icon_affiliate_id": "3c9d8e7f-1a2b-4c3d-9e8f-7a6b5c4d3e2f", "lead": { "personal": { "first_name": "Ada", "last_name": "Lovelace", "email": "ada.lovelace@mail.test", "phone": "6025552368", "date_of_birth": "1990-04-07" }, "address": { "address_line_1": "123 N Main St", "city": "Phoenix", "state": "AZ", "zip_code": "85004" }, "education": { "education_level": "bachelors", "high_school_graduation_year": "2008", "start_timeline": "1_3_months", "learning_preference": "online" }, "background": { "military_affiliation": "none", "us_citizen": "yes" } }, "tracking": { "ip_address": "203.0.113.9", "subid": "pub-42", "subid2": "creative-7", "utm_source": "partner", "utm_medium": "email", "utm_campaign": "fall-enrollment" }, "tcpa_consent": true, "trusted_form_cert_url": "https://cert.trustedform.com/0123456789abcdef0123456789abcdef01234567", "icon_is_test_lead": true }' ``` The answer has your search id, `icon_lead_id`, and the first results in `offers`. Keep the `icon_result_id` of the result you want. If `processing_done` is `false`, more results are coming. ## 2. Poll for more results ```bash curl -X GET 'https://iconroute.io/api/v1/engine/results/1e8d30bb-7c5f-41b9-8cf1-5e6b57abe999' \ -H "Authorization: Bearer $ICON_API_KEY" ``` Wait `poll_after_ms` (about a second) between polls and stop when `processing_done` is `true`. Each answer is the full list, not only the new entries. ## 3. Submit Send the result's id and your search id, the answers to its `form_fields`, one of its `programs` and the consumer's consent. The lead itself comes from your search: ```bash curl -X POST 'https://iconroute.io/api/v1/engine/leads' \ -H "Authorization: Bearer $ICON_API_KEY" \ -H 'Content-Type: application/json' \ -d '{ "icon_result_id": "api-0b7e4a2c-981234", "search_lead_id": "1e8d30bb-7c5f-41b9-8cf1-5e6b57abe999", "icon_campaign_id": "6f1c2d3e-4a5b-4c6d-8e9f-0a1b2c3d4e5f", "icon_affiliate_id": "3c9d8e7f-1a2b-4c3d-9e8f-7a6b5c4d3e2f", "program_id": "4411", "api_field_military": "none", "tcpa_consent": true, "consent_button_label": "Request information", "icon_is_test_lead": true }' ``` A test lead answers `status: "held"`, `code: "TEST_LEAD_HELD"`. The same call without the test flag answers `ACCEPTED` or a refusal such as `DUPLICATE`; see [Submit a lead](https://developers.iconroute.io/api/leads/). ## Next - Show the result's consent statement and send the consumer's decision: [Consent and TCPA](https://developers.iconroute.io/consent/). - Handle every answer by its `status` and `retryable`: [Errors and retries](https://developers.iconroute.io/errors/). - Stay under your limits (search: 60 per minute by default): [Rate limits](https://developers.iconroute.io/rate-limits/). The result id `api-0b7e4a2c-981234` above is only an example: always submit an id from your own search. --- # Authentication and API keys > Every call carries an API key issued to your affiliate account, and names your campaign and affiliate. ## API keys An API key belongs to your **affiliate account**. ICON issues keys; each key can carry its own [rate limits](https://developers.iconroute.io/rate-limits/) and an expiry date, and can be revoked at any time. - **Where to find them:** your ICON contact issues and rotates your keys. - **Keep keys on your server.** The API is server to server. Never put a key in a web page or an app. - **One key per integration** makes rotation and troubleshooting easier. Ask for a new key before you revoke the old one. ## Sending the key Send the key in the `Authorization` header: ```http POST /api/v1/engine/search HTTP/1.1 Host: iconroute.io Authorization: Bearer Content-Type: application/json ``` `X-API-Key: ` is accepted too. Prefer a header: a key in a URL ends up in logs. ## Campaign and affiliate on every call The key says who you are; each search and submit also says **which campaign** it is for: | Field | Type | Required | Description | | --- | --- | --- | --- | | `icon_campaign_id` | string | required (search + submit) | ICON campaign ID (UUID) of the campaign this request is for. Required on every search and every submit: ICON never picks a campaign for you. | | `icon_affiliate_id` | string | required (search + submit) | Your ICON affiliate ID (UUID). Required on every search and every submit, and must be the affiliate your API key belongs to. ICON records the affiliate from the key; this value is only checked against it. | Your ICON contact gives you your affiliate id and your campaign ids. A campaign determines which offers a search covers and how your payout is set. ## When authentication fails | HTTP | code | status | reason | When | | --- | --- | --- | --- | --- | | 401 | `AUTH_REQUIRED` | invalid | Missing API key | No API key was sent. | | 401 | `AUTH_INVALID` | invalid | Invalid API key | ICON does not know the key, or it was revoked or has expired. | | 403 | `FORBIDDEN` | invalid | Not permitted | The campaign, offer or lead is not yours, or the request is not allowed for this key. | None of these is retryable as is: fix the key or the ids first. --- # Affiliate API overview > Three calls: search with a lead, poll for results, submit the result the consumer chose. ## The three legs 1. **Search.** `POST /api/v1/engine/search` with the lead, `icon_campaign_id` and `icon_affiliate_id`. ICON searches every active offer on that campaign (its own offers and the partner offers it calls), runs its duplicate, cap, eligibility and requirement checks, and answers with `icon_lead_id` (your search id) and the first results. 2. **Results.** `GET /api/v1/engine/results/{icon_lead_id}` until `processing_done` is `true`. More results arrive while partner offers answer; wait `poll_after_ms` (about a second) between polls. A search stays open for up to 45 seconds. 3. **Submit.** `POST /api/v1/engine/leads` with the `icon_result_id` the consumer chose, `search_lead_id`, the program, the answers to the result's `form_fields` and the consumer's consent. No need to send the lead again: ICON builds the submit on the lead of the search. | Leg | Endpoint | Page | | --- | --- | --- | | 1. Search | `POST /api/v1/engine/search` | [Search](https://developers.iconroute.io/api/search/) | | 2. Results | `GET /api/v1/engine/results/{icon_lead_id}` | [Results](https://developers.iconroute.io/api/results/) | | 3. Submit | `POST /api/v1/engine/leads` | [Submit a lead](https://developers.iconroute.io/api/leads/) | | Optional pre-check | `POST /api/v1/engine/dupe-check` | [Duplicate pre-check](https://developers.iconroute.io/api/dupe-check/) | ```text your server ICON │ POST /engine/search (lead, campaign) ──▶ searches every active offer on the campaign │ ◀── icon_lead_id + first results ────── duplicate, cap, eligibility, consent checks │ GET /engine/results/{icon_lead_id} ────▶ (repeat until processing_done) │ ◀── all results so far ──────────────── │ POST /engine/leads (icon_result_id) ───▶ full checks again, then delivery │ ◀── status, reason, code, payout ────── ``` ## What every result carries The same core fields, whether the result is an ICON-hosted offer or a partner offer ICON calls: the result id to submit, the offer and its school (name and logo), the consent statement to show, the programs, the questions to ask (`form_fields`), your payout and an expiry. The full schema is on [Results](https://developers.iconroute.io/api/results/#the-result-entry). ## Your payout `payout` is **your own** payout for the result, per program where programs differ (`programs[].payout`). ICON never returns the offer's own price or anyone else's terms. - No payout is configured for your campaign: the `payout` key is absent. - A refused submit answers `payout: 0`. - An accepted submit answers the payout booked for that lead. ## Exclusivity | `offer.offer_type` | What an accepted submit means | | --- | --- | | `shared` | The lead can be sold to up to `max_accepted_submissions` offers (3 today). Submit to several shared results if the consumer chose several. | | `exclusive` | Sold as an exclusive offer for its school. Unlike true exclusive, it does not end the lead's other sales. | | `true_exclusive` | An accepted submit ends the lead's other sales: later submits for the same search are refused. | Every search and results answer also carries `accepted_count`, `max_accepted_submissions` and `true_exclusive_accepted` so you can tell what can still be sold. ## Reposts and corrections - Sending a lead to an offer that already accepted it answers `DUPLICATE` (`Duplicate - Offer`). This is recognised when the repost carries the same `search_lead_id` (or, for a direct post, the `icon_lead_id` ICON returned). - Once a lead has been submitted, a request with your key that would change its data is refused (`403`, `FORBIDDEN`) and nothing is saved. - To correct a submitted lead, contact your ICON contact with its `icon_lead_id`. ## Conventions - JSON in, JSON out, UTF-8. Send `Content-Type: application/json`. - Ids are UUIDs unless stated. Times are ISO 8601 in UTC. - **Additive changes:** ICON adds fields to answers without notice. Ignore keys you do not use. Removals and renames are announced in the [changelog](https://developers.iconroute.io/changelog/). - Every answer carries `status`, `reason` and `code` ([Standard responses](https://developers.iconroute.io/responses/)). --- # Search > POST /api/v1/engine/search: one lead in, the offers it qualifies for out. ```http POST /api/v1/engine/search ``` Searches every active offer on your campaign for one lead. ICON stores the lead, runs its duplicate, cap, eligibility and requirement checks, and answers with a search id (`icon_lead_id`) and the first results. Partner offers keep answering after that: poll [Results](https://developers.iconroute.io/api/results/) until `processing_done` is `true`. Rate limit: 60 per minute per key (bursts of up to 10). ## Request | Field | Type | Required | Description | | --- | --- | --- | --- | | `icon_campaign_id` | string | yes | ICON campaign ID (UUID) of the campaign this request is for. Required on every search and every submit: ICON never picks a campaign for you. | | `icon_affiliate_id` | string | yes | Your ICON affiliate ID (UUID). Required on every search and every submit, and must be the affiliate your API key belongs to. ICON records the affiliate from the key; this value is only checked against it. | | `lead` | object | yes | The consumer: `lead.personal`, `lead.address`, `lead.education`, `lead.background`. See the [field dictionary](https://developers.iconroute.io/fields/). Flat keys (`email`, `phone`, `zip`, …) are accepted too. | | `tracking.ip_address` | string | yes | The consumer's own public IP address (not your server's). See [The consumer's IP](https://developers.iconroute.io/consent/#the-consumer-s-ip). | | `tracking` | object | — | Also your sub ids, UTM values and click ids. See [Attribution](https://developers.iconroute.io/attribution/). | | `tcpa_consent` | boolean | — | The consumer's consent decision, when you collected it before the search. See [Consent and TCPA](https://developers.iconroute.io/consent/). | | `trusted_form_cert_url`, `universal_leadid` | string | — | Consent certificates, when you have them. | | `icon_is_test_lead` | boolean | — | Test mode: held before any buyer receives it (TEST_LEAD_HELD). | ```bash curl -X POST 'https://iconroute.io/api/v1/engine/search' \ -H "Authorization: Bearer $ICON_API_KEY" \ -H 'Content-Type: application/json' \ -d '{ "icon_campaign_id": "6f1c2d3e-4a5b-4c6d-8e9f-0a1b2c3d4e5f", "icon_affiliate_id": "3c9d8e7f-1a2b-4c3d-9e8f-7a6b5c4d3e2f", "lead": { "personal": { "first_name": "Ada", "last_name": "Lovelace", "email": "ada.lovelace@mail.test", "phone": "6025552368", "date_of_birth": "1990-04-07" }, "address": { "address_line_1": "123 N Main St", "city": "Phoenix", "state": "AZ", "zip_code": "85004" }, "education": { "education_level": "bachelors", "high_school_graduation_year": "2008", "start_timeline": "1_3_months", "learning_preference": "online" }, "background": { "military_affiliation": "none", "us_citizen": "yes" } }, "tracking": { "ip_address": "203.0.113.9", "subid": "pub-42", "subid2": "creative-7", "utm_source": "partner", "utm_medium": "email", "utm_campaign": "fall-enrollment" }, "tcpa_consent": true, "trusted_form_cert_url": "https://cert.trustedform.com/0123456789abcdef0123456789abcdef01234567" }' ``` ## Response `200` with the search id and the first results: ```json { "code": "OK", "status": "accepted", "reason": "OK", "success": true, "icon_lead_id": "1e8d30bb-7c5f-41b9-8cf1-5e6b57abe999", "processing_done": false, "poll_after_ms": 1000, "accepted_count": 0, "max_accepted_submissions": 3, "true_exclusive_accepted": false, "offers": [ { "icon_result_id": "api-0b7e4a2c-981234", "campaign_id": "6f1c2d3e-4a5b-4c6d-8e9f-0a1b2c3d4e5f", "offer": { "id": "0b7e4a2c-9d8f-4e1a-b2c3-d4e5f6a7b8c9", "name": "Example University", "offer_type": "shared", "is_exclusive": false, "tcpa_text": "By clicking Submit, I agree…" }, "school": { "name": "Example University", "logo_url": "https://cdn.example.com/logo.png" }, "programs": [ { "value": "4411", "label": "BS Business (Online)", "payout": 28 } ], "payout": 28, "form_fields": [ { "name": "api_field_military", "label": "Military affiliation", "type": "select", "required": true, "options": [ { "value": "none", "label": "None" } ] }, { "name": "api_field_rn_license", "label": "RN license?", "type": "select", "required": false, "required_when": { "program_ids": [ "4411" ] } } ], "expires_at": "2026-09-25T20:15:00Z" } ] } ``` | Field | Type | Required | Description | | --- | --- | --- | --- | | `code` | string | — | — | | `status` | string | — | The outcome, from one lower-case set. One of: `accepted`, `saved`, `pending`, `held`, `rejected`, `invalid`, `failed`. | | `reason` | string | — | A short sentence-case explanation, e.g. "Missing HS grad year" or "Duplicate - Client" (" - Client": the school's own system decided). | | `processing_done` | boolean | — | False while API offers are still answering: poll /engine/results again. | | `poll_after_ms` | integer | — | Present while processing_done is false: wait this long before the next poll. | | `icon_lead_id` | string (uuid) | — | Your search id: poll with it, and send it as search_lead_id on submit. | | `accepted_count` | integer | — | — | | `max_accepted_submissions` | integer | — | — | | `true_exclusive_accepted` | boolean | — | — | `offers` is a list of [result entries](https://developers.iconroute.io/api/results/#the-result-entry), best first. ICON orders the list; keep its order when you show it. ## Errors | HTTP | Meaning | | --- | --- | | 400 | REQUIRED_FIELD_MISSING / INVALID_FIELD (the field is named), or NOT_FOUND: the campaign is not active or not yours. | | 401 | AUTH_REQUIRED (no API key) or AUTH_INVALID (a key ICON does not know); status invalid. | | 403 | FORBIDDEN: icon_affiliate_id is not the affiliate of your key, or the lead was already submitted. | | 429 | RATE_LIMITED (retryable): this API key is over its rate limit. Wait the Retry-After header's seconds (also retry_after_seconds) and retry. Limits are per API key and per leg: search 60 per minute with bursts of up to 10, results polling 600 per minute, submit 120 per minute, unless ICON set other limits for your key. | | 500 | INTERNAL_ERROR: not retryable as is; tell your ICON contact the time and the icon_lead_id. | | 503 | UNAVAILABLE: retry later. | --- # Results > GET /api/v1/engine/results/{icon_lead_id}: poll a search until every offer has answered. ```http GET /api/v1/engine/results/{icon_lead_id} ``` Returns **all** results of a search so far (not only the new ones). Poll until `processing_done` is `true`, waiting `poll_after_ms` between calls. A search stays open for up to 45 seconds. Rate limit: 600 per minute per key (bursts of up to 600). ```bash curl -X GET 'https://iconroute.io/api/v1/engine/results/1e8d30bb-7c5f-41b9-8cf1-5e6b57abe999' \ -H "Authorization: Bearer $ICON_API_KEY" ``` ## Response ```json { "code": "OK", "status": "accepted", "reason": "OK", "success": true, "icon_lead_id": "1e8d30bb-7c5f-41b9-8cf1-5e6b57abe999", "processing_done": true, "accepted_count": 0, "max_accepted_submissions": 3, "true_exclusive_accepted": false, "offers": [ { "icon_result_id": "api-0b7e4a2c-981234", "campaign_id": "6f1c2d3e-4a5b-4c6d-8e9f-0a1b2c3d4e5f", "offer": { "id": "0b7e4a2c-9d8f-4e1a-b2c3-d4e5f6a7b8c9", "name": "Example University", "offer_type": "shared", "is_exclusive": false, "tcpa_text": "By clicking Submit, I agree…" }, "school": { "name": "Example University", "logo_url": "https://cdn.example.com/logo.png" }, "programs": [ { "value": "4411", "label": "BS Business (Online)", "payout": 28 } ], "payout": 28, "form_fields": [ { "name": "api_field_military", "label": "Military affiliation", "type": "select", "required": true, "options": [ { "value": "none", "label": "None" } ] }, { "name": "api_field_rn_license", "label": "RN license?", "type": "select", "required": false, "required_when": { "program_ids": [ "4411" ] } } ], "expires_at": "2026-09-25T20:15:00Z" } ] } ``` The envelope is the same as the [search answer](https://developers.iconroute.io/api/search/#response). ## The result entry One result. The same core fields whether the offer is hosted by ICON or is a partner offer ICON calls. | Field | Type | Required | Description | | --- | --- | --- | --- | | `icon_result_id` | string | yes | Submit this id to POST /api/v1/engine/leads. | | `campaign_id` | string (uuid) or null | — | The campaign the result is sold under. | | `offer` | object | yes | — | | `offer.id` | string (uuid) | — | The ICON offer id. | | `offer.name` | string | — | — | | `offer.offer_type` | string | — | shared \| exclusive \| true_exclusive | | `offer.is_exclusive` | boolean or null | — | — | | `offer.tcpa_text` | string or null | — | The consent statement to show with this result. | | `school` | object | — | — | | `school.name` | string | — | — | | `school.logo_url` | string or null | — | — | | `programs` | array of object | yes | — | | `programs[].value` | string | yes | Send as program_id on submit. | | `programs[].label` | string | yes | — | | `programs[].payout` | number | — | Your payout for this program. Absent when none is configured. | | `payout` | number | — | Your best payout for this result (never the offer's own price). Absent when none is configured. | | `form_fields` | array of object | yes | Questions this result needs answered on submit, by name. | | `form_fields[].name` | string | yes | — | | `form_fields[].label` | string or null | — | — | | `form_fields[].type` | string | yes | — | | `form_fields[].required` | boolean | yes | — | | `form_fields[].required_when` | object | — | Required only when one of these programs is chosen. | | `form_fields[].options` | array of object | — | — | | `expires_at` | string (date-time) or null | — | Submit before this time. | ### Questions (`form_fields`) Ask every `required` question, and every question whose `required_when.program_ids` contains the program the consumer chose. Send each answer on submit under the question's `name`; for a `select`, send one of its `options[].value`. ### Expiry Submit before `expires_at`. An expired result answers `OFFER_UNAVAILABLE`: search again. ## Errors | HTTP | Meaning | | --- | --- | | 401 | AUTH_REQUIRED (no API key) or AUTH_INVALID (a key ICON does not know); status invalid. | | 403 | FORBIDDEN: the campaign or offer is not yours. | | 404 | NOT_FOUND: no search with that id belongs to your key. | | 429 | RATE_LIMITED (retryable): this API key is over its rate limit. Wait the Retry-After header's seconds (also retry_after_seconds) and retry. Limits are per API key and per leg: search 60 per minute with bursts of up to 10, results polling 600 per minute, submit 120 per minute, unless ICON set other limits for your key. | | 500 | INTERNAL_ERROR: not retryable as is; tell your ICON contact the time and the icon_lead_id. | | 503 | UNAVAILABLE: retry later. | --- # Submit a lead > POST /api/v1/engine/leads: submit a search result, or post a lead straight to an offer. ```http POST /api/v1/engine/leads ``` Delivers a lead to one offer. ICON runs its full checks again (duplicate, caps, eligibility, required fields, consent) before the lead reaches the buyer, and answers with the outcome. Rate limit: 120 per minute per key (bursts of up to 120). Every call to this endpoint counts. ## Submitting a search result Send the `icon_result_id` the consumer chose, the search it came from (`search_lead_id`), the program, the answers to the result's `form_fields` and the consumer's consent. **You do not send the lead again**: ICON builds the submit on the lead of the search the result came from. | Field | Type | Required | Description | | --- | --- | --- | --- | | `icon_result_id` | string | yes | The result to submit (from search/results). With it, icon_offer_id is taken from the result. | | `search_lead_id` | string (uuid) | yes | The icon_lead_id of the search the result came from. | | `program_id` | string | — | The chosen program (a programs[].value of the result). | | `icon_is_test_lead` | boolean | — | Test mode: held before any buyer receives it (TEST_LEAD_HELD). | Plus `icon_campaign_id`, `icon_affiliate_id`, the consent fields ([Consent and TCPA](https://developers.iconroute.io/consent/)) and one key per answered question. Do not send `icon_lead_id` on a result submit: `search_lead_id` links it to the search. ```bash curl -X POST 'https://iconroute.io/api/v1/engine/leads' \ -H "Authorization: Bearer $ICON_API_KEY" \ -H 'Content-Type: application/json' \ -d '{ "icon_result_id": "api-0b7e4a2c-981234", "search_lead_id": "1e8d30bb-7c5f-41b9-8cf1-5e6b57abe999", "icon_campaign_id": "6f1c2d3e-4a5b-4c6d-8e9f-0a1b2c3d4e5f", "icon_affiliate_id": "3c9d8e7f-1a2b-4c3d-9e8f-7a6b5c4d3e2f", "program_id": "4411", "api_field_military": "none", "tcpa_consent": true, "consent_button_label": "Request information" }' ``` How ICON builds the submit: - The lead, tracking and consumer IP come from your search. Any lead field you send with the submit replaces the search's value (for example a corrected phone number, or an answer the search did not have). - **Consent is not taken from the search**: send `tcpa_consent` (and `consent_button_label`) with every submit, for the statement shown with that result. - The result must belong to your API key: another affiliate's result answers `404` (`error: "search_result_not_found"`), even if you send a full lead. A campaign you name must be one of the result's (`403`, `error: "result_campaign_mismatch"`). - Sending the full lead still works. Every submit is recorded as its own lead, linked to the search. ## Direct posting When your campaign has an offer you post to directly, skip the search: send the whole lead with your campaign id, the offer id and the consumer's IP in `tracking.ip_address` (required). | Field | Type | Required | Description | | --- | --- | --- | --- | | `icon_campaign_id` | string | required | ICON campaign ID (UUID) of the campaign this request is for. Required on every search and every submit: ICON never picks a campaign for you. | | `icon_affiliate_id` | string | required | Your ICON affiliate ID (UUID). Required on every search and every submit, and must be the affiliate your API key belongs to. ICON records the affiliate from the key; this value is only checked against it. | | `icon_offer_id` | string | required for direct posting | The ICON offer id (UUID). Required when you post a lead straight to an offer; taken from the result when you submit an icon_result_id. | | `icon_lead_id` | string | optional | The icon_lead_id ICON returned for a lead you posted directly: send it when you retry that post, so ICON recognises the lead. A request that would change a submitted lead is refused (403). Not used on a result submit (send search_lead_id). | ```bash curl -X POST 'https://iconroute.io/api/v1/engine/leads' \ -H "Authorization: Bearer $ICON_API_KEY" \ -H 'Content-Type: application/json' \ -d '{ "icon_campaign_id": "6f1c2d3e-4a5b-4c6d-8e9f-0a1b2c3d4e5f", "icon_affiliate_id": "3c9d8e7f-1a2b-4c3d-9e8f-7a6b5c4d3e2f", "icon_offer_id": "0b7e4a2c-9d8f-4e1a-b2c3-d4e5f6a7b8c9", "lead": { "personal": { "first_name": "Ada", "last_name": "Lovelace", "email": "ada.lovelace@mail.test", "phone": "6025552368", "date_of_birth": "1990-04-07" }, "address": { "address_line_1": "123 N Main St", "city": "Phoenix", "state": "AZ", "zip_code": "85004" }, "education": { "education_level": "bachelors", "high_school_graduation_year": "2008", "start_timeline": "1_3_months", "learning_preference": "online" }, "background": { "military_affiliation": "none", "us_citizen": "yes" } }, "tracking": { "ip_address": "203.0.113.9", "subid": "pub-42", "subid2": "creative-7", "utm_source": "partner", "utm_medium": "email", "utm_campaign": "fall-enrollment" }, "program_id": "4411", "tcpa_consent": true, "trusted_form_cert_url": "https://cert.trustedform.com/0123456789abcdef0123456789abcdef01234567" }' ``` Your ICON contact gives you the offer id, its consent statement and the questions it requires. A partner offer (one ICON calls through its own API) can only be submitted from a search result. ## Response Accepted: ```json { "code": "ACCEPTED", "status": "accepted", "reason": "Lead accepted", "success": true, "accepted": true, "retryable": false, "icon_lead_id": "5a2c7e91-3b4d-4f6a-9c8e-2d1f0b7a6e53", "payout": 28, "message": "Lead processed successfully" } ``` Refused (here, ICON already has this lead for the offer): ```json { "code": "DUPLICATE", "status": "rejected", "reason": "Duplicate - Offer", "source": "icon", "success": false, "accepted": false, "retryable": false, "icon_lead_id": "5a2c7e91-3b4d-4f6a-9c8e-2d1f0b7a6e53", "payout": 0, "message": "Duplicate lead" } ``` | Field | Type | Required | Description | | --- | --- | --- | --- | | `code` | string | — | ACCEPTED, or the reason it was not (DUPLICATE, NOT_ELIGIBLE, CAP_REACHED, BUYER_REJECTED, …). | | `status` | string | — | The outcome, from one lower-case set. One of: `accepted`, `saved`, `pending`, `held`, `rejected`, `invalid`, `failed`. | | `reason` | string | — | A short sentence-case explanation, e.g. "Missing HS grad year" or "Duplicate - Client" (" - Client": the school's own system decided). | | `success` | boolean | — | — | | `accepted` | boolean | — | — | | `retryable` | boolean | — | — | | `icon_lead_id` | string | — | — | | `payout` | number | — | Accepted: YOUR payout booked for this lead (absent when none is configured). Not accepted: 0. Never the offer's own price. | | `message` | string | — | — | | `disposition` | object | — | — | The answer's `icon_lead_id` is the id of the lead this submit created, not your search id: store it with your own lead. A decision (accepted or refused by ICON or the buyer) answers HTTP `200`, and so do most refusals for a missing or invalid answer or consent (`status: "invalid"`): read `status` and `code`, not the HTTP status. `PENDING` (HTTP 200 or 202) is not final: the buyer has not decided yet. The reasons are standardized: `Duplicate - Offer` means ICON already has the lead for that offer, `Duplicate - Client` means the school's own system said duplicate. See [Standard responses](https://developers.iconroute.io/responses/). ## Errors | HTTP | Meaning | | --- | --- | | 400 | REQUIRED_FIELD_MISSING / INVALID_FIELD (the field is named). | | 401 | AUTH_REQUIRED (no API key) or AUTH_INVALID (a key ICON does not know); status invalid. | | 403 | FORBIDDEN: the campaign or offer is not yours, icon_affiliate_id is not your key's affiliate, the campaign you named is not one of the result's (error "result_campaign_mismatch"), or the lead was already submitted. | | 404 | NOT_FOUND: the result, lead or offer is unknown to your key (another affiliate's result answers error "search_result_not_found"). | | 409 | MALFORMED_REQUEST: send search_lead_id (the icon_lead_id of the search the result came from). | | 429 | RATE_LIMITED (retryable): this API key is over its rate limit. Wait the Retry-After header's seconds (also retry_after_seconds) and retry. Limits are per API key and per leg: search 60 per minute with bursts of up to 10, results polling 600 per minute, submit 120 per minute, unless ICON set other limits for your key. | | 500 | INTERNAL_ERROR (request_id identifies it to ICON support). | | 503 | UNAVAILABLE: retry later. | --- # Duplicate pre-check > POST /api/v1/engine/dupe-check: ask whether ICON would call a lead a duplicate, before you post it. ```http POST /api/v1/engine/dupe-check ``` Optional. Answers one thing: `duplicate_likely`, `true` or `false`. No score, no matched fields. **Submit always runs ICON's full duplicate check**, whatever the pre-check answered. - **Server to server only.** The endpoint has no CORS: call it from your server, never from a web page. - **Rate limit:** 30 per minute per key (bursts of up to 10), separate from your other limits. - **Body:** at most 16 KB. ## Request Send any of the lead's fields, raw (the same names as a lead), or hashed under `hashed`, or both. A hashed key wins over the same raw key. The more fields you send, the more accurate the answer. | Field | Type | Required | Description | | --- | --- | --- | --- | | `offer_id` | string (uuid) | — | Optional: one of your offers (for an offer-scoped policy). | | `email` | string | — | — | | `phone` | string | — | — | | `first_name` | string | — | — | | `last_name` | string | — | — | | `zip` | string | — | — | | `address` | string | — | — | | `dob` | string | — | YYYY-MM-DD or MM/DD/YYYY. | | `hashed` | object | — | — | Raw: ```bash curl -X POST 'https://iconroute.io/api/v1/engine/dupe-check' \ -H "Authorization: Bearer $ICON_API_KEY" \ -H 'Content-Type: application/json' \ -d '{ "email": "ada.lovelace@mail.test", "phone": "(602) 555-2368", "zip": "85004", "last_name": "Lovelace" }' ``` Hashed: ```json { "hashed": { "email": "d6117306485ed0e50afab3ac871e98f81699151f30281527d63ff5f233656c69", "phone": "2f83685e66d4cb4d1bcff5f422ff9b0d7ce748a6f3103269d1adad16ab2e279b", "zip": "918abeeaae3a90ad25f3a5e45408af4cdd71ac92accc3638461dc2f76e39cc01" } } ``` ## Hashing `hashed.` is the **lower-case hex SHA-256** of the key's normalized form, encoded as UTF-8. Normalize first, then hash. Each example below was computed by ICON's own normalizer: | Key | Normalized form | Example | | --- | --- | --- | | `email` | Trimmed and lower-case. At gmail.com and googlemail.com the dots and any +tag are removed from the local part and the domain is gmail.com. | `Jane.Doe+promo@GoogleMail.com` → `janedoe@gmail.com` | | `phone` | The 10 US digits: no country code, no punctuation. | `+1 (602) 555-2368` → `6025552368` | | `last_name` | Lower-case a–z only: accents removed (ł ø đ ð þ æ œ ß ı ħ ŧ written l o d d th ae oe ss i h t); spaces, hyphens and apostrophes dropped. | `Núñez-O'Brien` → `nunezobrien` | | `zip` | The 5 digits (a +4 is dropped). | `85001-1234` → `85001` | | `dob` | YYYY-MM-DD. Send birth_year with it when you hash a date of birth. | `04/07/1990` → `1990-04-07` | | `birth_year` | Four digits (from a year-born answer or the date of birth). | `1990-04-07` → `1990` | | `grad_year` | Four digits. | `2008` → `2008` | | `ip` | The client address: the first of a forwarded list; IPv4 as a dotted quad without leading zeros; IPv6 compressed and lower-case, an IPv4-mapped address written as IPv4. | `2001:0DB8:0000:0000:0000:0000:0000:0001` → `2001:db8::1` | The SHA-256 of each normalized example above: ```text email d6117306485ed0e50afab3ac871e98f81699151f30281527d63ff5f233656c69 phone 2f83685e66d4cb4d1bcff5f422ff9b0d7ce748a6f3103269d1adad16ab2e279b last_name 36a69f8b745a87f4314b2dbcb8906ab78c03d4e2595f6b694f7af49b31f51242 zip 918abeeaae3a90ad25f3a5e45408af4cdd71ac92accc3638461dc2f76e39cc01 dob 8b44df2e1ab8ebe9eb4704090cf1524944141f66c7ab6dbed65391681a0d4878 birth_year a7be8e1fe282a37cd666e0632b17d933fa13f21addf4798fc0455bc166e2488c grad_year e5e53c784d5d49de1cabb6e904bf3380026aadcb9769775a268dd304dd9aa2df ip 5afd19e856d1c18d17d600dfd2b5f534992333985e126c2a951047102c1ed536 ``` Send the first name, the street address and the consent certificate **raw**, under their usual lead names (`first_name`, `address`, `trusted_form_cert_url` or `universal_leadid`): their matching forms are ICON's. ```javascript import { createHash } from 'node:crypto'; const sha256 = (value) => createHash('sha256').update(value, 'utf8').digest('hex'); // Normalize first (see the table), then hash. const body = { hashed: { email: sha256('janedoe@gmail.com'), phone: sha256('6025552368'), zip: sha256('85001'), }, }; ``` ```python import hashlib def sha256(value: str) -> str: return hashlib.sha256(value.encode("utf-8")).hexdigest() body = {"hashed": {"email": sha256("janedoe@gmail.com")}} ``` ## Response ```json { "code": "OK", "status": "accepted", "reason": "OK", "success": true, "duplicate_likely": false, "retryable": false } ``` `duplicate_likely: true` means a submit of this lead would very likely be refused as `DUPLICATE`. `false` is not a promise: the submit's own check decides. ## Errors | HTTP | Meaning | | --- | --- | | 400 | MALFORMED_REQUEST: no usable lead field was sent (raw or hashed); INVALID_FIELD: offer_id is not a UUID. | | 401 | AUTH_REQUIRED (no API key) or AUTH_INVALID (a key ICON does not know); status invalid. | | 403 | FORBIDDEN: the offer_id is not yours, or this key is not permitted to use the pre-check. | | 404 | Not found: the pre-check is not available on your account; ask your ICON contact. | | 405 | MALFORMED_REQUEST: use POST. | | 413 | The body is over 16 KB. | | 429 | RATE_LIMITED (retryable): this key is over its pre-check limit. Wait the Retry-After seconds and retry. | | 503 | UNAVAILABLE (retryable): the check could not run. Retry, or post the lead: submit runs the full check. | A `404` answers a plain `{"error": "Not found"}` body. --- # Standard responses > Every answer carries status, reason and code from one catalog. The tables below are generated from it. Every JSON answer carries the same fields (the one exception: an unknown path answers a plain `{"error": "Not found"}`): | Field | What it is | | --- | --- | | `status` | The outcome, always one of: `accepted`, `saved`, `pending`, `held`, `rejected`, `invalid`, `failed`. | | `reason` | A short sentence-case explanation: `Missing HS grad year`, `Not eligible - Program`, `Duplicate - Client`. | | `code` | A stable upper-case code. Branch on it; it never changes meaning. | | `retryable` | Whether the same request may succeed later without a change. | | `field` / `fields` | The field(s) at fault, when there are any. | | `level` | For an eligibility refusal: where it applied (`offer`, `program`, `bucket`, `campaign`, `affiliate`, `buyer`). | | `source` | For `DUPLICATE`: `icon` (ICON already has the lead) or `buyer` (the school's system said duplicate). | ## Statuses | status | Meaning | | --- | --- | | `accepted` | The request succeeded: a lead was sold, or a read answered. | | `saved` | The lead was stored; nothing was sold. | | `pending` | The buyer has not decided yet. | | `held` | A test lead, held by ICON before any buyer received it. | | `rejected` | A valid request that ICON or the buyer declined (eligibility, cap, duplicate, …). | | `invalid` | The request must change before it can succeed (a missing or bad field, consent, authentication). | | `failed` | Nothing was decided (a timeout, a buyer or platform error); it may be retried. | ## Reasons A reason ends in `- Client` when the school's own system decided, and in `- ` (`- Offer`, `- Program`, …) when ICON's rules at that level decided. A reason names a field when one is at fault: `Invalid ZIP code`. Reasons never carry the buyer's own words, internal rule names, limits or ids. ## Codes The HTTP column lists every status a code can be served with. Decisions about a lead (accepted, refused, held) come with HTTP `200`: read `status` and `code`. ### Success | code | status | Default reason | Retryable | HTTP | | --- | --- | --- | --- | --- | | `OK` | accepted | OK | no | 200 | | `ACCEPTED` | accepted | Lead accepted | no | 200 | | `SAVED` | saved | Lead saved | no | 200 | | `PENDING` | pending | Pending - Client | no | 200, 202 | | `TEST_LEAD_HELD` | held | Test lead held | no | 200 | ### Authentication and access | code | status | Default reason | Retryable | HTTP | | --- | --- | --- | --- | --- | | `AUTH_REQUIRED` | invalid | Missing API key | no | 401 | | `AUTH_INVALID` | invalid | Invalid API key | no | 401 | | `FORBIDDEN` | invalid | Not permitted | no | 403 | | `NOT_FOUND` | invalid | Not found | no | 200, 400, 404 | ### Request | code | status | Default reason | Retryable | HTTP | | --- | --- | --- | --- | --- | | `METHOD_NOT_ALLOWED` | invalid | Method not allowed | no | 405 | | `MALFORMED_REQUEST` | invalid | Malformed request | no | 400, 409 | | `REQUIRED_FIELD_MISSING` | invalid | Missing required field | no | 200, 400, 422 | | `INVALID_FIELD` | invalid | Invalid field | no | 200, 400, 422 | | `BOT_CHECK_FAILED` | invalid | Bot check failed | no | 400 | | `RATE_LIMITED` | failed | Too many requests | yes | 429 | ### Eligibility | code | status | Default reason | Retryable | HTTP | | --- | --- | --- | --- | --- | | `NOT_ELIGIBLE` | rejected | Not eligible | no | 200 | | `OFFER_UNAVAILABLE` | rejected | Offer unavailable | no | 200, 400, 404, 410 | ### Caps | code | status | Default reason | Retryable | HTTP | | --- | --- | --- | --- | --- | | `CAP_REACHED` | rejected | Cap reached | no | 200 | | `CAP_UNAVAILABLE` | failed | Cap check unavailable | yes | 200, 503 | ### Consent | code | status | Default reason | Retryable | HTTP | | --- | --- | --- | --- | --- | | `CONSENT_REQUIRED` | invalid | Missing TCPA consent | no | 200, 400 | | `CONSENT_INVALID` | invalid | Invalid TCPA consent | no | 400, 404, 409 | ### Duplicate | code | status | Default reason | Retryable | HTTP | | --- | --- | --- | --- | --- | | `DUPLICATE` | rejected | Duplicate - Offer | no | 200, 409 | ### Buyer decision | code | status | Default reason | Retryable | HTTP | | --- | --- | --- | --- | --- | | `BUYER_REJECTED` | rejected | Rejected - Client | no | 200, 502 | | `BUYER_INVALID_FIELD` | invalid | Invalid field - Client | no | 200, 502 | | `BUYER_NOT_ELIGIBLE` | rejected | Not eligible - Client | no | 200, 502 | | `BUYER_ERROR` | failed | Error - Client | no | 200, 502 | ### Transport | code | status | Default reason | Retryable | HTTP | | --- | --- | --- | --- | --- | | `TIMEOUT` | failed | Timeout - Client | yes | 200, 504 | ### Platform | code | status | Default reason | Retryable | HTTP | | --- | --- | --- | --- | --- | | `UNAVAILABLE` | failed | Temporarily unavailable | yes | 200, 503 | | `INTERNAL_ERROR` | failed | Internal error | no | 500 | ## Buyer reasons When a school's system refuses a lead, ICON translates its answer into one of these standardized reasons (a school that says it is full answers `CAP_REACHED`, `Cap reached`, like any other cap): | reason | code | field | | --- | --- | --- | | Rejected - Client | `BUYER_REJECTED` | — | | Duplicate - Client | `DUPLICATE` | — | | Location not served - Client | `BUYER_NOT_ELIGIBLE` | `zip` | | Age not eligible - Client | `BUYER_NOT_ELIGIBLE` | `date_of_birth` | | Education not eligible - Client | `BUYER_NOT_ELIGIBLE` | `education_level` | | HS grad year not eligible - Client | `BUYER_NOT_ELIGIBLE` | `grad_year` | | Program not offered - Client | `BUYER_NOT_ELIGIBLE` | `program_id` | | Invalid phone number - Client | `BUYER_INVALID_FIELD` | `phone` | | Invalid email - Client | `BUYER_INVALID_FIELD` | `email` | | Invalid ZIP code - Client | `BUYER_INVALID_FIELD` | `zip` | | Invalid TrustedForm token - Client | `BUYER_INVALID_FIELD` | `trustedform_cert_url` | | Consent not accepted - Client | `BUYER_REJECTED` | — | | Test lead - Client | `BUYER_REJECTED` | — | The `field` column uses ICON's short field names (see the [field dictionary](https://developers.iconroute.io/fields/)). A buyer answer ICON cannot map reads `Rejected - Client`. --- # Errors and retries > How to read a failed answer, which answers to retry, and how to retry safely. ## Read `status` first | status | What to do | | --- | --- | | `accepted`, `saved`, `held` | Done. Nothing to retry. | | `pending` | The buyer has not decided yet. Do not resubmit. | | `rejected` | A final decision (eligibility, cap, duplicate, buyer refusal). Do not retry the same lead to the same offer. | | `invalid` | Fix the request (the `field` / `fields` say what) and send it again. | | `failed` | Nothing was decided. Retry only when `retryable` is `true`. | ## Retryable answers | code | reason | HTTP | | --- | --- | --- | | `RATE_LIMITED` | Too many requests | 429 | | `CAP_UNAVAILABLE` | Cap check unavailable | 200, 503 | | `TIMEOUT` | Timeout - Client | 200, 504 | | `UNAVAILABLE` | Temporarily unavailable | 200, 503 | ## How to retry - **Honour `Retry-After`.** A `RATE_LIMITED` answer (HTTP 429) carries a `Retry-After` header and `retry_after_seconds`: wait that long. - **Back off.** Otherwise wait 1 s, then 2 s, then 4 s; give up after three retries and log the answer. - **Retry with the same link to the lead.** Retry a result submit with the same `search_lead_id`, and a direct post with the `icon_lead_id` ICON returned. ICON then recognises an offer that already accepted the lead and answers `DUPLICATE` (`Duplicate - Offer`) instead of selling it twice. - **A `DUPLICATE` on a retry** can mean your earlier attempt was accepted. Before you treat the lead as refused, ask your ICON contact with its `icon_lead_id`. - **No answer at all** (a network error or your own timeout): retry once, as above. Give a submit a generous timeout (30 seconds or more): the school's system answers inside that call. - **`INTERNAL_ERROR`** is not retryable as is. Send your ICON contact the time and the `icon_lead_id` (a submit's answer also carries a `request_id`). ## Error body ```json { "code": "REQUIRED_FIELD_MISSING", "status": "invalid", "reason": "Missing HS grad year", "message": "Required field missing", "retryable": false, "field": "lead.education.high_school_graduation_year" } ``` ```json { "code": "RATE_LIMITED", "status": "failed", "reason": "Too many requests", "message": "Too many requests", "retryable": true, "retry_after_seconds": 2 } ``` --- # Field dictionary > The lead fields ICON reads, where it stores them, the other names it accepts and the values it stores. Send these fields and values and every offer receives what it expects. The tables are generated from ICON's field value standards. ## Shape of a lead Send the lead nested, as ICON stores it: ```json { "lead": { "personal": { "first_name": "Ada", "last_name": "Lovelace", "email": "ada.lovelace@mail.test", "phone": "6025552368", "date_of_birth": "1990-04-07" }, "address": { "address_line_1": "123 N Main St", "city": "Phoenix", "state": "AZ", "zip_code": "85004" }, "education": { "education_level": "bachelors", "high_school_graduation_year": "2008", "start_timeline": "1_3_months", "learning_preference": "online" }, "background": { "military_affiliation": "none", "us_citizen": "yes" } }, "tracking": { "ip_address": "203.0.113.9", "subid": "pub-42", "subid2": "creative-7", "utm_source": "partner", "utm_medium": "email", "utm_campaign": "fall-enrollment" } } ``` Flat keys are accepted too (the **Also accepted** column): `email`, `phone`, `zip`, `dob`, … . Prefer the nested form. ## Identity and control fields | Field | Type | Required | Description | | --- | --- | --- | --- | | `icon_campaign_id` | string | required (search + submit) | ICON campaign ID (UUID) of the campaign this request is for. Required on every search and every submit: ICON never picks a campaign for you. | | `icon_affiliate_id` | string | required (search + submit) | Your ICON affiliate ID (UUID). Required on every search and every submit, and must be the affiliate your API key belongs to. ICON records the affiliate from the key; this value is only checked against it. | | `icon_offer_id` | string | required for direct posting | The ICON offer id (UUID). Required when you post a lead straight to an offer; taken from the result when you submit an icon_result_id. | | `icon_lead_id` | string | optional (submit) | The icon_lead_id ICON returned for a lead you posted directly: send it when you retry that post, so ICON recognises the lead. A request that would change a submitted lead is refused (403). Not used on a result submit (send search_lead_id). | ## Contact, address and formatted fields Send each in the format shown. | Field | Stored at | Also accepted | Format | | --- | --- | --- | --- | | high_school_graduation_year | `lead.education.high_school_graduation_year` | `high_school_graduation_year`, `grad_year` | Four digits, e.g. 2012. | | email | `lead.personal.email` | `email` | An email address. | | phone | `lead.personal.phone` | `phone` | The 10-digit US number, digits only (e.g. 6025552368). | | first_name | `lead.personal.first_name` | `first_name`, `firstname` | Text. | | last_name | `lead.personal.last_name` | `last_name`, `lastname` | Text. | | address_line_1 | `lead.address.address_line_1` | `address`, `address_line1` | The street line. | | address_line_2 | `lead.address.address_line_2` | `address2`, `address_line2` | Unit / apartment line. | | city | `lead.address.city` | `city` | Text. | | state | `lead.address.state` | `state` | The two-letter US state code. Values: `AL`, `AK`, `AZ`, `AR`, `CA`, `CO`, `CT`, `DE`, `FL`, `GA`, `HI`, `ID`, `IL`, `IN`, `IA`, `KS`, `KY`, `LA`, `ME`, `MD`, `MA`, `MI`, `MN`, `MS`, `MO`, `MT`, `NE`, `NV`, `NH`, `NJ`, `NM`, `NY`, `NC`, `ND`, `OH`, `OK`, `OR`, `PA`, `RI`, `SC`, `SD`, `TN`, `TX`, `UT`, `VT`, `VA`, `WA`, `WV`, `WI`, `WY`, `DC`, `AS`, `GU`, `MP`, `PR`, `VI`, `UM` | | zip_code | `lead.address.zip_code` | `zip`, `zip_code` | The 5-digit ZIP. | | date_of_birth | `lead.personal.date_of_birth` | `dob` | YYYY-MM-DD. | | age | `lead.personal.age` | `age` | Whole number of years. | | year_born | `year_born` | — | Four digits. | | ip_address | `tracking.ip_address` | `ip` | The consumer's own public IPv4 or IPv6 address (not your server's). Required from affiliates. | | UTM parameters | `tracking.utm_source`, `tracking.utm_medium`, `tracking.utm_campaign`, `tracking.utm_content`, `tracking.utm_term` | `utm_source`, `utm_medium`, `utm_campaign`, `utm_content`, `utm_term` | Text, trimmed; case is kept. | | Click ids and sub ids | `tracking.gclid`, `tracking.gbraid`, `tracking.wbraid`, `tracking.fbclid`, `tracking.msclkid`, `tracking.ttclid`, `tracking.click_id`, `tracking.subid`, `tracking.subid2`, `tracking.subid3`, `tracking.subid4`, `tracking.subid5` | `gclid`, `fbclid`, `msclkid`, `click_id`, `subid`, `subid2`, `subid3`, `subid4`, `subid5` | Text, trimmed; case is kept. | ## Answers with fixed values Send one of the listed values, exactly as written. | Field | Stored at | Also accepted | Values | | --- | --- | --- | --- | | rn_license | `lead.background.rn_license` | `rn_license` | `rn`, `lpn_lvn`, `no` | | military_affiliation | `lead.background.military_affiliation` | `military_affiliation` | `none`, `affiliated`, `active_duty`, `veteran`, `spouse_dependent` | | us_citizen | `lead.background.us_citizen` | `us_citizen` | `yes`, `no` | | has_internet_access | `lead.background.has_internet_access` | `has_internet_access` | `yes`, `no` | | teaching_certificate | `lead.education.teaching_certificate` | `teaching_certificate` | `yes`, `no` | | currently_enrolled | `currently_enrolled` | — | `yes`, `no` | | education_level | `lead.education.education_level` | `education_level` | `no_hs_diploma`, `ged`, `high_school`, `some_college`, `associates`, `bachelors`, `masters`, `doctorate` | | start_timeline | `lead.education.start_timeline` | `start_timeline`, `start_date` | `immediately`, `1_3_months`, `4_6_months`, `7_12_months`, `over_1_year`, `not_sure` | | learning_preference | `lead.education.learning_preference` | `learning_preference` | `online`, `campus`, `either` | | gender | `lead.personal.gender` | `gender` | `female`, `male` | ## Questions an offer adds An offer can ask more than the fields above (a program, a licence, a school-specific question). Those come with each result as `form_fields`: send each answer on submit under the question's `name`. See [Results](https://developers.iconroute.io/api/results/#questions-form_fields). Send the values exactly as listed. A value ICON does not recognise is kept as sent, never guessed; an offer that needs a recognised value refuses it as `INVALID_FIELD` with the field named. --- # Consent and TCPA > Show each result's consent statement as ICON gives it, and send the consumer's decision with the lead. ICON records, for every lead, the consent statement the consumer saw and their decision. You show the statement; ICON keeps the record. ## What to show Each result carries its statement in `offer.tcpa_text`. Show it next to the button that submits the lead, **word for word**. Do not shorten or rephrase it. The statement may contain tokens: | Token | Becomes | Filled by | | --- | --- | --- | | `{{BUTTON}}` | the label of the button the visitor clicks | You: replace it with your submit button's label, and send that label as consent_button_label. | | `{{PHONE}}` | the phone number they entered | ICON, from the phone number on the lead. | | `{{OFFER NAME}}` | the school the offer sends the lead to | ICON, with the name of the school the result sends the lead to. | - `offer.needs_checkbox: true`: show an **unticked** checkbox with the statement; the consumer must tick it. Otherwise clicking the button is the consent. - `offer.consent_checkboxes`: extra boxes an offer asks for (`field`, `text`, `required`). Show each with its `text`; do not let the consumer submit to that offer until every `required` box is ticked. ## What to send | Field | When | Value | | --- | --- | --- | | `tracking.ip_address` | **Required** on every search and every direct post. A result submit uses the search's unless you send it. | The consumer's own IP address: a real public IPv4 or IPv6 address, the one the consumer used on your page (not your server's). Private, shared and reserved addresses are refused. | | `tcpa_consent` | Every submit, and on search when you collected consent before it. | The boolean `true` when the consumer agreed. | | `consent_button_label` | Every submit whose statement had `{{BUTTON}}`. | The label of your submit button (at most 80 characters). | | `additional_consents` | When the result had `consent_checkboxes`. | One entry per ticked box: `{"field": "", "text": "", "checked": true}`. | | `xxTrustedFormCertUrl`, `xxTrustedFormToken`, `trusted_form_cert_url` | When you use TrustedForm. | The certificate URL. | | `universal_leadid`, `universalLeadId`, `jornaya_lead_id` | When you use Jornaya. | The LeadiD token. | ```json { "icon_result_id": "…", "tracking": { "ip_address": "203.0.113.9" }, "tcpa_consent": true, "consent_button_label": "Request information", "additional_consents": [ { "field": "sms_opt_in", "text": "Text me about my application.", "checked": true } ] } ``` ## The consumer's IP ICON records the consumer's IP address in the consent record and on the lead. Your call comes from your server, so ICON takes the consumer's IP **only from `tracking.ip_address`** (`ip` is accepted too) and never treats the connection's address as the consumer's: - A valid consumer IP is recorded as the consumer's; your server's address is kept separately, for audit. - On a search or a direct post, a missing, private, shared or reserved address is not used: the call is still processed, but the lead and its consent record hold your server's address, marked as a server address. That is weaker consent evidence, so always send the consumer's. - A result submit uses the IP of its search, unless you send one. - The examples on this site use documentation addresses (`203.0.113.x`), which ICON refuses: send the real one. ## Sending consent Send the consent fields with **every submit**: each submit is checked on its own. A new phone number withdraws the consent given for the old one: collect consent again for the new number. ## When consent is missing or wrong | code | reason | When | | --- | --- | --- | | `CONSENT_REQUIRED` | Missing TCPA consent | The offer needs consent and none was sent, or the statement shown does not cover the offer. | | `CONSENT_INVALID` | Invalid TCPA consent | The consent sent cannot be matched to a statement ICON knows. | Both have status `invalid`: fix the request and submit again. --- # Attribution > Send your sub ids, UTM values and click ids with the lead; ICON stores them as sent and returns its own lead id. Put attribution under `tracking` on the search and on every direct post. A result submit carries the search's; anything you send with it replaces the search's value. ICON stores each value as sent: trimmed, never re-cased. ## Sub ids | Field | Use | | --- | --- | | `tracking.subid` | Your primary sub id (a traffic source, a publisher). | | `tracking.subid2` | Any further breakdown you need. | | `tracking.subid3` | Any further breakdown you need. | | `tracking.subid4` | Any further breakdown you need. | | `tracking.subid5` | Any further breakdown you need. | Keep your ids short and free of personal data: no emails, phone numbers or names. ## UTM values | Field | | --- | | `tracking.utm_source` | | `tracking.utm_medium` | | `tracking.utm_campaign` | | `tracking.utm_content` | | `tracking.utm_term` | ## Click ids | Field | | --- | | `tracking.gclid` | | `tracking.gbraid` | | `tracking.wbraid` | | `tracking.fbclid` | | `tracking.msclkid` | | `tracking.ttclid` | | `tracking.click_id` | ## The consumer's IP Send `tracking.ip_address` on every search and every direct post (a result submit uses the search's): the consumer's own public address, not your server's. It is **required**: ICON records it in the consent record and on the lead, and uses it for duplicate checks. See [Consent and TCPA](https://developers.iconroute.io/consent/#the-consumer-s-ip). ## Joining back Every search and submit answers `icon_lead_id`. Store it with your own lead id: it is the key for any question to ICON about a lead. ICON does not send postbacks to affiliates today: the submit answer is the outcome (a `PENDING` answer is not final yet). --- # Rate limits > Limits are per API key and per endpoint. Over a limit ICON answers 429 with Retry-After. ## Defaults | Endpoint | Per minute | Burst | | --- | --- | --- | | Search: `POST /api/v1/engine/search` | 60 | 10 | | Results polling: `GET /api/v1/engine/results/{icon_lead_id}` | 600 | 600 | | Submit: `POST /api/v1/engine/leads` | 120 | 120 | | Duplicate pre-check: `POST /api/v1/engine/dupe-check` | 30 | 10 | Each key and endpoint has its own budget. It refills continuously (per minute ÷ 60 each second) and holds at most the burst, so a key can send a burst at once and then its steady rate. Treat the numbers as your ceiling, not a guarantee of capacity. ICON can set other limits on a key; ask your ICON contact. ## Over a limit A call over a limit may be refused: ```http HTTP/1.1 429 Too Many Requests Retry-After: 2 Content-Type: application/json { "code": "RATE_LIMITED", "status": "failed", "reason": "Too many requests", "retryable": true, "retry_after_seconds": 2 } ``` Wait the `Retry-After` seconds, then retry. See [Errors and retries](https://developers.iconroute.io/errors/). ## Staying under - Poll results no faster than `poll_after_ms` and stop at `processing_done`. - Search once per lead. Submit only results the consumer chose. - Use one key per integration so one busy integration cannot slow another. --- # Testing > Test leads run the whole pipeline with your live key and are held before any buyer receives them. ## Test mode Add `"icon_is_test_lead": true` to a search and to its submit. > Test mode: held before any buyer receives it (TEST_LEAD_HELD). A test lead: - runs the live pipeline with your live key and campaign, with three differences: a test search lists every ranked offer (a school can appear more than once), buyers are not contacted, and the person-level duplicate check does not run; - answers `code: "TEST_LEAD_HELD"`, `status: "held"`, `reason: "Test lead held"` on submit, with `payout: 0`, `submitted: false` and `test_lead: true`; `success` and `accepted` look like the live answer, so your flow runs as it would; - is **never delivered** to a school and never counted (no sale, no cap use, no payout); - stays a test lead: a later call for the same lead cannot make it live. ## A test plan 1. Search with a test lead; check that you receive results and that `processing_done` turns `true`. 2. Show a result with its consent statement; submit it with the test flag; expect `TEST_LEAD_HELD`. 3. Leave out a required answer; expect `REQUIRED_FIELD_MISSING` naming the field. 4. Try the [duplicate pre-check](https://developers.iconroute.io/api/dupe-check/) with raw and hashed fields. Test leads count toward your [rate limits](https://developers.iconroute.io/rate-limits/). ## Going live Remove the test flag. Tell your ICON contact before your first live lead so they can watch it through. --- # Changelog > Changes to what the API accepts and answers, newest first. ICON adds fields to answers without notice (ignore keys you do not use). Anything that removes or changes a field is listed here first. ## 2026-09-29: The consumer's IP, result-id-only submit, general refusals - A result submit needs no lead: send `icon_result_id`, `search_lead_id`, your campaign and affiliate ids, the program, the answers and consent; ICON builds it on the lead of the search. A result that is not yours answers 404. Sending the full lead still works. - Every cap answers `CAP_REACHED`, with no level in the code, reason or message: the per-level cap codes are no longer sent, and cap answers no longer carry `level`, `cap_level`, `cap_message` or `source`. Eligibility refusals always answer `NOT_ELIGIBLE`. - `tracking.ip_address` is required on every search and direct post: the consumer's own public IP address. ICON records it on the lead and in the consent record, and never replaces it with the address your call comes from. A result submit uses the search's. - Private, shared and reserved addresses are refused. Without a valid one, ICON records your server's address and marks it as such. ## 2026-09-28: Developer Center - These docs are public, generated from the API definitions ICON runs on. Every page is also available as Markdown, with an llms.txt index and an OpenAPI description. ## 2026-09-26: Duplicate pre-check, standardized buyer reasons - New optional endpoint `POST /api/v1/engine/dupe-check`: answers `duplicate_likely` for raw or hashed lead fields. Its own rate limit (30 per minute per key). - A buyer's refusal is translated into a standardized reason; the buyer's own answer is never passed through. - Reposting a lead (same search or same lead id) to an offer that already accepted it answers `DUPLICATE`. ## 2026-09-25: Affiliate API: search → results → submit - Every answer carries `status` (one lower-case set) and a sentence-case `reason` next to `code`. - Every result carries the same core: `icon_result_id`, `campaign_id`, offer, school, `tcpa_text`, `programs`, `form_fields`, `payout`, `expires_at`. - `payout` is your own payout for the offer. The submit answer reports the payout booked for the lead. - Results arrive incrementally: poll until `processing_done`, waiting `poll_after_ms`. - Per-key rate limits with HTTP 429, `RATE_LIMITED` and `Retry-After`.