DeX API
Everything you need to take a business from its first answer to real carrier quotes: start an application, show the form, add locations, drivers and the other repeating parts, and collect the quotes. Your applications covers listing them for a dashboard and taking them over, and the last chapter lists the standard questions every application asks.
- Before you start
- Build your app: step by step, for customers on your storefront and for your agents
- How to get a quote
- Displaying the form
- Creating sections
- Your applications
- Technical reference
- Standard questions
Before you start
| Setting | Details |
|---|---|
| Base URL | The host Bold Penguin gives you for each environment, followed by /v1: https://<api host>/v1. Every path in these docs is relative to it. |
| Version | This is version 1. Within it, fields, endpoints and events are only ever added, never removed or changed, so ignore what you don't recognise. Every response has an API-Version header (for example 1.0). See Versions. |
| Format | JSON request and response bodies. Send Content-Type: application/json. |
| OpenAPI spec | OpenAPI 3.1.0 (YAML) · OpenAPI 3.0.3 compatible (YAML) |
| Authentication | Authorization: Bearer <token> on every call except GET /health, GET /storefronts/{code}, POST /auth/token and POST /guest. If Bold Penguin gave you a client id and secret, get a token from POST /auth/token, for your client or, with an agent's username and password or SSO, for that agent; otherwise you get one when you start a guest application. |
| Storefront | Your storefront code and tenant id, from Bold Penguin. If captcha is on for your storefront, you also need a reCAPTCHA token from the browser. |
| IDs | Every id is a UUID. A malformed id is rejected with 400 before anything else happens. |
Build your app
This chapter walks through building a web app that takes a business from its first answer to carrier quotes, in two versions:
- Part 1: a guest application on your storefront. A customer comes to your site, answers the questions without an account, and sees their quotes. It runs entirely in the browser: no secret, no sign-in.
- Part 2: an agent application with sign-in. An agent signs in with their Bold Penguin username and password, sees their applications on a dashboard, starts new ones and quotes them for their customers.
Both use the same form renderer and the same quote pages. Part 1 builds them; Part 2 reuses
them and adds sign-in, the dashboard and taking applications over. The examples use fetch
and plain JavaScript, so they carry over to any framework.
// Used by every example below.
const API = 'https://<api host>/v1';
async function api(path, { token, method = 'GET', body } = {}) {
const res = await fetch(`${API}${path}`, {
method,
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const json = res.status === 204 ? null : await res.json().catch(() => null);
if (!res.ok) throw Object.assign(new Error(`${method} ${path}: ${res.status}`), { status: res.status, body: json });
return json;
}
Part 1: A guest application on your storefront
What you need from Bold Penguin: your storefront code, your tenant id, and, if captcha is on for your storefront, your reCAPTCHA site key.
Landing page GET /storefronts/{code} branding, captcha setting
Start POST /guest -> { token_id, token }
GET /tokens/{token_id} -> the application
Questions POST /application_forms/{id} a save per answer, as the customer gives it
POST /location, /driver, … more locations, drivers, … as pages ask
Quotes WebSocket /application_forms/{id}/events (or poll GET …/quote_requests)
Buy POST …/quote_requests/{quote_request_id}/select, then …/bind -> retrieve_url
Step 1. Load your storefront
Before the customer does anything, load your storefront's settings. No token is needed:
const storefront = await api(`/storefronts/${STOREFRONT_CODE}`);
Use it to brand the landing page and to know how to start the application:
| Field | Use it for |
|---|---|
tenant_id | The tenant to start the application in (step 2) |
enable_captcha | true: get a reCAPTCHA token before starting (step 2) |
agency_name, logo_url, primary_hex_color, accent_hex_color | Your header and colours |
phone, email, address, hours_by_day, timezone | "Talk to an agent" contact details |
landing_page_headline, landing_page_subheadline | The landing page's text |
show_quotes | Whether customers see prices on this storefront. When it's false, show a "we'll be in touch" confirmation at the end instead of the quotes page. |
footer_disclaimer_html, faq_html | Your footer and FAQ (already sanitized) |
A 404 means the code is wrong or the storefront isn't active.
Step 2. Start the application
Ask for a first answer or two on your landing page (the business name, the state, what it does), then start the application with them:
const recaptcha_token = storefront.enable_captcha
? await grecaptcha.execute(RECAPTCHA_SITE_KEY, { action: 'start' })
: undefined;
const { token_id, token } = await api('/guest', {
method: 'POST',
body: {
tenant_id: storefront.tenant_id,
source: 'storefront',
source_metadata: { storefront_code: STOREFRONT_CODE, ...(recaptcha_token ? { recaptcha_token } : {}) },
application_form: {
field_values: [{ code: 'mqs_business_name', value: 'Acme Carpentry LLC' }],
},
},
});
grecaptcha.execute is reCAPTCHA v3. With a v2 (checkbox) site key, send the widget's
grecaptcha.getResponse() instead. Either way the token works once, so get a new one if
starting fails.
token is the customer's Bearer token for every call from here on. A 403 means the storefront
code is wrong, the captcha failed (get a new token and try again), or the storefront doesn't
take new applications.
Then load the application, once, with token_id:
const { application_form: form } = await api(`/tokens/${token_id}`, { token });
sessionStorage.setItem('application', JSON.stringify({ id: form.id, token }));
Keep form.id and token. token_id works only once; from now on load the application by
its id. If the customer reloads the page, pick up where they were:
const saved = JSON.parse(sessionStorage.getItem('application') ?? 'null');
const form = saved && await api(`/application_forms/${saved.id}`, { token: saved.token });
Step 3. Follow the application live
Open the application's live updates now, so new pages, quotes and messages reach the customer without polling:
const ws = new WebSocket(`${API.replace(/^http/, 'ws')}/application_forms/${form.id}/events?token=${token}`);
ws.onmessage = (e) => {
const { event, data } = JSON.parse(e.data);
if (event === 'ready' || event === 'form.updated') render(data.application_form);
if (event === 'quote_request.updated') renderQuote(data.quote_request);
if (event === 'quote_request.removed') removeQuote(data.quote_request_id);
if (event === 'form.taken_over') showTakenOver(data); // an agent took the application over
};
ws.onclose = (e) => { if (e.code !== 4401 && e.code !== 4403 && e.code !== 4404) setTimeout(connect, 3000); };
Wrap it in a connect() function so it can reconnect: ready sends the whole application
again. When an agent takes the application over, form.taken_over arrives: the agent carries on
with a copy, so stop taking answers and tell the customer an agent will be in touch. The events are listed
in Live updates.
Step 4. Render the page to answer
The application is a list of pages, like a wizard. Show the first page that's open and not finished yet:
function currentPage(form) {
return form.pages.find((p) => p.active && !p.valid) ?? form.pages.at(-1);
}
Show every page's title in a progress bar: done (valid), current, and upcoming (not
active yet). Draw the current page from the top down:
- The form's messages with
location: null, as a banner, and the page's ownmessagesat the top of the page. - Each row by its
layout(Rows): one field, fields side by side, an address block, or a repeat row of locations, drivers and the like. - Each field by its
type(Fields), filled in with itsvalue, with itslabel,help,placeholder,requiredmarker andmessages.
Two kinds of field need a call of their own:
search(the business type): look up choices as the customer types, withPOST /choices, and send the chosen choice'salias.- Repeat rows (another location, another driver): the
add_labelbutton, whenadd_labelisn'tnull, adds an item withPOST /location,POST /driver, …, or an empty one withPOST /application_forms/{id}/{section}. Each entry's remove button callsDELETE /application_forms/{id}/{section}/{item_id}. Render from the application each call returns.
Check each field's validation as the customer types, and show errors once they've left a
field or pressed Next (Validity and errors).
Step 5. Save each answer
Save each answer as soon as the customer gives it, not when they press Next. An answer can add questions to the page they're on (say "Do you currently have insurance?" is "Yes": the current carrier's questions appear right under it), and the save's response is what has them. Saving answer by answer shows them while the customer is still on the page.
- Choices (
select,multiselect,radio,checkbox) and dates: save on change. - Text and numbers: save when the customer leaves the field, or after a short pause in typing (half a second or so), not on every keystroke.
- One save at a time. If the customer changes more while a save is out, send those in the
next save, with the
revisionfrom the response you just got. Saves sent at the same time can finish in either order.
Every save sends autocomplete: true and the revision of the application on screen:
let saving = Promise.resolve();
const queued = new Map(); // field id -> the field with its new value
function saveAnswer(field) {
queued.set(field.id, field);
saving = saving.then(async () => {
if (!queued.size) return;
const changed = [...queued.values()];
queued.clear();
const saved = await api(`/application_forms/${form.id}`, {
token,
method: 'POST',
body: {
application_form: {
// One entry per changed field; a field inside a location also sends its location_id.
field_values: changed.map((f) => ({ code: f.code, value: f.value, ...itemIdsOf(f) })),
autocomplete: true,
},
revision: form.revision,
},
});
form = saved;
render(saved);
if (saved.changes?.has_changes) toast(saved.changes.message);
});
return saving;
}
itemIdsOf(f) is the field's item id, when it has one: every *_id key on the field
(location_id, building_id, …), sent back as it came.
Render from each response, which is the whole application again:
- New questions on this page (
changes.fields_added) appear where the form puts them. Highlight them. Keep what the customer is typing in a field you haven't saved yet; don't overwrite it with the response's value. - A
422means an answer was rejected. Showerror.details.errorson the fields, orerror.message. - Next moves on once the page is
valid, after any save still out has finished: to the next page that'sactiveand notvalid. Saving is what adds pages: completing the Eligibility pages adds the locations, vehicles and carrier pages the answers call for. Don't keep your own list of pages; take it from each response.
Step 6. Show the quotes
Each carrier is asked for a quote as soon as its page is complete, so quotes start arriving
while the customer is still answering. When every page is valid and the application's
state is completed or quoted, move to your quotes page. If show_quotes is false for
your storefront, show your confirmation page instead and stop here.
Render the application's quote_requests, and update them from the live updates (or poll
GET /application_forms/{id}/quote_requests every few seconds):
request_status | Show |
|---|---|
unsent, sent, in_progress | The carrier's logo with a spinner |
completed | A quote card: carrier.name and carrier.logo_url, annual_price (and monthly_price), labels, and a Buy button when bindable |
referral | "The carrier needs to review your application" |
failed, timed_out | Leave it out, or say the carrier couldn't quote |
Quoting is finished when none is unsent, sent or in_progress (the application's state
is then quoted). Also show the form's messages with location: "quote_presentation" on
this page. See Collect the quotes for suppliers' options,
labels and calls to action.
The proposal. A completed quote's proposal, once it's there, is the carrier's PDF. Its
url needs the Bearer token, so download it with fetch rather than a plain link:
const res = await fetch(`${API}${quote.proposal.url}?disposition=inline`, { headers: { Authorization: `Bearer ${token}` } });
window.open(URL.createObjectURL(await res.blob()));
Step 7. Buy
When the customer picks a quote, record their choice and bind it:
await api(`/application_forms/${form.id}/quote_requests/${quote.id}/select`, { token, method: 'POST' });
const { retrieve_url } = await api(`/application_forms/${form.id}/quote_requests/${quote.id}/bind`, { token, method: 'POST', body: {} });
if (retrieve_url) window.open(retrieve_url, '_blank');
The customer finishes buying on the carrier's site. If the quote has calls_to_action, its
first one replaces the Buy button (Calls to action). When
retrieve_url is null, keep showing the quote until its request_status is bound or
binding_failed. Checkout inside your app isn't offered yet: every quote has
online_bind: false for now.
Checklist for Part 1
- The landing page loads the storefront, and gets a captcha token when it's on
- Starting saves the application's id and token, so a reload carries on
- Live updates reconnect after a drop
- Every row layout and field type renders, including repeat rows and search fields
- Each answer is saved as it's given, one save at a time, with
autocomplete: trueandrevision, and the page re-renders from the response, new questions included - Messages show as a banner, at the top of the page and with their question
- Quotes update as they arrive, with the proposal download and Buy
Part 2: An agent application with sign-in
Here the user is one of your agents, quoting for their customers. What you need from Bold Penguin: your client id and client secret, and a Bold Penguin login for each agent.
Sign in POST /auth/token grant_type=password -> { access_token, refresh_token, expires_in }
Dashboard GET /application_forms, /application_forms/counts
New POST /application_forms -> the new application
Open GET /application_forms/{id} (take it over first when it isn't theirs)
Questions the same as Part 1, steps 3 to 5
Quotes the same as Part 1, steps 6 and 7, plus checkout in your app
Step 1. Put a small server behind your app
An agent's sign-in gives a refresh token, and refreshing needs your client secret. Neither can go to the browser, so put a small server of your own (a backend for your app) in front of the sign-in calls:
POST /loginon your server takes the agent's username and password, callsPOST /auth/token, keeps the refresh token in the agent's server-side session, and returns only the access token and when it expires.POST /refreshon your server uses the session's refresh token to get a new access token.POST /logoutdrops the session.
The browser then calls the API directly with the access token, exactly as in Part 1.
Step 2. Sign the agent in
Your sign-in page posts the username and password to your server, which signs in:
// On your server
const signIn = await fetch(`${API}/auth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ grant_type: 'password', username, password, client_id: CLIENT_ID, client_secret: CLIENT_SECRET }),
});
if (signIn.status === 401) return reply.status(401).send({ error: 'Wrong email or password' });
const { access_token, refresh_token, expires_in, tenant_id } = await signIn.json();
session.refresh_token = refresh_token;
return { access_token, expires_at: Date.now() + expires_in * 1000 };
usernameis the agent's Bold Penguin username, for most agents their email.- A
401witherror.codeinvalid_grantis a wrong username or password. Show one message for both. - Send the password straight on over HTTPS, and don't store or log it.
- If your agents sign in to Bold Penguin through their company's identity provider, use SSO instead of a password; the rest of Part 2 is the same.
Show who's signed in with GET /user, using the access token: it returns the agent's
name, email, tenant and permitted_to_bind (whether they can buy policies). See
GET /user.
Step 3. Keep the token fresh
The access token lasts expires_in seconds, from the sign-in response. Refresh it a little before
expires_at, and on a 401, refresh once and retry the call:
// On your server: POST /refresh
const res = await fetch(`${API}/auth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ grant_type: 'refresh_token', refresh_token: session.refresh_token, client_id: CLIENT_ID, client_secret: CLIENT_SECRET }),
});
if (!res.ok) return reply.status(401).send(); // send the agent back to sign-in
const next = await res.json();
session.refresh_token = next.refresh_token; // each refresh token works once
return { access_token: next.access_token, expires_at: Date.now() + next.expires_in * 1000 };
When a refresh fails, send the agent to the sign-in page. Reopen the live updates socket with the new token when it changes.
Step 4. Build the dashboard
List the agent's applications, most recently updated first, with the headline counts:
const [list, counts] = await Promise.all([
api('/application_forms?state=all&sort=updated_at&per_page=20&page=1', { token }),
api('/application_forms/counts', { token }),
]);
Each row in list.application_forms has what a table needs (Your applications):
| Column | From |
|---|---|
| Business | summary.business_name, summary.business_type |
| Contact | summary.first_name, summary.last_name, summary.email, summary.phone |
| Coverage | summary.coverage_types, summary.coverage_start_date |
| Status | state, percent_completed, and bind.status as a badge |
| Quotes | quote_requests: how many are completed, and the lowest annual_price |
| Owner | owner.first_name, owner.last_name, with a "Not yours" badge when not_owner is true |
Send state=all: without a state, the list leaves out applications that were just started
(incomplete), including the ones the agent creates in step 5. Add a search box (q), filters
(state, owner=current_user, labels=has_bound_policy)
and paging (there are more pages while page × per_page < total). The list can lag a moment
behind the latest save, so after saving, show the application from the save's response
rather than reloading the list.
Step 5. Start a new application
The New application button starts one owned by the agent. Send any answers you already have:
const form = await api('/application_forms', {
token,
method: 'POST',
body: {
application_form: {
field_values: [{ code: 'mqs_business_name', value: 'Acme Carpentry LLC' }],
autocomplete: true,
},
},
});
The response (201) is the new application, in the usual shape. There's no token id to
load: go straight to Part 1, step 3 with form and
the agent's access token.
Step 6. Open an existing application
Clicking a dashboard row loads the application:
const form = await api(`/application_forms/${id}`, { token });
When form.not_owner is false, it's the agent's: carry on from
Part 1, step 3.
When form.not_owner is true, a customer or another agent owns it. Show it read-only, with
a Take over button, since changing it, and buying its quotes, needs it to be the agent's:
const { application_form_id, application_form } = await api(`/application_forms/${id}/take_over`, { token, method: 'POST' });
// Carry on with the copy, under its new id.
history.replaceState(null, '', `/applications/${application_form_id}`);
render(application_form);
Taking over makes a copy owned by the agent, with the same answers; see
Take over an application. On the live updates,
form.taken_over means someone else took over the application the agent has open: stop
editing it, and open new_application_form_id if taken_over_by.id is the agent's own id
(id from GET /user).
Step 7. Answer, quote and buy
From here the agent's app works like the customer's: follow the application live, render the current page, save it, and show the quotes, as in Part 1, steps 3 to 7, with the agent's access token. The differences:
- Quotes on someone else's application have
not_owner: trueand can't be bought. Show Take over to buy in place of Buy (Someone else's quotes). - Checkout in your app isn't offered yet: every quote has
online_bind: falsefor now, so bind every quote as in Part 1. When it's turned on, quotes withonline_bind: truecan be bought and paid for without leaving your app, with the steps in Checkout in your app. - Calls to action can send a quote to the carrier's underwriters instead of binding it
(
send_to_underwriting); see Calls to action. - There's no
show_quotessetting: agents always see the prices.
Step 8. Sign out
Close the live updates socket, forget the access token in the browser, and call your server's
POST /logout to drop the refresh token.
Checklist for Part 2
- The client secret and refresh tokens stay on your server
- A wrong password shows one message; the password is never stored or logged
- Tokens refresh before they expire and after a
401, and a failed refresh signs the agent out - The dashboard searches, filters and pages, and flags applications that aren't the agent's
- New application opens the new application straight away
- Opening someone else's application offers Take over, then carries on with the copy
- Quotes offer Buy for the agent's own quotes and Take over to buy for someone else's
How to get a quote
There is no "get a quote" call. Bold Penguin quotes each carrier by itself, as soon as that
carrier's page of the application is complete. Your job is to get every page answered and
saved with autocomplete: true, then watch the application's quote_requests fill in.
1. POST /guest start the application -> { token_id, token }
2. GET /tokens/{token_id} load it -> the form, as pages to show
3. POST /application_forms/{id} save a page's answers with autocomplete: true
... repeat for each page; add locations, drivers, ... as the pages ask for them
4. GET /application_forms/{id}/quote_requests
poll until the quotes and proposals are in
(or follow them with live updates)
5. POST /application_forms/{id}/quote_requests/{quote_request_id}/select
mark the quote the user picks (optional)
6. POST /application_forms/{id}/quote_requests/{quote_request_id}/bind
buy it
1. Start the application
{
"tenant_id": "<your tenant id>",
"source": "storefront",
"source_metadata": { "storefront_code": "<your storefront code>" }
}
The response is 201 with { "token_id": "…", "token": "…" }. Send token as the Bearer
token on every call that follows. If you already know some answers (the business name, the
state), you can send them now as application_form.field_values in the same format as a
save.
2. Load the form
{
"id": "…",
"application_form": {
"id": "<form id>",
"state": "incomplete",
"cable_token": "…",
"pages": [ … ],
"quote_requests": []
}
}
Keep application_form.id: every other call uses it. Each token id works only once; the
next one is in cable_token. You won't normally need it again, because from here on you load
the form by its id.
3. Answer the pages
Show the first page (see Displaying the form), then save each answer as the user gives it (see Saving):
{
"application_form": {
"field_values": [
{ "code": "mqs_business_name", "value": "Acme Carpentry LLC" },
{ "code": "CoverageTypes", "value": ["CGL"] }
],
"autocomplete": true
}
}
The response is the whole updated form. Render from it: it has the saved values, any errors,
each page's valid, and any pages or questions the answers turned on.
Always send autocomplete: true. It tells Bold Penguin to mark every page whose required
answers are all in as complete, and completing pages is what moves the application forward:
- Completing the Eligibility pages (the first page set, see Standard questions) adds the next pages, depending on the answers: locations and buildings, vehicles and drivers, and the carriers' own pages.
- Completing a carrier's page sends that carrier a quote request.
A page completes only when every required question on it, and on every page before it, is
answered. Use each page's valid to know when to let the user move on.
When a page asks for more than one of something (another location, a driver, a building),
add it with POST /location, POST /driver, and so on. See
Creating sections.
4. Collect the quotes
Quotes arrive in the background. Follow them with live updates, or every few seconds, load the quote requests:
{
"application_form_id": "<form id>",
"state": "quoted",
"quote_requests": [
{
"id": "…",
"request_status": "completed",
"carrier": { "name": "…", "logo_url": "https://…", "underwriter": { "full_name": "…", "naic_number": "…", … } },
"annual_price": 1234.0,
"monthly_price": 112.5,
"quote_number": "…",
"effective_date": "2025-07-01",
"labels": ["ready_to_bind", "taxes_and_fees_included"],
"products": [{ "id": "…", "name": "General Liability", "coverage": { … } }],
"calls_to_action": [{ "trigger_type": "programmatic_bind", "action_type": "programmatic_bind_workflow", "button_text": "Buy now", "content": null }],
"bindable": true,
"rate_type": "ready_to_bind",
"online_bind": false,
"proposal": { "id": "…", "filename": "Quote_123.pdf", "attachment_type": "quote_pdf", "content_type": "application/pdf", "url": "/attachments/…" },
"retrieve_url": "https://…"
}
],
"cable_token": "…"
}
This shows the main fields; every quote request has all the fields in the quote request object.
request_status | Meaning |
|---|---|
unsent, sent, in_progress | The carrier is still working on it. Keep polling. |
completed | A quote. The price is in annual_price (and monthly_price when the carrier offers monthly payment). |
referral | The carrier needs to review the application before it can quote. |
failed | The carrier declined or couldn't quote. translated_messages says why, when the carrier gave a reason. |
timed_out | The carrier didn't answer in time (about two minutes). |
binding | The policy is being bought. |
pending_payment | The purchase is waiting for payment (for example, a payment link was sent). |
binding_failed | The carrier couldn't complete the purchase. |
bound | The policy was bought. |
Stop polling when no quote request is unsent, sent or in_progress, and the completed
quotes you want to show have their proposal. The form's state is then quoted.
state | Meaning |
|---|---|
incomplete | Just started |
in_progress | The user has answered something |
completed | Every page is complete; quote requests are out |
quoted | Every quote request has finished |
binding, bound | A policy is being bought, or was bought |
The same quote_requests list is also in the form (GET /application_forms/{id}), and one
quote request can be refreshed on its own with
GET /application_forms/{id}/quote_requests/{quote_request_id}.
products lists the products (coverages) the quote request is for, each with its name and
coverage details.
Suppliers. Some carriers quote through several markets (suppliers) at once. Then
options has one entry per supplier with just what differs: id, supplier (code, name,
logos), quote_number, annual_price, semiannual_price, price_details, retrieve_url
and products. Everything else is the quote request's. You can show each option as its own
quote card. For a single quote options is [], and supplier names its supplier if it has
one.
The proposal. proposal is the carrier's quote document, a PDF, once the carrier has
sent it; null until then (and for carriers that don't send one). It sometimes arrives a
little after the price, so keep polling a completed quote until proposal is set. Download
it with its url, sending your Bearer token:
GET /attachments/{id} the file, to save
GET /attachments/{id}?disposition=inline the file, to show in the browser
attachments lists every document for the quote in the same form: the proposal, and
sometimes a specimen policy or premium finance agreement. Once the policy is bought, it has
the policy documents instead.
To buy a quote, bind it when the user picks one with bindable: true (you can
select it first to record their choice):
{}
{ "quote_request_id": "…", "retrieve_url": "https://…" }
Send the user to retrieve_url (a new tab works best) to finish buying on the carrier's
site. The response also has quote_request, the quote after binding.
- Only quotes with
bindable: truecan be bought.bindablemeans the user can press Buy now: the carrier has a purchase link for this quote and the application is theirs (it'sfalsewhen someone else owns it). For guests it also depends on your storefront's settings. A quote that isn't bindable can only be shown. - Someone else's quote has
not_owner: true, and can't be bought until the user takes the application over. See someone else's quotes. - Is the price final?
rate_typesays:ready_to_bindmeans the quote can be bought at this price;indicativemeans it's an estimate and the carrier may ask more questions or review the application first;nullmeans the carrier didn't say, so don't present the price as final.bindabledoesn't tell you this. - When the quote has a call to action, its first
calls_to_actionentry is the button to show instead of the usual one. See calls to action. - When
retrieve_urlisnull, the purchase continues inside Bold Penguin: keep loading the quote request until itsrequest_statusisbound(orbinding_failed). The policy documents then appear in itsattachments.
Checkout in your app. Checkout inside your app isn't offered yet: every quote has
online_bind: false for now, so bind every quote as above. When it's turned on, a quote with
online_bind: true (a call to action with trigger_type: programmatic_bind and action_type:
programmatic_bind_workflow, and nothing else) can be bought and paid for without leaving your
app, with the steps in Checkout in your app; only the quote's owner can
check out.
Select a quote
When the user chooses a quote, mark it as their pick, as Bold Penguin's own quote pages do:
POST /application_forms/{id}/quote_requests/{quote_request_id}/select
Authorization: Bearer <token>
200 with { quote_request_id, quote_request }, the quote read again. Bold Penguin records
the quote's carrier as the user's choice, and on a guest application the quote's focused
becomes true. selected is set later, when the quote is bought (bind, or starting checkout
in your app). Only the quote's owner can select it: 403 otherwise.
Someone else's quotes
A quote belongs to the user who owns the application. When it isn't the user's, it has
not_owner: true: show it, but it can't be bought. Its bind URL and bind options are
null: retrieve_url (and each option's), calls_to_action, actions, online_bind,
plans and payment_methods. bindable is false, and bind, select and every step of
checkout in your app answer 403 without changing anything.
Ask the user to take the application over to finish buying, whichever way the quote would be bought: on the carrier's site, on Bold Penguin's bind page, or with checkout in your app. Show a "Take over to buy" button in place of Buy, and when it's pressed:
- Take the application over. This makes a copy owned by the user.
- Carry on with the copy. Its quotes are the user's, with their bind URL and bind options.
Pinned quotes are copied over; the others are requested again once the copy's pages are
complete, so wait for them as usual. If a page has
valid: false, the user finishes it first. - Bind the quote there. (Checkout in your app isn't offered yet: every quote has
online_bind: falsefor now.)
not_owner is null when Bold Penguin couldn't confirm who the user is. The bind fields are
null then too, and bind, select and the checkout steps answer 503: try again.
If the user changes an answer on a page that was already complete, that page opens up
again and the carriers' quotes that depended on it are withdrawn. Save again with
autocomplete: true and they are re-quoted.
Which pages and carriers you get depends on your storefront. Bold Penguin decides which carriers can quote from the business's state, what it does (
Naics) and the coverages it wants (CoverageTypes), among the carriers your storefront works with. The form always shows exactly what is needed: when there are no more pages to answer, you're done.
Quote labels
labels is a list of strings describing the quote:
| Label | Meaning | What to show |
|---|---|---|
ready_to_bind | The quote can be bought as quoted. | “Bindable quote” |
rate_indication | An estimate, not a final price: the carrier may ask more questions or review the application before the quote can be bought. | “Rate indication”, with a note that the carrier may need more information |
taxes_and_fees_included | The price already includes taxes and fees. | “Includes taxes and fees” next to the price |
is_additional_coverage | An add-on coverage offered alongside the main quotes (for example, extra coverage offered with a business owner's policy), not an alternative to them. | List it after the main quotes, and leave it out of price comparisons |
underlying_ready_to_bind | The quote builds on an underlying policy, and that underlying policy's quote is ready to be bought. | Nothing needed; Bold Penguin's own apps don't show it |
- A quote can have more than one label, for example
["ready_to_bind", "taxes_and_fees_included"]. If a quote has bothrate_indicationandready_to_bind, treat it as a rate indication. []means no label. Labels are only filled in when they're turned on for your account; ask your Bold Penguin integration lead. Until then every quote has[].- Labels matter on
completedquotes. Ignore any label you don't recognise: new ones can be added. rate_typeis worked out from these labels for you:indicative,ready_to_bind, ornull.
Calls to action
calls_to_action is what the quote's button should do when buying it isn't the usual bind.
Bold Penguin sets these up per carrier for your storefront, and a quote lists only the ones
that apply to it right now. Most quotes have []: use the usual bind.
{ "trigger_type": "not_permitted_to_bind", "action_type": "dialog", "button_text": "Contact us to buy", "content": "Call 555-0100 to finish buying this policy." }
Show the first entry as the quote's button, labelled with button_text. What it does depends
on action_type:
action_type | What to do |
|---|---|
programmatic_bind_workflow | Bind the quote as usual. Checkout inside your app isn't offered yet (online_bind is false for now); with trigger_type: bridge_to_programmatic_bind, bind returns your storefront's bind page as retrieve_url: open it. |
send_to_underwriting | Bind with { "action_type": "send_to_underwriting" } to send the application to the carrier's underwriters for review instead of buying. |
dialog | Show content in a message. There's nothing to bind. |
wholesale_workflow, north_peak_workflow, data_collection_workflow | Placement through your wholesaler or Bold Penguin's placement desk. Not available through this API; show button_text as a note. |
pin_quote | Not used through this API; ignore it. |
trigger_type says why it's there:
trigger_type | It's there when |
|---|---|
programmatic_bind | The owner can buy policies (an agent, or your storefront's customer when the storefront lets them buy), the carrier is set up for checkout in your app, the quote is ready_to_bind, and any excess and surplus agreement is signed |
bridge_to_programmatic_bind | The same, for storefronts that finish checkout on Bold Penguin's bind page; bind returns that page as retrieve_url |
not_permitted_to_bind | The owner can't buy from this carrier here, and the quote hasn't gone to a wholesaler |
referral | The quote is a referral: the carrier has to review the application first |
sent_to_wholesaler | The quote has already been sent to your wholesaler |
send_to_north_peak | Your storefront has a wholesaler for placement reviews |
excess_and_surplus | An excess and surplus lines quote whose agreement isn't signed yet |
Ignore any action_type or trigger_type you don't recognise.
The quote request object
A quote request is the same object everywhere: in the form's quote_requests, from the quote
request endpoints, in bind's quote_request, in each row of your applications list, and in
quote_request.updated events. It always has every field below, in this order. A field with
no value is null, and a list with nothing in it is [], except that someone else's quote
has null bind options (someone else's quotes). Ignore fields you
don't recognise: new ones can be added after these.
| Field | Meaning |
|---|---|
id | The quote request's id |
application_form_id | The application it belongs to |
request_status | Where the carrier is with it; see the table above |
active | false once it's been withdrawn (an answer it depended on changed) |
declined | The carrier declined to quote |
expired | The quote is past its expiry date |
stale | The application changed after the quote came back, so the price may no longer hold |
quote_number | The carrier's quote number |
account_number | The carrier's account number, when it gives one |
effective_date | When the policy would start |
started_at, finished_at | When the carrier was asked, and when it answered |
created_at, updated_at | Timestamps |
owner_id, tenant_id | The user and tenant it belongs to |
not_owner | true when the quote isn't the user's: take the application over to buy it. null when that couldn't be checked. See someone else's quotes. |
carrier | The carrier: id, code, name, logo_url, logo_url_small, labels, appointment_type, bind_payment_strategy, and underwriter (below) |
supplier | For a single quote from a supplier, { code, name, logo_url, logo_url_full }; see Suppliers in Collect the quotes |
products | The products (coverages) it's for: id, name, choice_id, from_name, coverage and timestamps |
options | The suppliers' quotes, when the carrier gave more than one; see Suppliers in Collect the quotes |
annual_price, monthly_price, semiannual_price | The premium per term. monthly_price only when the carrier offers monthly payment; semiannual_price only for six-month terms. |
downpayment, number_of_installments | For installment payment: the first payment, and how many follow |
total_installment_premium | For integrated bind quotes, the total paid over all installments; otherwise null |
surcharge | Any surcharge the carrier added |
price_details | The price broken down: new_total_premium, and premiums, taxes and fees as { description, amount } lists, plus disclaimer_html |
plans | The payment plans the carrier offers, for integrated bind; null when the quote isn't yours (someone else's quotes) |
selected_plan_id | The plan chosen during checkout |
payment_methods | For carriers paid online through Bold Penguin, the ways to pay; otherwise null, including when the quote isn't yours |
labels | What kind of quote it is; see quote labels |
rate_type | ready_to_bind, indicative or null: whether the price is final; see To buy a quote in Collect the quotes |
exclusions | What the carrier excluded from cover, as the carrier worded it |
translated_messages | The carrier's messages, for example why it declined, in Bold Penguin's wording |
bindable | This user can press Buy now; see To buy a quote in Collect the quotes. false when the quote isn't yours. |
online_bind | Whether it can be bought and paid for inside your app. false on every quote for now: checkout inside your app isn't offered yet. null when the quote isn't yours (someone else's quotes) |
online | The carrier takes purchases online |
permitted_to_bind | The application's owner is allowed to buy policies |
calls_to_action | What the quote's button should do; see calls to action; null when the quote isn't yours (someone else's quotes) |
actions | For carriers that bind in steps, the next step's choices; null when the quote isn't yours (someone else's quotes) |
retrieve_url | The carrier's page for this quote; null when the quote isn't yours (someone else's quotes) |
prohibited_operations | Business activities the carrier won't cover, as the carrier worded them |
customizable | Its coverage limits can be changed by answering its product's questions again |
order, score | Bold Penguin's sort order and ranking score for the quote |
recommended, preferred | Bold Penguin recommends it; the carrier is one of your storefront's preferred carriers |
selected, consumer_selected, focused, pinned | It's the quote being bought (set by bind or checkout in your app); the customer chose this carrier; the user picked it with select (guest applications); it's pinned to the top |
satisfies_application_form_product_request | It covers every product the application asked for |
proposal | The carrier's quote document; see The proposal in Collect the quotes |
attachments | Every document for the quote; see The proposal in Collect the quotes |
policy | Once bound, the policy: policy_number, effective_date, risk_company, policy_details and the plan paid with |
carrier.underwriter is the insurance company that writes the policy:
{ full_name, title, naic_number, phone_number, email, street1, street2, city, state, zipcode }
(any of them can be null), or null when the carrier has none on file. Show it where a
policy's legal paper is named, for example next to the price or on a confirmation page.
Displaying the form
Every call that returns the application returns it the same way, so one renderer covers
the whole flow. The form is pages, made of rows, made of fields. A renderer only
needs to switch on each row's layout and each field's type.
{
"id": "<form id>",
"state": "in_progress",
"reference_id": "LB5-4NX-CNK",
"pages": [
{
"id": "…",
"title": "General information",
"active": true,
"valid": false,
"warning": null,
"messages": [],
"rows": [
{ "layout": "inline", "label": null, "fields": [ { "code": "mqs_first_name", … }, { "code": "mqs_last_name", … } ] },
{ "layout": "stack", "label": null, "fields": [ { "code": "mqs_business_name", … } ] },
{ "layout": "address", "label": "Primary business address", "fields": [ … ] }
]
}
],
"quote_requests": [],
"messages": [],
"cable_token": "…",
"revision": "r1.…"
}
revision identifies what's on the pages; send it with your next save to learn which
questions it added (see New questions after a save).
Pages
Show pages in order, one at a time, like a wizard.
| Field | Use it for |
|---|---|
title | The page heading, and its name in your progress bar |
active | Whether the user can fill it in yet. A page becomes active once every page before it is complete. Show inactive pages as upcoming, not editable. |
valid | Whether every field on the page passes. Enable Next only when it's true. |
warning | null, or a message to show next to the title when an item on the page has questions left on an earlier page. See below. |
messages | Messages to show at the top of the page. Usually []. |
rows | What to draw, top to bottom |
New pages appear in the response to the save that unlocked them. Re-render the page list after every save; don't cache it.
Adding an item can add questions to earlier pages: a location added from the Buildings page
also has its questions on the Locations page. A page that shows an item with questions left
to answer on an earlier page has a warning:
"warning": {
"message": "Finish answering the questions on Locations, then continue.",
"pages": [{ "id": "…", "title": "Locations" }],
"items": [{ "section": "locations", "location_id": "…", "label": "New Location" }]
}
Show message next to the page's title, and link to each page in pages. items are the
unfinished items on this page. Until their earlier questions are answered, the page leaves
out those items' read-only fields, which show values copied from them (a location's address
on the Buildings page). They come back once those questions are answered.
Rows
layout | Draw |
|---|---|
stack | One field on its own line |
inline | The row's fields side by side (first and last name, phone and email) |
address | An address block: street, suite, city, state, ZIP. Use an address autocomplete if you have one. |
vehicle, driver, coverage, accordion, auto_collapsed_accordion, accumulate_percentage, stepper | The row's fields as one component of that kind. Drawing them as a plain group of fields also works. |
repeat | A section the user can add to: locations, buildings, drivers, … See Repeat rows. |
label is a heading to show above the row, or null. Only what the user should see is
included: questions switched off by earlier answers, and ones Bold Penguin fills itself, are
left out. So are an item's remove button (it's the entry's remove_label, see
Repeat rows) and questions that only hold data for the form.
Fields
{
"id": "<answer id>",
"code": "mqs_state",
"type": "select",
"format": null,
"label": "State",
"help": null,
"placeholder": null,
"required": true,
"readonly": false,
"value": "OH",
"options": [
{ "id": "…", "label": "Ohio", "value": "OH" },
{ "id": "…", "label": "Texas", "value": "TX" }
],
"validation": null,
"errors": [],
"valid": true,
"messages": []
}
type | Draw | format | Send as value |
|---|---|---|---|
text | Text input | email, url, zipcode, vin, drivers_license, or null | string |
number | Number input | year or null | number |
masked | Input with a mask | ssn, fein, phone, currency, percentage | string or number |
date | Date picker | month_year or null | YYYY-MM-DD |
select | Dropdown | an option's value | |
multiselect | Checkboxes or a multi-select | array of option values | |
radio | Radio buttons | an option's value | |
search | Search-as-you-type (the business type) | business_class | the chosen choice's alias, see below |
address | Nothing: Bold Penguin builds it from the row's other fields | don't send it | |
checkbox | A checkbox | "1" when checked, null to clear it | |
label | Text: show its label (and help) as a note | nothing to send |
Other types may appear as Bold Penguin adds them: skip a field whose type you don't recognise.
valueis the saved answer, on every field that has been answered, whatever row it's in (side by side, an address, or an item such as a location). Fill the input with it, and send it back unchanged if the user doesn't change it. It'snullwhen unanswered ([]for amultiselectwith options).labelis the question. Showhelpunder it andplaceholderin the input.options[].labelis what to show;options[].valueis what to send.validationhas the field's rules when it has any:minandmax(for numbers the value, for text its length),min_dateandmax_date,pattern(a regular expression) andmessage(what to say when a rule fails). Check them as the user types.readonlyfields show a value the user can't change. An item's read-only fields are left out while the page has awarningabout that item.messagesare messages to show with the question, above its input.
Search fields. For type: "search", look up choices as the user types:
{ "application_form_id": "<form id>", "answer_id": "<field id>", "filter": "carpentry", "per_page": 10 }
Each choice has a value (to show) and an alias (the code to send, e.g. "238350"). Once
saved, the field's value is the chosen choice, so show it as selected when the page loads.
Messages
As the application is answered, it can pick up messages for the user, such as "Owner/officer information is optional for quoting, but will be required to bind." when workers' compensation is chosen, or a note that the policy start date was moved. Each is returned where it belongs:
| Where | Show it |
|---|---|
application_form.messages with location: null | As a banner above the pages. Showing only the latest one is fine. |
application_form.messages with location: "quote_presentation" | On your quotes page |
A page's messages | At the top of the page |
A field's messages | With the question, above its input |
{
"id": "…",
"code": "wc_location_owner_requirement",
"level": "info",
"priority": false,
"html": "Owner/officer information is optional for quoting, but will be required to bind.",
"text": "Owner/officer information is optional for quoting, but will be required to bind.",
"created_at": "…",
"updated_at": "…"
}
- Style each message by
level:info,warningorerror. Messages withpriority: truecome first in each list. htmlcan contain links and formatting. Sanitize it before inserting it as HTML, or showtext, the same message without markup.codenames the message (nullfor some), if you want to use your own wording.- Messages come and go as answers change. Render them from every form response, and from
form.updatedon the live updates socket, which is sent when one is added, changed or removed. They never count as new questions inchanges.
Validity and errors
validistruewhen the field's value passes. A required field needs a value; an optional field that's empty is valid.errorslists what fails:required,pattern,min,max,minDate,maxDate, followed by any message Bold Penguin returned. Show the field'svalidation.messageif it has one.- A required field that hasn't been answered reports
requiredstraight away. Show errors once the user has touched the field or pressed Next, not when the page first loads. - A page's
validistruewhen all its fields are. It uses the same rules as Bold Penguin's own forms.
Saving
Save each answer as the user gives it: choices and dates on change, text and numbers when the
user leaves the field (or after a short pause in typing). Send autocomplete: true and the
revision, then re-render from the response. An answer can add questions to the page the user
is on, and saving it straight away shows them while the user is still there; saving only when
they press Next would show them only after they tried to leave. Send one save at a time:
anything changed while a save is out goes in the next one, with the revision from the latest
response. A save can carry several answers when they change together.
{
"application_form": {
"field_values": [
{ "code": "mqs_state", "value": "OH" },
{ "code": "mqs_loc_address_1", "value": "1 Main St", "location_id": "<location id>" }
],
"autocomplete": true
}
}
For each field send { "code": field.code, "value": value }. A field inside a section item
also carries the item's id (for example location_id); send it back under the same key.
A 422 means Bold Penguin rejected an answer. error.details.errors lists what's wrong, when
it's known; show it on the fields and let the user fix them. See Errors.
New questions after a save
An answer can add questions: to the page the user is on (say they pick a coverage that
needs more detail), or as whole new pages. To tell the user what appeared, send back the
revision of the form you rendered from with the save:
{
"application_form": {
"field_values": [ { "code": "CoverageTypes", "value": ["CGL", "AUTOB"] } ],
"autocomplete": true
},
"revision": "<revision from the form you rendered>"
}
Every response that returns the form has a revision. Keep the one from the form on screen
and send it with each save. The save's response then has changes, comparing the two:
{
"has_changes": true,
"pages_added": [ { "id": "…", "title": "Vehicles", "fields": 12 } ],
"fields_added": [
{
"page_id": "…",
"page_title": "Business details",
"id": "…",
"code": "mqs_number_of_employees",
"label": "How many employees?",
"required": true
}
],
"fields_removed": 0,
"message": "1 new question was added to Business details. 1 new page was added: Vehicles."
}
| Field | Meaning |
|---|---|
has_changes | Whether anything appeared or went away |
fields_added | New questions on pages that were already there. id is the new field's id, so you can highlight it. A question inside an item also has the item's id (location_id, …). |
pages_added | Pages that weren't there before, with how many questions each has |
fields_removed | How many questions went away, because an answer switched them off |
message | A sentence to show the user, or null when nothing changed |
When has_changes is true, show message (a banner or a toast works) and highlight the
fields in fields_added. Because each answer is saved as it's given, new questions on the
page the user is on appear while they're still there; the page isn't valid until they're
answered. Without a revision, changes is null.
POST /location, POST /driver and the rest take revision too.
Repeat rows
A part of the form the user can add more of is a repeat row, with one entry per item added
so far:
{
"layout": "repeat",
"section": "locations",
"item": "location",
"label": "Locations",
"add_label": "Add Another Location",
"entries": [
{ "location_id": "<id>", "label": "1 Main St, Columbus, OH 43215", "remove_label": null, "rows": [ … ] },
{ "location_id": "<id>", "label": "New Location", "remove_label": null, "rows": [ … ] }
]
}
Show the row's label as the section's heading. Draw each entry's rows as a card or a
collapsible block, with the entry's label as its heading, and put an add_label button
below them that adds an item (see Creating sections). Give each entry
a remove control too (see Remove an item), labelled with the entry's
remove_label when it has one ("Delete Additional Insured") and your own text ("Remove")
when it's null. The remove button is never one of the entry's fields.
Sections inside an item work the same way. Each location's rows can hold its own buildings,
gl_classifications or jobs row, and every building, classification or job class added to
it is a separate entry with its own id (gl_classification_id, job_id) and
remove_label. Remove one with its own section and id, e.g.
DELETE /application_forms/{id}/gl_classifications/{gl_classification_id}. The form shows
"Remove Classification" only once a location has more than one classification. Until then
remove_label is null, and the classification can still be removed.
When add_label is null, don't show an add button: items of that section are added on
another page. The Buildings page, for example, lists each location to hold
its buildings, so there the locations row has no add button and each location's
buildings row has "Add Another Building".
The add button is offered even where some carriers limit the number of items (for example 20 locations).
Draw rows in the order they come, the add button with its repeat row. That's where Bold Penguin's own form puts them: a location's job classes, then "Add Another Job Class", then the location's own payroll fields.
Use label rather than numbering entries ("Location 1", "Location 2"): numbers change as
items are added, and on the Buildings or Classifications pages they don't say which location
is meant. For a location, label is its full address once the address is answered, and
"New Location" until then. It's updated in every response, so refresh it after each save.
Entries can contain repeat rows of their own, such as the buildings inside each location.
Live updates
Instead of polling, open a WebSocket to the application and render from what it sends:
const ws = new WebSocket(`wss://<api host>/v1/application_forms/${formId}/events?token=${token}`);
ws.onmessage = (e) => {
const { event, data } = JSON.parse(e.data);
// …
};
Use the same Bearer token as for the other calls (as ?token=, since browsers can't send
headers on a WebSocket). Every message is { event, application_form_id, data, sent_at }:
event | data | What to do |
|---|---|---|
ready | { application_form } | Render the form. Sent once connected. |
form.updated | { application_form, changes } | Re-render: application_form is the whole application as it is now, including questions a save just added. changes.message says what's new, as after a save. Also sent when the application's owner changes, so not_owner stays current, and when its messages change. |
form.state_changed | { state, previous_state } | E.g. show that quoting is done at quoted |
form.progress | { percent_completed } | Update a progress bar |
quote_request.updated | { quote_request } | Update that quote |
quote_request.status_changed | { quote_request_id, request_status, previous_request_status } | E.g. show a new quote at completed |
quote_request.proposal_available | { quote_request_id, proposal } | Offer the proposal download |
quote_request.removed | { quote_request_id } | Remove that quote; an answer change withdrew it |
bind.status_changed | { quote_request_id, status, previous_status, policy } | Show the purchase's progress: binding, pending_payment, then bound (with policy) or binding_failed |
item.added, item.removed | { section, <item>_id, <parent>_id? } (the parent's id for a nested item, such as a building's location_id) | A location, building, … was added or removed |
bind.started, bind.review_started, bind.review_cancelled, bind.submitted | { quote_request_id, … } | A purchase step was taken, for example in another tab |
form.taken_over | { new_application_form_id, taken_over_by } | Someone took the application over: stop editing, and open the new one if it's yours |
process.updated, notification | Background checks and notices; optional | |
error | { code, message } | The connection is closing; see below |
Closing. 4401 means the token is missing or expired, 4403 the token's user can't
follow this application, 4404 there's no such application. For 4502 or a dropped
connection, reconnect after a few seconds: ready sends the current application again.
Creating sections
Some parts of an application repeat: a business can have several locations, each location several buildings, a commercial auto policy several vehicles and drivers, and so on. Each is a section, and each location, building or driver the user adds is an item in it.
- Every item has an id named after it:
location_id,building_id,driver_id. The same key appears on the repeat row's entries, on every field inside the item, and in your requests. - Some items exist already. For sections such as locations, buildings, drivers and
vehicles, Bold Penguin creates the first item for you, when the application starts or when
its parent is added. Answer it through
POST /application_forms/{id}, using the id from the repeat row'sentries. Only add items for the second one onwards. - Nested items need their parent. A building belongs to a location and a violation to a
driver: send the parent's id (
location_id,driver_id) when you add one.
Add an item with its answers
One call creates the item and saves its answers. Each section has its own endpoint, named after its item.
{
"application_form_id": "<form id>",
"field_values": [
{ "code": "mqs_loc_address_1", "value": "1 Main St" },
{ "code": "mqs_loc_city", "value": "Columbus" },
{ "code": "mqs_loc_state", "value": "OH" },
{ "code": "mqs_loc_zip_code", "value": "43215" }
]
}
{ "location_id": "<new location id>", "application_form": { … } }
A building for that location:
{
"application_form_id": "<form id>",
"location_id": "<location id>",
"field_values": [
{ "code": "mqs_bldg_desc", "value": "Warehouse" },
{ "code": "mqs_bldg_year_built", "value": 1998 }
]
}
Send only questions from that section: every answer in the call goes to the new item. If the answers can't be saved, the item is removed again, so a failed call never leaves an empty item behind.
Add an empty item
To add an item and answer it later, for example when the user clicks the add button:
POST /application_forms/{id}/locations -> { "section": "locations", "location_id": "…" }
POST /application_forms/{id}/buildings { "location_id": "<location id>" } -> { "section": "buildings", "building_id": "…", "location_id": "…" }
The path ends with the repeat row's section. Then load the form to show the new entry, and
save its answers with POST /application_forms/{id}, sending the item's id on each answer.
Remove an item
Any item in any section can be removed, for example when the user clicks a remove button on its entry:
DELETE /application_forms/{id}/locations/{location_id}
DELETE /application_forms/{id}/buildings/{building_id}
The path ends with the repeat row's section and the entry's id.
{ "section": "locations", "location_id": "…", "application_form": { … } }
- Items inside it are removed too: removing a location removes its buildings, classifications and jobs, and removing a driver removes its violations. Ask the user to confirm first; a removed item can't be brought back.
- Re-render from
application_form: it's the application without the item. Send the form'srevision(?revision=…, URL-encoded) to getchangesas after a save. - The section must match: removing a location through
driversis a400and removes nothing, when the item has answers on the form (which is how its section is known). Always use the item's ownsection. - Keep one item where the application needs one. Bold Penguin creates the first location, building, driver and vehicle for you; removing the last one leaves the section empty until the user adds another.
All sections
The section reference has each section's questions, allowed values and an example request.
| Section | Heading | Add with answers | Item id | Inside | First item |
|---|---|---|---|---|---|
locations | Locations | POST /location | location_id | — | Created for you |
buildings | Buildings | POST /building | building_id | locations (send location_id) | Created for you |
gl_classifications | GL classifications | POST /gl_classification | gl_classification_id | locations (send location_id) | Created for you |
jobs | Job classes | POST /job | job_id | locations (send location_id) | Created for you |
owners | Owners and officers | POST /owner | owner_id | — | Created for you |
gl_claims | General liability claims | POST /gl_claim | gl_claim_id | — | You add it |
bop_claims | Business owner's policy claims | POST /bop_claim | bop_claim_id | — | You add it |
wc_claims | Workers' compensation claims | POST /wc_claim | wc_claim_id | — | You add it |
pl_claims | Professional liability claims | POST /pl_claim | pl_claim_id | — | You add it |
cl_claims | Cyber claims | POST /cl_claim | cl_claim_id | — | You add it |
flood_claims | Flood claims | POST /flood_claim | flood_claim_id | — | You add it |
prop_claims | Property claims | POST /prop_claim | prop_claim_id | — | You add it |
crime_claims | Crime claims | POST /crime_claim | crime_claim_id | — | You add it |
dol_claims | Directors and officers claims | POST /dol_claim | dol_claim_id | — | You add it |
epli_claims | Employment practices liability claims | POST /epli_claim | epli_claim_id | — | You add it |
additional_insureds | Additional insureds | POST /additional_insured | additional_insured_id | — | You add it |
other_named_insureds | Other named insureds | POST /other_named_insured | other_named_insured_id | — | You add it |
subrogation_waivers | Waivers of subrogation | POST /subrogation_waiver | subrogation_waiver_id | — | You add it |
additional_interests | Additional interests | POST /additional_interest | additional_interest_id | — | You add it |
drivers | Drivers | POST /driver | driver_id | — | Created for you |
violations | Violations | POST /violation | violation_id | drivers (send driver_id) | You add it |
accidents | Accidents | POST /accident | accident_id | — | You add it |
claims | Claims | POST /claim | claim_id | — | You add it |
vehicles | Vehicles | POST /vehicle | vehicle_id | — | Created for you |
salesperson | Salespeople | POST /salesperson | salesperson_id | — | You add it |
misc_party | Scheduled parties | POST /misc_party | misc_party_id | — | You add it |
schedule_of_hazards | Schedule of hazards | POST /schedule_of_hazard | schedule_of_hazard_id | locations (send location_id) | Created for you |
shareholders | Shareholders | POST /shareholder | shareholder_id | — | You add it |
fireplaces | Fireplaces | POST /fireplace | fireplace_id | — | Created for you |
class_codes | Class codes | POST /class_code | class_code_id | locations (send location_id) | Created for you |
im_claims | Inland marine claims | POST /im_claim | im_claim_id | — | You add it |
gl_additional_interest | General liability additional interests | POST /gl_additional_interest | gl_additional_interest_id | — | You add it |
bop_additional_interest | Business owner's policy additional interests | POST /bop_additional_interest | bop_additional_interest_id | — | You add it |
underlying_policies | Underlying policies | POST /underlying_policy | underlying_policy_id | — | Created for you |
photo_scheduled_equipment | Photo scheduled equipment | POST /photo_scheduled_equipment | photo_scheduled_equipment_id | — | Created for you |
scheduled_equipment | Scheduled equipment | POST /scheduled_equipment | scheduled_equipment_id | — | Created for you |
Your applications
To build a dashboard of your applications, list them with the token from
POST /auth/token or an agent's token. You see the applications your
account has access to, usually every one in your tenant. A guest token sees none.
GET /application_forms?state=quoted&sort=updated_at&per_page=20
Authorization: Bearer <token>
Each application comes back as a dashboard row: its summary first, then its bind status,
owner and quotes.
{
"application_forms": [
{
"id": "cd5ccf16-…",
"reference_id": "BP-1A2B3C",
"summary": { "business_name": "Acme Bakery LLC", "business_type": "Bakery", "email": "sam@acmebakery.com", "city": "Columbus", "state": "OH", "…": "…" },
"state": "quoted",
"percent_completed": 100,
"created_at": "2026-09-01T14:03:11Z",
"updated_at": "2026-09-02T09:40:52Z",
"bind": { "status": "bound", "payment_pending": false, "binding": false, "has_bound_policy": true, "dispositions": [] },
"owner": { "id": "…", "first_name": "Ada", "last_name": "Lee", "user_type": "end_user" },
"not_owner": false,
"quote_requests": [
{ "id": "…", "request_status": "completed", "quote_number": "Q-100", "carrier": { … }, "annual_price": 1234.0, "rate_type": "ready_to_bind", "online_bind": false, … }
]
}
],
"page": 1,
"per_page": 20,
"total": 41
}
summaryhas the answers a dashboard shows: business name and type, coverage types and start date, contact name, email and phone, and address, and more (such as the legal entity, employees, revenue and payroll). Every key is there,nullwhen unanswered.summary.stateis the business's state; the application's ownstateis beside it.bindis the application's bind status; see below.owneris who the application is assigned to, ornullwhen nobody is.not_owneristruewhen the application isn't yours (someone else owns it, or nobody does). Show a warning on those rows: you can view the application, but you have to take it over before you can change it. It'sfalsefor your own applications, andnullif we couldn't tell who you are.quote_requestshas the application's quotes, each the full quote request object, the same as everywhere else. Withdrawn quotes are left out. It'snullif the quotes couldn't be loaded; open them withGET /application_forms/{id}/quote_requests.- Ask for the next page while
page × per_page < total. GET /application_forms/countsreturns{ "new": 4, "in_progress": 9 }for headline numbers.
bind.status is the bind badge to show, the first that applies:
status | Meaning |
|---|---|
payment_pending | The customer was sent a payment link |
binding | In checkout, not yet bound |
bound | A policy was bound through Bold Penguin |
bound_elsewhere | The agent marked it done as bound elsewhere |
not_bound | The agent marked it done as not bound |
null | None of these |
The flags behind it are also there (payment_pending, binding, has_bound_policy), with
the agent's dispositions. Filter with labels=has_bound_policy for bound applications.
Filters, all optional:
| Query | Values |
|---|---|
q | Text to search the answers for (business name, email, …) |
state | A form state, or all; repeat for several. Without one, every form except incomplete ones. |
owner | current_user, not_current_user or unassigned; owner_id for one owner |
created_after, created_before | ISO 8601 date-time |
disposition | none, bound, not_bound or dispositioned |
labels | has_bound_policy: only applications with a policy bound through Bold Penguin |
sort, order | A field (created_at, updated_at, state, percent_completed) or question code; asc or desc |
page, per_page | Page from 1; 1–100 per page, 20 by default |
The list can lag slightly behind the latest save.
Take over an application
To work on an application yourself (one a customer started, or another agent's), take it
over. You can tell which ones need it: not_owner is true on them in
your applications, when you open one with
GET /application_forms/{id}, and in the form that live updates send.
POST /application_forms/{id}/take_over
Authorization: Bearer <token>
201 with { application_form_id, taken_over_application_form_id, application_form }.
Bold Penguin copies the application into a new application with the same answers, owned
by you, and application_form is that new one, in the usual shape (without not_owner: it's
yours). Use the new
application_form_id from then on. It continues where the original was: every page whose
answers are all in is complete, including the carriers' pages. A page with a missing or
invalid answer stays open; once the user saves it, the pages after it appear. The carriers
are the ones available to you, which can differ from the original's.
- The original: marked as taken over, it drops out of your list, where the new one takes
its place. Anyone following its live updates gets
form.taken_over. - Buying its quotes: quotes on the original aren't the user's, so they can't be bought there (see someone else's quotes). Bind them on the new application.
- Errors:
404when the application doesn't exist or your account can't take applications over (guests can't);409when it's busy (retry);422when the copy failed (error.messagesays why).
Technical reference
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET | /health | none | Service health check: { "status": "ok", "environment": "…", "api_version": "1.0" } |
GET | /storefronts/{code} | none | Your storefront's settings, for a landing page |
GET | /user | Bearer | The signed-in user (/userinfo returns the same) |
GET | /auth/authorize | none | Sign an agent in with SSO: redirects their browser |
POST | /auth/token | none | Get a Bearer token: your client id and secret, an agent's username and password or SSO code, or a refresh token |
POST | /guest | none | Start an application; returns { token_id, token } |
GET | /tokens/{token_id} | Bearer | Load the application by token id: { id, application_form } |
GET | /application_forms | Bearer | List your applications, for a dashboard |
GET | /application_forms/counts | Bearer | Counts of new and in-progress applications |
POST | /application_forms | Bearer | Start an application as the signed-in agent; returns the new application |
GET | /application_forms/{id} | Bearer | Load the application by its id |
POST | /application_forms/{id} | Bearer | Save answers; returns the updated application |
POST | /{item} (/location, /building, /driver, …) | Bearer | Add an item with its answers |
POST | /application_forms/{id}/{section} | Bearer | Add an empty item |
DELETE | /application_forms/{id}/{section}/{item_id} | Bearer | Remove an item and the items inside it |
POST | /application_forms/{id}/take_over | Bearer | Take an application over: a copy owned by you |
POST | /choices | Bearer | Search the choices for a search field |
GET | /application_forms/{id}/quote_requests | Bearer | The quote requests, with their proposals |
GET | /application_forms/{id}/quote_requests/{quote_request_id} | Bearer | One quote request |
POST | /application_forms/{id}/quote_requests/{quote_request_id}/bind | Bearer | Start buying a quote; returns { quote_request_id, retrieve_url, quote_request } |
POST | /application_forms/{id}/quote_requests/{quote_request_id}/select | Bearer | Mark the quote the user picked |
GET | /attachments/{id} | Bearer | Download a proposal or policy document |
| WebSocket | /application_forms/{id}/events?token=… | ?token= | Live updates for the application, its quotes and purchases |
| various | /application_forms/{id}/quote_requests/{quote_request_id}/… | Bearer | Checkout in your app, for agents |
Versions
Every path is under /v1, and every HTTP response says which release answered in its
API-Version header. Within v1:
- Only additions. New endpoints, events, optional request fields, response fields (after
the existing ones in each object) and new values in lists such as
labels,trigger_typeor a field'stype. Ignore anything you don't recognise. - Nothing removed or changed. Removing or renaming anything, changing a field's type or
meaning, or making a request field required waits for a new major version,
/v2, which will run next to/v1for a deprecation period. During that period v1 responses haveDeprecationandSunsetheaders.
The paths without /v1 (from before versions) still work the same way, but are deprecated:
their responses have a Deprecation header, a Link header with the /v1 path, and a
Sunset header once there's a date they stop working. Add /v1 to your base URL to move;
the WebSocket is /v1/application_forms/{id}/events. A quote's attachment url
(/attachments/{id}) is a path under the base URL: add it to your base URL, /v1 included
(${API}${url}). Don't resolve it as a URL against the base URL, which drops /v1.
GET /storefronts/{code}
No token needed. Every field is always there, null when unset:
{
"id": "…", "code": "abc123", "tenant_id": "…", "tenant_subdomain": "acme", "active": true,
"agency_name": "Acme Insurance", "url": "https://acme.example",
"logo_url": "https://…", "logo_url_full": "https://…", "partner_logo_url": null,
"primary_hex_color": "#003366", "accent_hex_color": "#ff9900",
"phone": "6145550100", "email": "hi@acme.example",
"address": { "street_1": "1 Main St", "street_2": null, "city": "Columbus", "state": "OH", "zipcode": "43215" },
"hours_by_day": { "monday": { "open": "09:00", "close": "17:00" } }, "timezone": "EST",
"landing_page_headline": "…", "landing_page_subheadline": null,
"faq_html": null, "footer_disclaimer_html": "<p>…</p>",
"show_quotes": true, "enable_captcha": false
}
400 for a code with characters other than letters, digits, - and _; 404 when
there's no active storefront with that code.
GET /user
The signed-in user. GET /userinfo returns the same.
{
"id": "…", "first_name": "Ada", "last_name": "Lee", "name": "Ada Lee",
"email": "ada@example.com", "phone": null, "user_type": "end_user", "active": true,
"permitted_to_bind": true,
"tenant": { "id": "…", "name": "Acme", "subdomain": "acme", "type": "agency", "logo_url": null },
"created_at": "2026-01-01T00:00:00Z"
}
permitted_to_bind is whether the user can buy policies, including
checkout in your app. 401 without a valid token.
POST /auth/token
{ "client_id": "<your client id>", "client_secret": "<your client secret>" }
200 with { "access_token": "…", "token_type": "bearer", "expires_in": 43200, "refresh_token": "…", "tenant_id": "…" }.
Send access_token as Authorization: Bearer <access_token>. It lasts expires_in seconds
(12 hours); get a new one when it expires, or refresh it. tenant_id is your tenant, for POST /guest.
401 when the id and secret don't match. Keep the secret on your server: never put it in a
browser or app.
As a user, with a username and password. To act as one of your agents (their dashboard, their applications, taking applications over), sign in with their Bold Penguin username and password instead:
{ "grant_type": "password", "username": "ada@example.com", "password": "…", "client_id": "<your client id>" }
usernameis the agent's Bold Penguin username; for most agents it's their email.client_secretis optional here. Send it if the call comes from your server.- A form body works too. Never put the password in the URL.
- The response is the same, with
tenant_idthe agent's tenant. The token acts as the agent, so your applications andnot_ownerare theirs. 401witherror.codeinvalid_grantfor a wrong username or password, orunauthorized_clientfor an unknown client id.400if a field is missing.- Send the password straight to this endpoint over HTTPS and don't keep it; keep the token.
Sign in with SSO
If your agents sign in to Bold Penguin through their company's identity provider (SAML, Google or Microsoft), send them through SSO instead of asking for a password. There are two steps.
1. Send the agent's browser (a link or redirect, not a fetch or an iframe) to
GET /auth/authorize:
GET /auth/authorize?client_id=<your client id>&redirect_uri=<your callback URL>&state=<random>&login_hint=<their email>
They sign in with their identity provider and come back to
<your callback URL>?code=…&state=….
redirect_urimust be registered for your client with Bold Penguin.stateis a random value you keep, and check matches when they come back.login_hint(their email, or@<your tenant's subdomain>) picks the identity provider.response_typedefaults tocode; keep it.
2. Exchange the code on your server, straight away. Codes expire 5 seconds after they're issued, and work once:
{ "grant_type": "authorization_code", "code": "…", "client_id": "<your client id>", "client_secret": "<your client secret>" }
The response is the same as above, and the token acts as the agent, as with a password.
401 with error.code access_denied when the code is wrong, expired or already used; start again from
step 1.
Before SSO works, Bold Penguin has to register your callback URL for your client and set up your tenant's identity provider. Ask your Bold Penguin contact.
Refreshing a token
Every token comes with a refresh_token. Exchange it for a new token instead of signing the
agent in again:
{ "grant_type": "refresh_token", "refresh_token": "…", "client_id": "<your client id>", "client_secret": "<your client secret>" }
You get a new access_token and a new refresh_token. Each refresh token works once, so
keep the new one. 401 means it's no longer valid; sign in again. Keep refresh tokens on
your server, like the secret.
POST /guest
| Field | Type | Required | Notes |
|---|---|---|---|
tenant_id | UUID | yes | Your tenant |
source_metadata.storefront_code | string | yes | Your storefront |
source_metadata.recaptcha_token | string | if captcha is on | From the browser |
source | string | yes | Where the application came from: storefront, unless Bold Penguin gives you another value. It's one of a fixed set. Leaving it out, or sending a value not in the set, fails the request. |
application_form.field_values | array | no | Answers to start with, as in a save |
201 with { token_id, token }. 403 when the captcha token is missing or invalid, or the
tenant doesn't allow new applications. A missing or wrong storefront code, or a missing
source, fails the request too; check them if starting fails.
POST /application_forms
Starts an application owned by the token's user: an agent signed in with a username and password or SSO. The body is optional:
| Field | Type | Required | Notes |
|---|---|---|---|
application_form.field_values | array | no | Answers to start with, as in a save |
application_form.autocomplete | boolean | no, but send true with answers | Completes the pages those answers finish |
application_form.source | string | no | Where the application came from. Leave it out unless Bold Penguin gives you a value: it's one of a fixed set, and anything else is rejected with a 422. It's required when you send source_metadata. |
201 with the new application, in the same shape as
GET /application_forms/{id}. 401 without a valid token; 403 when the user can't start
applications (guests start theirs with POST /guest); 422 when an answer was rejected or
the application couldn't be created.
GET /tokens/{token_id} and GET /application_forms/{id}
Both return the application in the same shape: the token read wraps
it as { id, application_form }. Each read replaces the caller's token id; the new one is in
cable_token. The Bearer token stays the same.
POST /application_forms/{id}
| Field | Type | Required | Notes |
|---|---|---|---|
application_form.field_values[].code | string | yes | The field's code |
application_form.field_values[].value | string, number or array | The new value; see the field types. answer, its older name, still works. | |
application_form.field_values[].<item>_id | UUID | for fields in an item | The field's item id, e.g. location_id. One per answer. |
application_form.autocomplete | boolean | no, but send true | Completes every page that's ready. This is what adds the next pages and sends quote requests. |
revision | string | no | Next to application_form: the revision of the form on screen. The response then has changes; see New questions after a save. |
field_values works the same way in every request that sends answers: this save, POST /guest,
POST /application_forms and POST /{item}. Older apps may send it as answer_values,
which still works; don't send both.
200 with the updated application, plus changes (null without a revision). 422 when
an answer was rejected; error.details.errors lists them.
POST /{item}
| Field | Type | Required | Notes |
|---|---|---|---|
application_form_id | UUID | yes | |
field_values | array | at least one | As in a save; leave out the item id |
<parent>_id | UUID | for nested sections | location_id for buildings, classifications, jobs, hazards and class codes; driver_id for violations. Other sections reject it. |
revision | string | no | The revision of the form on screen; application_form.changes then lists what the item added |
200 with { <item>_id, <parent>_id?, application_form }.
POST /application_forms/{id}/{section}
Optional body: the parent's id, for nested sections. 200 with
{ section, <item>_id, <parent>_id? }.
DELETE /application_forms/{id}/{section}/{item_id}
Optional: revision, in the query or a JSON body. 200 with
{ section, <item>_id, application_form }. application_form is null in the rare case it
couldn't be read back; the item is removed either way, so load the application again. 400
when the item's answers show it belongs to another section, 404 when the application has no such item.
POST /choices
| Field | Type | Required | Notes |
|---|---|---|---|
application_form_id | UUID | yes | |
answer_id | UUID | yes | The field's id |
filter | string | no | The search text |
page, per_page | number | no | Pagination; totals are in the X-Total, X-Page and X-Per-Page headers |
POST /application_forms/{id}/quote_requests/{quote_request_id}/bind
Optional body: { "action_type": "send_to_underwriting" }. 200 with
{ quote_request_id, retrieve_url, quote_request }; retrieve_url is null when the
purchase continues inside Bold Penguin. 403 when the quote isn't the user's (take the
application over and bind on the copy; see someone else's quotes),
422 when the quote can't be bought, 503 when Bold Penguin couldn't confirm who the user
is (try again).
POST /application_forms/{id}/quote_requests/{quote_request_id}/select
No body. 200 with { quote_request_id, quote_request }: see select a quote.
403 when the quote isn't the user's, 404 when there's no such quote, 503 when Bold
Penguin couldn't confirm who the user is.
Checkout in your app
Not offered yet: every quote has online_bind: false for now, so bind every quote with
POST …/bind. The steps below
are for when it's turned on.
All paths are under /application_forms/{id}/quote_requests/{quote_request_id}. Only the
quote's owner can check out: every step answers 403 on someone else's quote (see
someone else's quotes) and 503 when Bold Penguin couldn't confirm
who the user is.
POST …/bind_review: start.201when ready;400when checks failed (error.details.errors) or are still running (error.details.processes: try again shortly). This limits the application to this carrier until checkout ends;DELETE …/bind_reviewcancels it.GET …/configurable_texts: the legal texts to show,[{ id, code, content, requires_attestation }].POST …/attest { "attestation_codes": { "<code>": true } }: the user's agreement to each text thatrequires_attestation.POST …/payment_plan { "plan_id": "…" }: a plan from the quote'splans. Skip it when there's anintegrated_bind:select_payment_planstext.POST …/payment_strategy { "origin_url": "<your page URL>" }: how to take payment.strategyiscarrier_stripe(tokenize the card with Stripe.js usingstripe_api_key),authorize_net(Accept.js),iframe(the carrier's own payment form, fromiframe_details),stripe_payment_linkorskip.origin_urlmust be the fullhttp(s)URL of the page showing the form; from a browser it's taken from theOrigin(orReferer) header when left out.For Hiscox (
varianthiscoxorhiscox_paymentus): choose a plan in step 4 first, and showiframe_urlin an<iframe>. Hiscox loads the form's stylesheet from your page's origin (/assets/styles/hiscox-iframe.css, orhiscox-iframe-<version>.cssforhiscox_paymentus; ask Bold Penguin for the version). Withhiscox_paymentus, thepayment_tokenis thedata["reference-number"]of thesuccessmessage the form posts to your page. Withhiscox, the frame goes to/assets/iframe-callbacks/hiscox.htmlon your origin when the user finishes: serve a page there that posts its query string to the parent.code=000means paid, anduIDis thepayment_token.POST …/bind_and_pay { "automatic_payments": false, "payment_token": "…" }to buy, orPOST …/payment_pendingforstripe_payment_link(the customer is emailed a link).Load the quote request until
request_statusisbound(orbinding_failed, orpending_paymentuntil a link is paid). The policy is then in itspolicyandattachments.
GET /attachments/{id}
Optional query: disposition=inline to show the file rather than download it. 200 with the
file and its Content-Type; 404 when there's no such attachment.
Errors
Every error has the same body:
{ "error": { "status": 422, "code": "unprocessable", "message": "The request was rejected.", "details": { "errors": { "mqs_state": ["is invalid"] } } } }
codeis for your code to check;messageis a sentence to show or log, and may be reworded. Treat acodeyou don't recognise by itsstatus.detailshas more where these docs mention it (a save'serrors, checkout'serrorsandprocesses). Otherwise it'snullor holds other information from Bold Penguin; don't rely on it beyond what these docs describe.
| Status | code | Meaning |
|---|---|---|
400 | bad_request | The request is malformed: a missing field, a bad id, or a parent id the section doesn't take. message says which. |
401 | unauthorized | Missing or expired token. Signing in uses invalid_grant, unauthorized_client or access_denied. |
403 | forbidden | Not allowed: someone else's quote, or see POST /guest |
404 | not_found | The endpoint, application, item or answer doesn't exist |
409 | conflict | The application is busy. Retry. |
422 | unprocessable | Bold Penguin rejected an answer or a purchase; details.errors says what, when known |
502 | bad_gateway | Bold Penguin couldn't be reached. Retry. |
503 | unavailable | Bold Penguin couldn't confirm who the user is. Retry. |
{ "error": { "status": 400, "code": "bad_request", "message": "buildings items belong to locations; send location_id", "details": null } }
Standard questions
Every application starts with the Eligibility pages: the questions Bold Penguin needs to find carriers and send quote requests. They are the same for every storefront. Carrier pages and sections add more questions later; the form shows them as they apply.
For each page below:
- Questions you answer are always on the page.
- Questions that depend on other answers appear once an earlier answer calls for them, for example the FEIN once the user says the business has one.
- Filled in by Bold Penguin are hidden. Don't send them; they're worked out from other answers.
The form is the source of truth for what's required on a given application: each field has
required.
Eligibility: General information
Questions you answer
| Code | Question | Type | Required | Values |
|---|---|---|---|---|
mqs_first_name | Policyholder's First Name | text | Yes | — |
mqs_last_name | Policyholder's Last Name | text | Yes | — |
mqs_phone | Phone Number | masked (phone) | Yes | Exactly 10 characters. Must match ^(?!.*(\d)\1{9})[2-9][0-9]{9}$. |
mqs_email | Policyholder's email address | text | Yes | Must match (?=^.{1,254}$)^[a-zA-Z0-9/_%+-](?:[a-zA-Z0-9./_%+-]*[a-zA-Z0-9/_%+-])?@[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?\.[a-zA-Z0-9]{2,}$. |
mqs_business_name | Business Name | text | Yes | At most 80 characters. |
mqs_street_1 | Street address | text | Yes | — |
mqs_street_2 | Suite / Apt Number | text | No | — |
mqs_city | City | text | Yes | — |
mqs_state | State | select | Yes | 51 choices; use the field's options. For example AK, AL or AR. |
mqs_zipcode | Zipcode | text (zipcode) | Yes | Exactly 5 characters. Must match ^\d{5}$. |
mqs_address | What is your address? | address | No | — |
Filled in by Bold Penguin: mqs_reference_id, mqs_route, mqs_tenant_transfer_reason, mqs_focused, mqs_agent_focused, mqs_consumer_focused_alternate_carrier, mqs_selected, mqs_consumer_selected, mqs_source_type, mqs_customer_metadata, mqs_full_name, mqs_title, mqs_middle_name, mqs_suffix, mqs_date_of_birth, mqs_full_street, mqs_language_preference.
Eligibility: Business type
Questions you answer
| Code | Question | Type | Required | Values |
|---|---|---|---|---|
Naics | What kind of work do you do? | search (business_class) | Yes | Search with POST /choices, using the field's id as answer_id, and send the chosen choice's alias (e.g. 238350 for Finish carpentry). |
Filled in by Bold Penguin: mqs_naics_code.
Eligibility: Coverage type
Questions you answer
| Code | Question | Type | Required | Values |
|---|---|---|---|---|
CoverageTypes | Coverage type | multiselect | Yes | 40 choices; use the field's options. For example CGL, BOP or WORK. |
Filled in by Bold Penguin: mqs_state_naics_coverage_current, mqs_state_naics_coverage_changed.
Eligibility: Business details
Questions you answer
| Code | Question | Type | Required | Values |
|---|---|---|---|---|
mqs_coverage_start_date | Policy start date | date | Yes | Between today and 120 days from now. |
mqs_year_established | Founding Year of Business | number (year) | Yes | Between 423 years ago and this year. Must match ^\d{4}$. |
mqs_legal_entity | Legal Entity Type | select | Yes | One of LLC, Sole Proprietorship, C Corporation, S Corporation, Partnership, Association, Trust, Joint Venture, Municipality or Other. |
mqs_full_time_employees | Full-time employees | number | Yes | At least 0. |
mqs_part_time_employees | Part-time employees | number | Yes | At least 0. |
mqs_currently_insured | Do you currently have insurance for this business? | radio | Yes | One of Yes or No. |
mqs_ins_history_3yr_catchall | Has this business had a lapse of coverage, been declined, canceled, or non-renewed in the last 3 years? | radio | Yes | One of Yes or No. |
Questions that depend on other answers
| Code | Question | Type | Required | Shown when | Values |
|---|---|---|---|---|---|
mqs_years_of_experience | Years of experience in this industry | number | Yes, when shown | It already has an answer (one Bold Penguin filled in) | 0 to 999. |
mqs_has_fein | Does your business have a Federal Employer Identification Number (FEIN)? | radio | Yes, when shown | Carrier biberk can quote WC | One of Yes or No. |
mqs_fein | FEIN (Federal Tax Identification Number) | masked (fein) | Yes, when shown | mqs_has_fein is Yes | Must match ^\d{9}$. |
mqs_ssn | SSN (Social Security Number) | masked (ssn) | Yes, when shown | mqs_has_fein is No | Must match ^\d{9}$. |
mqs_estimated_annual_revenue | Estimated total gross revenue | masked (currency) | Yes, when shown | Coverage includes general liability, BOP, workers' compensation, EO, commercial property or cyber | 1 to 999,999,999. |
mqs_number_of_owners | How many owners are there in your business? | number | Yes, when shown | Carrier acuity can quote CGL, AUTOB or BOP; or carrier hartford can quote BOP or GL; or carrier hiscox can quote GL; or carrier nextcarrier can quote BOP or GL; or carrier progressivecarrier can quote GL or BOP; or carrier rtspecialty can quote BOP or CGL | At least 1. |
mqs_estimated_annual_payroll_owner | Business owner(s) only | masked (currency) | Yes, when shown | Coverage includes general liability, BOP, workers' compensation, EO or commercial property | 1 to 999,999,999. |
mqs_estimated_annual_payroll | Full-time, part-time, and temporary employees | masked (currency) | Yes, when shown | Coverage includes general liability, BOP, workers' compensation or EO and mqs_full_time_employees is more than 0; or coverage includes general liability, BOP, workers' compensation or EO and mqs_part_time_employees is more than 0 | 0 to 999,999,999. |
mqs_estimated_annual_payroll_subcontractors | Subcontractors | masked (currency) | Yes, when shown | Coverage includes general liability, BOP, workers' compensation, EO or commercial property | 0 to 999,999,999. |
mqs_snow_removal_percentage | What percentage of your annual sales involves snowplowing and ice removal? | masked (percentage) | Yes, when shown | Naics is 561730 or 25 more | 0 to 100. |
mqs_roof_work | Do you perform any roofing work? | radio | Yes, when shown | Naics is 238350, 238160, 238390, 237990, 237310, 238220, 238910 or 978 more | One of Yes or No. |
mqs_remediation_work | Do you perform any fire, mold or water remediation? | radio | Yes, when shown | Naics is 238350, 238160, 238390, 237990, 237310, 238220, 238910, 424690 or 1086 more | One of Yes or No. |
mqs_additional_insured_required | Are you required to name a third party as an additional insured or loss payee on your policy? (i.e. building owner, bank, landlord, etc.) | radio | Yes, when shown | Coverage includes general liability, BOP, EO, commercial property, SURE, CRIM, EPLI, PLMSC, D&O, AUTOB, cyber, UMBRC or 27 more and your tenant isn't geico; or coverage includes general liability, BOP, workers' compensation, EO, commercial property, SURE, CRIM, EPLI, PLMSC, D&O, cyber, UMBRC or 27 more and your tenant is geico | One of Yes or No. |
mqs_loss_claim_5yr_catchall | Has this business had a loss or claim in the last 5 years? | radio | Yes, when shown | The tenant has feature claims_and_losses_mqs | One of Yes or No. |
mqs_ins_history_3y_non_payment | Was it for non-payment? | radio | Yes, when shown | mqs_ins_history_3yr_catchall is Yes | One of Yes or No. |
mqs_covg_ca_liability_limit | Select the Bodily Injury & Property Damage for this commercial auto policy. | select | Yes, when shown | Your tenant isn't geico; and coverage includes AUTOB | One of $300,000 combined single limit, $500,000 combined single limit or $1,000,000 combined single limit. |
mqs_ca_refuse_quote_consent | Do you understand that carrier(s) reserve the right to refuse to give you an auto quote? | checkbox | Yes, when shown | Coverage includes AUTOB and mqs_state is PA | Send "1" to check it. |
Filled in by Bold Penguin: mqs_actual_years_in_business, mqs_tax_id, mqs_revenue_percent_subcontracted, mqs_employees, mqs_estimated_annual_payroll_owner_employees, mqs_estimated_annual_payroll_employees_subcontractors, mqs_estimated_annual_payroll_all, mqs_estimated_annual_payroll_percentage_subcontractors, mqs_annual_policy, mqs_services_contract, mqs_work_above_three_stories, mqs_provides_towing_services, mqs_swimming_pool_repair, mqs_dance_floor, mqs_subcontract, mqs_excavation_four_feet, mqs_framing, mqs_tract_housing, mqs_baby_furniture, mqs_firearms_on_premise, mqs_cooking_or_food_prep, mqs_gasoline_sales, mqs_window_cleaning, mqs_open_past_midnight, mqs_open_24_hours, mqs_percentage_offsite_catering, mqs_alcohol_sales, mqs_grilling_or_frying, mqs_vehicles_a_year_sold, mqs_install_eifs, mqs_covg_ca_uninsured_motorist_limit, mqs_vehicles_provide_tours, mqs_seasonal_worker_transport, mqs_progressive_auto_policy, mqs_tcpa_consent, mqs_jornaya_lead_id.
Eligibility: Building information
Questions you answer
| Code | Question | Type | Required | Values |
|---|---|---|---|---|
mqs_building_percent_sprinklered | What percentage of the building is sprinklered? | masked (percentage) | Yes | — |
mqs_building_roof_material | What is the roofing material of the building? | select | Yes | One of Asphalt Shingle, Built-Up (Tar & Gravel), Clay or Concrete Tile, Metal, Single-Ply Membrane, Slate or Wood Shake. |
mqs_building_roof_geometry | What is the roof geometry of the building? | select | Yes | One of Flat, Gable, Hip or Pitched. |
mqs_building_limit | Provide an estimated value for the building limit | masked (currency) | Yes | 0 to 999,999,999. |
Questions that depend on other answers
| Code | Question | Type | Required | Shown when | Values |
|---|---|---|---|---|---|
mqs_building_relationship | What type of building do you operate your business out of? | select | Yes, when shown | Coverage includes BOP or commercial property | One of A building I lease, My home, A building I own and run my business out of, A building I own/manage and lease to others, An apartment, A commercial condo I lease, A kiosk or A commercial condo I own. |
mqs_building_has_sprinkler_system | Does the building contain an automatic sprinkler system? | radio | Yes, when shown | Coverage includes commercial property | One of Yes or No. |
mqs_building_stories | How many floors are in this building? | number | Yes, when shown | Coverage includes BOP or commercial property | 1 to 255. |
mqs_area_occupied | Approximately how many square feet does your business occupy of this building (not total square feet of building)? Enter 0 if you do not occupy the building. | number | Yes, when shown | Coverage includes BOP or commercial property | 0 to 999,999,999. |
mqs_building_square_footage | What is the approximate total square footage of the building (including space you do not occupy)? | number | Yes, when shown | Coverage includes BOP or commercial property | 1 to 999,999,999. |
mqs_building_year_built | What is the approximate year the building your company occupies finished construction? | number (year) | Yes, when shown | Coverage includes BOP or commercial property | Between 423 years ago and next year. Must match ^\d{4}$. |
mqs_building_construction | Which of the following describes the construction of your building? | select | Yes, when shown | Coverage includes BOP or commercial property | One of Frame, Joisted Masonry, Masonry Non Combustible, Non Combustible, Fire Resistive or Modified Fire Resistive. |
Filled in by Bold Penguin: mqs_dba, mqs_building_ownership, mqs_building_age.
Example: answering Eligibility
Every question that is always required, in one save. Questions that depend on these answers come back in the response; answer them in the next save.
{
"application_form": {
"field_values": [
{ "code": "mqs_first_name", "value": "Jane" },
{ "code": "mqs_last_name", "value": "Doe" },
{ "code": "mqs_phone", "value": "6145550100" },
{ "code": "mqs_email", "value": "owner@example.com" },
{ "code": "mqs_business_name", "value": "Acme Carpentry LLC" },
{ "code": "mqs_street_1", "value": "1 Main St" },
{ "code": "mqs_city", "value": "Columbus" },
{ "code": "mqs_state", "value": "OH" },
{ "code": "mqs_zipcode", "value": "43215" },
{ "code": "Naics", "value": "238350" },
{ "code": "CoverageTypes", "value": ["CGL"] },
{ "code": "mqs_coverage_start_date", "value": "2026-11-01" },
{ "code": "mqs_year_established", "value": 2018 },
{ "code": "mqs_legal_entity", "value": "LLC" },
{ "code": "mqs_full_time_employees", "value": 1 },
{ "code": "mqs_part_time_employees", "value": 1 },
{ "code": "mqs_currently_insured", "value": "Yes" },
{ "code": "mqs_ins_history_3yr_catchall", "value": "Yes" },
{ "code": "mqs_building_percent_sprinklered", "value": 100 },
{ "code": "mqs_building_roof_material", "value": "Asphalt Shingle" },
{ "code": "mqs_building_roof_geometry", "value": "Flat" },
{ "code": "mqs_building_limit", "value": 25000 }
],
"autocomplete": true
}
}
Coverage types
CoverageTypes is a multi-select. Send the codes, e.g. ["CGL", "BOP"]. The coverages
chosen decide which pages and sections follow.
| Send | Coverage |
|---|---|
CGL | Liability |
BOP | BOP |
WORK | Workers Compensation |
EO | Professional Liability (E&O) |
PROP | Commercial Property |
SURE | Bond |
CRIM | Crime |
EPLI | Employment Practices Liability |
PLMSC | Miscellaneous Professional Liability |
DO | Directors & Officers Liability |
AUTOB | Commercial Auto |
CYBER | Cyber Liability |
UMBRC | Umbrella |
FLOOD | Commercial Flood |
MMAL | Medical Malpractice |
INMRC | Inland Marine |
OTHER | Other/Not Sure |
CAVN | Aviation |
BANDM | Boiler & Machinery |
BLDR | Builder's Risk |
DIC | Difference in Conditions |
GARAG | Garage & Dealers |
LL | Liquor Liability |
OCP | Owners & Contractors Protective Liability |
POLL | Pollution Liability |
SE | Special Events |
BOAT | Watercraft |
AL | Auto Liability |
APD | Automobile Physical Damage |
CAL | Condo Association Liability |
EB | Equipment Breakdown |
EL | Environmental Liability |
EQ | Earthquake Insurance |
FL | Fiduciary Liability |
HNOA | Hired and Non-Owned Auto Liability |
MTC | Motor Truck Cargo |
PRODLIAB | Product Liability |
PWR | Power Outage Coverage |
VP | Vacant Property |
XL | Excess Liability |
After Eligibility
Once the Eligibility pages are complete, Bold Penguin adds pages for what the business needs. Which ones depends on the coverages chosen and your storefront's setup:
| Page | Added when | Sections on it |
|---|---|---|
| Additional details | The coverage includes BOP, general liability, workers' compensation or commercial property (and, for claims history, other lines) | locations, buildings, gl_classifications, jobs, owners, the claims sections |
| Vehicles | The coverage includes commercial auto (AUTOB) | vehicles |
| Drivers | The coverage includes commercial auto, after Vehicles | drivers, violations |
| Carrier pages | One per carrier that can quote, once the pages above are complete | The carrier's own questions and sections |
Completing a carrier's page sends that carrier a quote request (see How to get a quote).