This guide follows a customer from the first question to a policy, the same way the quote form does. Use it when you're building your own form or app, or selling from your own systems. If you only need a quote form on your website, the quote form embed does all of this.
You can make these requests from your server with a server-only application,
or from your web pages with a browser application (see
Server-only and browser applications).
Ask for a token with both read and write. Every step after getting the
questions changes something, so it needs write. The examples use curl.
Replace YOUR_ACCESS_TOKEN with a token, and the ids with the ones you get
back. Money is in pence throughout.
The steps#
- Get the questions for a product's journey.
- Start an application.
- Save the answers as the customer gives them, and their details.
- Quote it, and accept the quote.
- Take the Direct Debit.
- Show what they must see before they buy.
- Take the first premium by card.
- Set up the policy.
For a whole-of-market product, steps 5 onwards are different (see Whole-of-market products).
1. Get the questions#
Find your product's slug with List your products and its journeys with List a product's journeys. Then ask for the journey's questions:
curl https://subcontext.com/api/v1/products/level-term-life/journeys/express/questions \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
You get the product (with the limits on amounts, ages and terms), the journey, the questions in order and the presentation, which arranges them into pages (shortened here):
{
"data": {
"product": {
"slug": "level-term-life",
"name": "Level Term Life",
"currency_code": "GBP",
"cover_bounds": {
"sum_assured_min_pence": 1000000, "sum_assured_max_pence": 50000000,
"age_min": 18, "age_max": 65, "term_min": 5, "term_max": 40
}
},
"journey": { "slug": "express", "name": "Quick quote" },
"questions": [
{
"key": "uk_resident",
"label": "Do you live in the UK?",
"question_type": "enum",
"required": true,
"answer_options": [
{ "value": "yes", "label": "Yes", "position": 0 },
{ "value": "no", "label": "No", "position": 1,
"terminal_outcome": "decline", "terminal_reason_code": "not_uk_resident",
"terminal_reason_message": "We can only cover people who live in the UK." }
]
},
{
"key": "smoker",
"label": "Have you smoked or used nicotine in the last 12 months?",
"question_type": "boolean",
"required": true,
"answer_options": []
},
{
"key": "cigarettes_per_day",
"label": "How many cigarettes a day?",
"question_type": "integer",
"validation": { "min": 1, "max": 100 },
"ask_if": { "key": "smoker", "equals": true },
"answer_options": []
}
],
"presentation": {
"slug": "standard",
"stages": [
{ "key": "details", "stage_type": "personal_details", "title": "Your details" },
{ "key": "cover", "stage_type": "cover_capture", "title": "Cover" },
{ "key": "health_questions", "stage_type": "questions", "title": "Health questions" },
{ "key": "quote", "stage_type": "quote", "title": "Your quote" },
{ "key": "payment", "stage_type": "payment", "title": "Payment" }
]
}
}
}
Some things to build into your form:
- Ask a question with
ask_ifonly when the answers so far meet its condition. - An answer option with a
terminal_outcomeends the application (here, "No" declines). You can tell the customer at once, using itsterminal_reason_message. - Don't ask
derivedquestions. They're worked out from other answers. - Check the amount, age and term against
cover_bounds(and each cover's own limits incovers) before you ask for a price.
2. Start an application#
Start the application as soon as the customer begins, so their answers are saved as they go. You don't need their details yet.
curl -X POST https://subcontext.com/api/v1/applications \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: visit-7f3a9c" \
-d '{
"product_slug": "level-term-life",
"journey_slug": "express",
"provenance": { "page_url": "https://www.example.com/life-cover?utm_source=newsletter" }
}'
201 Created, with the application as data (shortened):
{
"data": {
"id": "0199a3c2-7a00-7e55-9d7c-5a6b7c8d9ea5",
"display_id": "app_0199a3c2-7a00-7e55-9d7c-5a6b7c8d9ea5",
"status": "anonymous",
"customer": null,
"risk_factors": {},
"cover_amount_pence": null,
"term_years": null,
"inline_bacs_mandate": false,
"direct_debit_first": false,
"market_quotes": null,
"share_token": "ytPVoqdUS9HP5G-dUgYpSSu4IjkbQdS0"
}
}
Keep the id, and the share_token if you'll use the webchat or the quote
form with this application. A browser application only gets it in this first
answer, and needs it for everything after. It sends it in an X-Share-Token
header with every request about the application and its quotes, or the
answer is 404 not_found. A server-only application doesn't need it, so the
examples below leave it out. The Idempotency-Key makes the request safe to retry. The same key
with the same inputs gives back the same application (without the
share_token, for a browser application), and the same key with different
inputs is refused with 409 idempotency_key_conflict. A browser application
replays only with the share_token in X-Share-Token; without it the answer
is 409 idempotency_replay_unproven, so if the first answer never arrived,
start again with a new key, sending the old one as replaces_idempotency_key
so the first application is closed rather than followed up. provenance
says where the customer was, for your reports on what brings people in.
3. Save the answers#
Each time the customer finishes a page, save what they answered. Answers are
keyed by the question's key. The amount of cover (sum_assured, in whole
pence) and the term (term_years) are saved the same way.
curl -X PATCH https://subcontext.com/api/v1/applications/0199a3c2-7a00-7e55-9d7c-5a6b7c8d9ea5/answers \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "answers": { "uk_resident": "yes", "smoker": false, "sum_assured": 25000000, "term_years": 25 } }'
{
"data": {
"application": {
"id": "0199a3c2-7a00-7e55-9d7c-5a6b7c8d9ea5",
"status": "anonymous",
"risk_factors": { "uk_resident": "yes", "smoker": false },
"cover_amount_pence": 25000000,
"term_years": 25
},
"captured_keys": ["uk_resident", "smoker"],
"missing_keys": ["weight_kg", "height_cm"],
"knockout": null
}
}
data holds the application and what this save captured. missing_keys
lists the questions still to ask. When an answer ends the application,
knockout says which and why:
"knockout": { "outcome": "decline", "question_key": "uk_resident", "value": "no",
"reason_code": "not_uk_resident", "message": "We can only cover people who live in the UK." }
An answer that doesn't fit its question is refused with 422 answers_invalid,
and fields says which. Nothing from that request is saved.
4. Save the customer's details#
When the customer reaches the details page:
curl -X PATCH https://subcontext.com/api/v1/applications/0199a3c2-7a00-7e55-9d7c-5a6b7c8d9ea5/customer \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"customer": {
"title": "Ms", "first_name": "Ada", "last_name": "Lovelace",
"email": "ada@example.com", "phone_number": "+447700900123", "dob": "1988-04-12",
"postal_code": "SW1A 2AA", "address_line1": "10 Example Street", "city": "London",
"country": "United Kingdom"
}
}'
The answer is the application (as data). Its status becomes
in_capture and its customer is filled in. Customers are found by email
address. If one already exists, a browser application doesn't change their
record unless this application created it: what was sent is kept on the
application, shown back in customer and used for the quote. Details that aren't valid
answer 422 customer_invalid, with fields naming each, such as
customer.email. Two lookups help with this page: Check a phone number
and Find addresses at a postcode.
If the details stage has a consent_notice, show it under the fields. If it
has consent_opt_outs, show them as tick boxes and send the ticked ones as
"consent_answers": { "KEY": false }.
5. Quote it#
curl -X POST https://subcontext.com/api/v1/applications/0199a3c2-7a00-7e55-9d7c-5a6b7c8d9ea5/quote \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
{
"data": {
"application": { "id": "0199a3c2-7a00-7e55-9d7c-5a6b7c8d9ea5", "status": "quoted" },
"quote": {
"id": "0199a3c2-7c00-7a77-9f9e-7c8d9eafb0c7",
"reference": "QT-AB-P001-000042",
"status": "issued",
"decision": "accept_standard",
"currency_code": "GBP",
"total_premium_monthly_pence": 1245,
"expires_at": "2026-11-11T10:19:42+00:00",
"assumptions": [],
"components": [
{ "cover_type": "life", "sum_assured_pence": 25000000, "term_years": 25,
"decision": "accept_standard", "rating_pct": 100,
"premium_monthly_pence": 1245, "premium_annual_pence": 14940 }
]
}
}
}
That's £12.45 a month for £250,000 of cover over 25 years.
- You can quote part way through the form, for an early price. A quote with
assumptionsrests on answers not given yet, so treat it as a guide and quote again at the end. Each new quote replaces the last. - When the answers mean no price can be offered,
quoteisnulland the application'sstatusisdeclined,postponedorreferred. - If the customer wants to talk to someone instead, Ask for a person to follow up.
If your own system has everything at once, Quote in one request
takes the customer, answers and cover together and gives back the application
and its quote. Sent with an application_id, it quotes an application you
started earlier. If that application already has a customer, send the same
person, because a different one answers 409 customer_mismatch. Each cover's
amount (sum_assured_pence) and term are whole numbers. A component it can't
read answers 422 components_invalid, with fields naming each problem. A
browser application gets the share_token only when this makes a new
application, and sends the earlier application's share_token in
X-Share-Token when it names one with application_id.
6. Accept the quote#
When the customer chooses to buy:
curl -X POST https://subcontext.com/api/v1/applications/0199a3c2-7a00-7e55-9d7c-5a6b7c8d9ea5/accept_quote \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "quote_id": "0199a3c2-7c00-7a77-9f9e-7c8d9eafb0c7" }'
The answer is the application. From here on, the requests are on the quote
(/api/v1/quotes/{id}/...), and each one checks the quote is still current.
An expired or replaced quote answers 409 (quote_expired or
quote_not_bindable). Quote again to fix it.
7. Take the Direct Debit#
Premiums are collected by Direct Debit. The application's inline_bacs_mandate
says how:
If it's false, send the bank details:
curl -X POST https://subcontext.com/api/v1/quotes/0199a3c2-7c00-7a77-9f9e-7c8d9eafb0c7/direct_debit_details \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "direct_debit": { "account_holder_name": "Ada Lovelace", "sort_code": "40-47-84",
"account_number": "70872490", "address_line1": "10 Example Street",
"address_city": "London", "address_postal_code": "SW1A 2AA" } }'
{
"data": {
"id": "0199a3c2-7f00-70dd-9f5e-d2e3f405162d",
"account_holder_name": "Ada Lovelace",
"sort_code": "40-47-84",
"account_number_last4": "2490"
}
}
The sort code and account number are checked together. Details that don't
pass answer 422 direct_debit_invalid, with fields naming them.
If it's true, the customer sets it up on Stripe's form. Start a Direct Debit mandate
gives you a Stripe Checkout session to mount with Stripe.js
(initEmbeddedCheckout). When the customer finishes, send the session's id to
Record the Direct Debit mandate.
8. Show what they must see before they buy#
Your firm's rules may say the customer must see certain things before they buy: the product's demands and needs statement, its key documents, their right to cancel, and a read-back of what they're buying. Ask what's owed:
curl https://subcontext.com/api/v1/quotes/0199a3c2-7c00-7a77-9f9e-7c8d9eafb0c7/before_you_buy \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
{
"data": {
"show": true,
"steps": ["demands_and_needs", "cancellation_rights_before_bind", "confirm_before_purchase"],
"sellable": true,
"demands_and_needs": "This policy meets the demands and needs of someone who wants to leave a lump sum to their family if they die during the term.",
"key_documents": [],
"cancellation": {
"days": 30,
"statement": "You can cancel within 30 days of the start date and get back everything you've paid."
},
"confirmation": {
"intro": "Before we set up your policy:",
"points": ["Level Term Life.", "Life cover of £250,000.00 for 25 years.", "£12.45 a month.", "Starting today, 12 October 2026."],
"question": "Are you happy for us to set this up?"
}
}
}
When show is true, show each part on one screen, ending with the
confirmation question. When the customer says yes, record it:
curl -X POST https://subcontext.com/api/v1/quotes/0199a3c2-7c00-7a77-9f9e-7c8d9eafb0c7/confirm \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
{ "data": { "owed": [] } }
When show is false, skip the screen. When sellable is false, the product
can't be bought on a form. Offer a conversation with your team instead.
9. Take the first premium#
If the application's direct_debit_first is true, the first premium is the
first Direct Debit collection, so skip to step 10.
Otherwise the customer pays the first month by card, through Stripe:
curl -X POST https://subcontext.com/api/v1/quotes/0199a3c2-7c00-7a77-9f9e-7c8d9eafb0c7/payment_intent \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
{
"data": {
"payment_intent": {
"id": "pi_3Example",
"client_secret": "pi_3Example_secret_x9Y8",
"status": "requires_payment_method",
"amount_pence": 1245,
"currency": "GBP"
},
"publishable_key": "pk_live_51Example",
"stripe_account": "acct_1Example"
}
}
Mount Stripe's Payment Element with the publishable_key, the client_secret
and (when it isn't null) stripe_account, and confirm the payment with
Stripe.js. Asking again for the same quote gives back the same payment, so
the customer isn't charged twice. If your firm's Stripe account can't take payments yet, this
answers 503 payments_unavailable.
10. Set up the policy#
Once the payment has succeeded (or straight after the Direct Debit, for a Direct-Debit-first product):
curl -X POST https://subcontext.com/api/v1/quotes/0199a3c2-7c00-7a77-9f9e-7c8d9eafb0c7/bind \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "payment_intent_id": "pi_3Example" }'
201 Created, with the policy and the customer's links as data (shortened):
{
"data": {
"policy_number": "POL-AB-P001-000042",
"commencement_date": "2026-10-12",
"total_premium_monthly_pence": 1245,
"first_premium_record": { "amount_pence": 1245, "status": "collected", "processor": "stripe",
"processor_reference": "pi_3Example" },
"policy_hub_path": "https://www.example.com/policy/Ro3oicP01pB-RlxbOFd01A2f7fgMDCAe",
"schedule_path": "https://www.example.com/policy/Ro3oicP01pB-RlxbOFd01A2f7fgMDCAe/schedule",
"documents": [
{ "kind": "policy_wording", "name": "Policy wording",
"path": "https://www.example.com/policy/Ro3oicP01pB-RlxbOFd01A2f7fgMDCAe/documents/0199a3c2-7e00-7ebb-9d3c-b0c1d2e3f40b" }
],
"first_collection_on": "2026-11-12",
"collection_day_of_month": 12,
"wrap_up": null
}
}
Show the customer their policy number and give them the policy_hub_path,
where they manage their policy and find its documents. If your firm has
closing words for after purchase, they're in wrap_up. When it includes a
marketing question, send the customer's answers to Record marketing choices.
Everything is checked again here, so these errors can appear:
| Answer | What to do |
|---|---|
402 payment_not_succeeded |
The card payment needs the customer (for example a bank check). Let Stripe.js finish it, then try again. |
409 quote_expired or quote_not_bindable |
Quote again. |
422 sale_steps_owed |
Something from step 8 is owed. Show it, confirm, and try again. |
422 direct_debit_missing |
Do step 7 first. |
422 payment_missing or payment_mismatch |
Send the payment_intent_id of the payment made for this quote. |
It's safe to retry. Once the policy is set up, asking again gives back the
same policy with 200 OK. The policy starts today. A server-only application
may send a commencement_date (up to 90 days ahead) and its own
policy_number.
Joint cover#
For cover on two people, the journey asks whether it's joint. Save the second
person with Save the second person's details
after the first. They appear as the application's joint_customer, and the
quote's basis is joint.
Group plans (priced per member)#
Some products, such as an employer's health plan, are priced at a monthly
amount for each person on them rather than by an amount of cover and a term.
Each cover in the product's covers says so with a pricing_basis of
per_member. It has a title (one level of the plan, such as Diagnosis),
its monthly price for each person (member_premium_pence), and
members_factor, the key of the answer that holds how many people are on it.
An add-on that everyone on the plan can have, such as an employee assistance
programme, also has an opt_in_factor: the key of the yes or no answer that
adds it.
"covers": [
{ "cover_type": "healthcare", "title": "Diagnosis", "pricing_basis": "per_member",
"member_premium_pence": 2400, "members_factor": "members_diagnosis", "opt_in_factor": null },
{ "cover_type": "healthcare", "title": "Stress Support", "pricing_basis": "per_member",
"member_premium_pence": 150, "members_factor": "members_total", "opt_in_factor": "add_stress_support" }
]
For these products, save the headcounts and opt-ins as answers, like any
other answer. There's no amount of cover or term to save, and
Quote in one request
ignores components: the answers decide which covers are on the plan and
for how many people. The quote has a component for each cover with people on
it, with its member_count and title, and its price is that many times the
cover's member_premium_pence:
"components": [
{ "cover_type": "healthcare", "title": "Diagnosis", "member_count": 12, "sum_assured_pence": 0,
"term_years": null, "premium_monthly_pence": 28800 },
{ "cover_type": "healthcare", "title": "Stress Support", "member_count": 12, "sum_assured_pence": 0,
"term_years": null, "premium_monthly_pence": 1800 }
]
Answers that put nobody on the plan can't be priced, so the quote answers
422 quote_failed. On every other product, member_count is null.
Whole-of-market products#
A whole-of-market product compares quotes from providers across the market, and the customer finishes with the provider they choose. After the answers and details:
- Start the search with Search the market.
It answers straight away with the application, whose
market_quotes.statusispending. - Get the application
every few seconds until
market_quotes.statusiscompleted(orfailed). The quotes are inmarket_quotes.results, cheapest first. - When the customer chooses one, send its
result_idto Choose a market quote. - Get the link to the provider and send the customer there to finish.
{
"data": {
"status": "in_capture",
"market_quotes": {
"status": "completed",
"stale": false,
"provider_count": 2,
"selected_result_id": null,
"results": [
{ "result_id": "0199a3c2-7d01-7daa-8c2b-afb0c1d2e3fa", "provider_name": "Example Life",
"product_name": "Example Life Protection", "premium_pence": 1180, "premium_monthly": "£11.80",
"status": "success", "expiry_date": "2026-11-12", "selected": false }
]
}
}
}
If the customer changes an answer, stale becomes true. Search again before
they choose.