Embed SDK Integration Guide
Drop the HelloBill onboarding flow into your product with a one-line frontend mount and a one-line backend middleware. Your customers move into their new home, you ship a finished tenant experience — we handle utility setup, switching, and post‑handoff billing.
This guide covers the Embed SDK integration: how to ship a HelloBill-powered onboarding experience inside your product. Your server mounts a middleware that exchanges a session payload for a token; your frontend mounts the embed; HelloBill handles the rest.
Base URL:
| Environment | Partner API base |
|---|---|
| Production | https://partnerapi.hellobill.app/api/v1 |
Mode (sandbox vs production) is determined by the client_id prefix
(sb_ or live_) carried in the access token — not by
the host. See Sandbox vs production.
The security model keeps API credentials off the browser
entirely. The @hello-bill/node package
mounts the routes the embed needs (session creation, LoA capture, status
polling) under a single base path on your server. You configure one mount;
everything else is handled.
Quick start #
The minimum viable integration: 60 seconds, one server file, one client file.
You'll need client_id + client_secret from PartnerDock
(sandbox keys are prefixed sb_).
1. Install
npm install @hello-bill/node @hello-bill/sdk
2. Mount the middleware (server)
import express from 'express';
import { createHellobillRouter } from '@hello-bill/node/express';
const app = express();
app.use(express.json());
app.use('/api/hellobill', createHellobillRouter({
clientId: process.env.HELLOBILL_CLIENT_ID, // sb_… or live_…
clientSecret: process.env.HELLOBILL_CLIENT_SECRET,
buildSessionPayload: async (req) => ({
customer: { type: 'tenant', email: req.body.email },
addresses: {
current: {
address_line_1: req.body.addressLine1,
city: req.body.city,
postcode: req.body.postcode,
},
// Optional: only when the customer is moving OUT of a previous property
previous: req.body.previousAddress ?? undefined,
},
move: {
in: { move_in_date: req.body.moveInDate },
// Optional: when partner wants HelloBill to notify incumbents at the previous address
out: req.body.moveOut ?? undefined,
},
consent: { data_sharing_accepted: true, data_sharing_accepted_at: new Date().toISOString() },
}),
}));
3. Mount the embed (client)
<script src="https://embed-sandbox.hellobill.app/v1/sdk.js"></script>
<div id="hellobill-mount"></div>
<script>
HelloBill.init({
baseEndpoint: '/api/hellobill',
mountTo: '#hellobill-mount',
sessionData: {
email: currentCustomer.email,
addressLine1: property.addressLine1,
addressLine3: property.addressLine3, // NEW v11 — optional 3rd line
city: property.city,
postcode: property.postcode,
moveInDate: occupancy.moveInDate,
// Optional B2B: customer's billing address differs from property
customerAddress: currentCustomer.address,
// Optional move-out journey
previousAddress: previousProperty, // PropertyObject; null when no move-out
moveOut: undefined, // MoveOutObject; omit for move-in only
},
onComplete(result) {
window.location.href = '/move/complete?account=' + result.customer_id;
},
});
</script>
The API returns embed_base_url — the embed origin only
(e.g. https://embed-sandbox.hellobill.app), with no path or token.
The SDK validates this origin against its compiled allowlist and tier, then composes
the iframe URL as {embed_base_url}/{sdk_version}/onboard?token={session_token}.
The version segment comes from the pinned SDK build; the origin comes from the API.
Do not construct the iframe URL yourself — always use the SDK.
The customer sees the HelloBill onboarding flow, picks their products, signs the
Letter of Authority, and you get a customer_id back in
onComplete — plus a stream of webhook
events for everything that happens after handoff.
API credentials #
Provisioned via PartnerDock → API Credentials → Create Key Pair:
client_id— prefixedsb_(sandbox) orlive_(production), e.g.sb_goodlord_abc123.client_secret— treat as a password. Never expose in browser bundles or client source.
When using the Embed SDK with the middleware pattern, you never write auth
code. createHellobillRouter / createHellobillHandler
handle token exchange, caching, and refresh internally. The endpoint and error
codes below are reference only.
Token exchange
POST /api/v1/auth/partner/token
POST /api/v1/auth/partner/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&client_id=sb_goodlord_abc&client_secret=***
Response:
{
"access_token": "eyJhbGciOiJSUzI1NiJ9...",
"token_type": "Bearer",
"expires_in": 300
}
- Cache the token. Refresh at
expires_in − 30s. - The
@hello-bill/nodeSDK handles caching and refresh automatically. - Re-authenticate and retry once on any
401— the API does not distinguish wrong credentials from expired or malformed tokens.
Auth error codes
| HTTP | Code | Retryable | Cause |
|---|---|---|---|
| 400 | validation.failed | no | Missing or invalid grant_type, client_id, or client_secret in the request body |
| 401 | auth.invalid_credentials | no (re-auth) | Keycloak rejected the credentials (wrong client_id/client_secret), or a /partner/* request presented a missing, malformed, or expired Bearer token |
| 403 | auth.insufficient_scope | no | (target) Key authenticated but lacks permission for the resource |
| 429 | rate.limited | yes | (target) Token endpoint rate limit — see Rate limits |
| 502 | internal.error | yes | Keycloak unreachable, or returned a non-token response |
A wrong secret, a malformed JWT, and an expired JWT all surface as 401 auth.invalid_credentials on /partner/* routes — the API does not distinguish between them in the error code, to keep the auth boundary opaque.
Conventions #
These conventions apply throughout the API and you can rely on them when writing parsers, validators, and SDKs:
| Surface | Convention | Example |
|---|---|---|
| JSON body field names | snake_case | customer.first_name, data_sharing_accepted |
| Query parameters | snake_case | ?force_refresh=true&product_ids=… |
| HTTP header names | Kebab-Case-Title (RFC 7230) | Authorization, Idempotency-Key, X-HelloBill-Signature |
| Path segments | lowercase, hyphenated | /partner/sessions/:id/products |
| Enum string values | snake_case | "dual_fuel", "owner_occupier" |
| TypeScript SDK fields | camelCase | client.sessions.create({ … }) — language-idiomatic |
| Time values | ISO 8601 UTC | "2026-05-10T14:32:00Z" |
| Date values | ISO 8601 date | "2026-05-10" |
| Monetary values | Integer pence, GBP | monthly_amount_pence: 16724 |
Standard REST/JSON conventions throughout. No camelCase in JSON bodies; no snake_case in SDK identifiers.
Resource scoping #
All /partner/* endpoints are scoped to the partner_org_id derived from the access_token. Cross-partner access is impossible:
- A request for a resource (session, customer, LoA) owned by a different partner returns
404 not_found(not403), to prevent existence enumeration. - IDs returned by the API (
session_id,customer_id,loa_id,product_id,setup_id) are opaque to partners; do not parse or correlate them. - Sandbox and production are separate scope realms — see Sandbox & testing.
Sandbox vs production #
client_id prefix | Mode | Side effects |
|---|---|---|
sb_ | Sandbox | Mock discovery, no real emails, no real switches |
live_ | Production | Real discovery, real emails, real switches |
Session creation #
POST /api/v1/partner/sessions
The SDK calls this endpoint from within your middleware to initiate a session.
Triggers async product discovery and returns a session_token for the
embed plus a session_id for subsequent reference.
As an Embed SDK partner, you do not call this endpoint directly
— the middleware does it. But you do define the
buildSessionPayload function that produces the request body, so this
section is your contract.
Request headers
| Header | Required | Notes |
|---|---|---|
Authorization | required | Bearer {access_token} — handled by SDK |
Content-Type | required | application/json — handled by SDK |
Idempotency-Key | recommended | UUID v4. Safe to retry on network failure. |
Request payload #
The full shape you return from buildSessionPayload:
{
"referral_id": "REF-2026-05-10-abc123",
"customer": {
"type": "tenant",
"first_name": "Anya",
"last_name": "Haq",
"email": "anya@example.com",
"phone": "+447700900000",
"date_of_birth": "1995-03-15",
"is_primary_occupant": true,
"on_psr": false,
"address": {
"address_line_1": "Goodlord Lettings",
"address_line_2": "Unit 4, Lensbury Way",
"address_line_3": "Thamesmead",
"city": "London",
"postcode": "SE2 9TG"
}
},
"occupants": [
{
"type": "tenant",
"first_name": "James",
"last_name": "Haq",
"email": "james@example.com",
"phone": "+447700900001",
"is_primary_occupant": false
}
],
"addresses": {
"current": {
"address_line_1": "14 Clapham Road",
"address_line_2": "Flat 3",
"address_line_3": null,
"city": "London",
"postcode": "SW8 2JB",
"uprn": "100023456789",
"bedrooms": 2,
"number_occupants": 2,
"property_type": "flat",
"epc_rating": "C",
"heating_type": "gas_central"
},
"previous": {
"address_line_1": "Holly Cottage",
"address_line_2": "Mill Lane",
"address_line_3": "Saxton-cum-Scarthingwell",
"city": "Tadcaster",
"postcode": "LS24 9PX",
"uprn": "100022345678",
"property_type": "house"
}
},
"move": {
"in": {
"move_in_date": "2026-06-01",
"bill_type": "single",
"rent_pcm": 1450,
"tenancy_start_date": "2026-06-01",
"tenancy_end_date": "2027-05-31"
},
"out": {
"move_out_date": "2026-05-30",
"tenancy_end_date": "2026-05-31",
"reason": "end_of_tenancy",
"forwarding_address": {
"address_line_1": "14 Clapham Road",
"address_line_2": "Flat 3",
"city": "London",
"postcode": "SW8 2JB"
},
"notify": {
"energy": true,
"water": true,
"council_tax": true,
"tv_licence": false,
"broadband": true,
"mobile": false
},
"final_reads": [
{
"category": "energy",
"fuel": "electricity",
"meter_serial_number": "S95L00012345",
"reading": 42180,
"read_date": "2026-05-30"
},
{
"category": "energy",
"fuel": "gas",
"meter_serial_number": "G4A00098765",
"reading": 9817,
"read_date": "2026-05-30"
}
]
}
},
"meters": {
"mpan": "1900000123456",
"mprn": "7612345678",
"current_supplier_electricity": "British Gas",
"current_supplier_gas": "British Gas"
},
"consent": {
"data_sharing_accepted": true,
"marketing_accepted": false,
"data_sharing_accepted_at": "2026-05-10T14:30:00Z"
},
"context": [
{ "name": "device_type", "type": "string", "value": "iPhone 16 Pro" },
{ "name": "utm_source", "type": "string", "value": "email" },
{ "name": "utm_campaign", "type": "string", "value": "move_in_2026_q2" },
{ "name": "gl_tenancy_id", "type": "string", "value": "TEN-89234" },
{ "name": "session_seconds","type": "number", "value": 42 }
]
}
customer.email · addresses.current.postcode ·
addresses.current.address_line_1 ·
consent.data_sharing_accepted: true. Everything else skips screens
in the adaptive embed flow — richer payload, shorter customer journey.
Partners who only need move-in support can omit addresses.previous
and move.out entirely. If move.out is present,
addresses.previous is required (the inverse is
allowed — partners may supply addresses.previous alone to
capture the previous address for the record, with no supplier notifications
until move.out is added).
customer.address is optional
When omitted, the server defaults the customer's billing/contact address to
addresses.current. Supply explicitly when the customer's contact
address differs from the property the utilities serve — common for
property-management companies acting on behalf of tenants, lettings agents,
or any B2B integration where the account holder is not the occupant.
customer required #
The account holder. Typically the primary occupant of the property; for B2B integrations, the account holder may differ from the occupant — see customer.address.
| Field | Type | Required | Default | Validation | Notes |
|---|---|---|---|---|---|
type | enum | required | — | CustomerType | Legal relationship to property |
email | string | required | — | RFC 5322, max 254 chars | Account creation + magic link |
first_name | string | optional | null | Max 100 chars | Skips name screen |
last_name | string | optional | null | Max 100 chars | Skips name screen |
phone | string | optional | null | E.164 format | SMS auth fallback |
date_of_birth | string | optional | null | ISO 8601 YYYY-MM-DD | Required by some energy suppliers |
is_primary_occupant | boolean | optional | true | — | False if creating on behalf of another |
on_psr | boolean | optional | null | — | Priority Services Register status |
address | AddressObject | optional | addresses.current | See AddressObject | Customer's billing/contact address. Omit to default to the current property. Supply explicitly for B2B integrations where the account holder's address differs from the property. |
CustomerType enum — exhaustive:
| Value | When to use |
|---|---|
owner_occupier | Owns and occupies (freehold or long leasehold) |
tenant | Renting under tenancy agreement (AST, periodic, regulated) |
licensee | Licence to occupy — legally distinct from a tenancy |
lodger | Lodger or sub-tenant in another person's home |
management_company | Professional managing agent acting on behalf of owner |
other | Any relationship not covered above |
occupants optional, array #
Additional people living at the property. May be empty [] or omitted.
| Field | Type | Required | Default | Validation |
|---|---|---|---|---|
type | enum | required | — | OccupantType |
first_name | string | optional | null | Max 100 chars |
last_name | string | optional | null | Max 100 chars |
email | string | optional | null | RFC 5322 |
phone | string | optional | null | E.164 |
is_primary_occupant | boolean | optional | false | At most one true per array |
OccupantType enum: owner · tenant · licensee · lodger · guarantor · other
AddressObject shared base #
Used wherever an address appears: customer.address, addresses.current, addresses.previous, move.out.forwarding_address.
| Field | Type | Required | Default | Validation | Notes |
|---|---|---|---|---|---|
address_line_1 | string | required | — | Free text, max 255 chars (server normalises whitespace, casing, BS 7666 punctuation) | First display line — usually the building name/number + street |
address_line_2 | string | optional | null | Max 255 chars | Second display line — usually a flat/unit reference |
address_line_3 | string | optional | null | Max 255 chars | Third display line — rural / industrial-estate / multi-building cases (Royal Mail PAF supports up to five lines; three is sufficient for the vast majority of UK addresses) |
city | string | required | — | Max 100 chars | Post town |
postcode | string | required | — | UK postcode, 3–8 chars | — |
uprn | string | optional | null | 12-digit UPRN | Royal Mail / OS Unique Property Reference. Canonical when supplied. |
country | enum | optional | "GB" | "GB" (reserved for future expansion) | Currently UK addresses only |
addresses required #
Grouped current property (required) + optional previous property for move-out journeys.
| Field | Type | Required | Notes |
|---|---|---|---|
addresses.current | PropertyObject | required | The property the customer is moving INTO. Where utilities are set up. |
addresses.previous | PropertyObject | optional | The property the customer is moving OUT of, if applicable. Required when move.out is present. |
PropertyObject extends AddressObject with optional physical characteristics: bedrooms (1–20), number_occupants (1–50), property_type (PropertyType enum), epc_rating (A–G), heating_type (HeatingType enum). For addresses.previous, partners typically supply only the address lines + postcode + UPRN.
PropertyType: detached · semi_detached · terraced · flat · bungalow · maisonette · other
HeatingType: gas_central · electric · oil · heat_pump · district · solid_fuel · other
move required #
Unified move-in (required) + optional move-out details.
move.in required
| Field | Type | Required | Default | Validation | Notes |
|---|---|---|---|---|---|
move_in_date | string | required | — | ISO 8601 YYYY-MM-DD | Skips move-in screen |
bill_type | enum | optional | "single" | single | split | How bills are apportioned |
rent_pcm | integer | optional | null | Positive integer, GBP | Affordability context |
tenancy_start_date | string | optional | null | ISO 8601 | Tenancy commencement (may pre-date physical move-in) |
tenancy_end_date | string | optional | null | ISO 8601, after tenancy_start_date | Tenancy expiry |
BillType: single (one person responsible) · split (each occupant pays their share — Glide, Huddle, Acasa pattern)
move.out optional
Move-out captures the customer's departure from addresses.previous. Supplying move.out requires addresses.previous to be present.
| Field | Type | Required (when move.out present) | Notes |
|---|---|---|---|
move_out_date | string | required | ISO 8601. Accepted up to 90 days in the past and 180 days in the future. |
tenancy_end_date | string | optional | May differ from physical move-out (council-tax liability often follows tenancy end) |
reason | enum | optional | end_of_tenancy · sale · transfer · bereavement · other |
forwarding_address | AddressObject | optional | Where final bills, refund cheques, and Royal Mail redirection should go |
notify | object | optional | Per-service opt-in flags: energy, water, council_tax, tv_licence, broadband, mobile — each defaults to false. true opts the customer in to a notification with their incumbent supplier in that category at the previous address. |
final_reads | FinalReadObject[] | optional | Max 8 entries. Final meter readings at the previous property. |
FinalReadObject
| Field | Type | Required | Notes |
|---|---|---|---|
category | enum | required | energy | water |
fuel | enum | required when category: 'energy' | electricity | gas. Omit for water. |
meter_serial_number | string | optional | Max 50 chars |
reading | integer | required | Reading value in the supplier's natural units (kWh / m³) |
read_date | string | required | ISO 8601 YYYY-MM-DD, on or before move.out.move_out_date |
photo_data_base64 | string | optional | PNG, max 500 KB decoded. Audit evidence; supplier may request for disputed readings. |
estimated | boolean | optional | true if estimated rather than physically taken |
meters optional #
All sub-fields optional.
| Field | Type | Validation | Effect |
|---|---|---|---|
mpan | string | 13 digits | Skips Electralink electricity lookup |
mprn | string | 6–10 digits | Skips Electralink gas lookup |
current_supplier_electricity | string | Max 100 chars | Skips Electralink lookup |
current_supplier_gas | string | Max 100 chars | Skips Electralink lookup |
consent required #
| Field | Type | Required | Validation | Notes |
|---|---|---|---|---|
data_sharing_accepted | boolean | required | Must be literal true | GDPR lawful basis. Rejected if false. |
data_sharing_accepted_at | string | required | ISO 8601 datetime | When consent was captured |
marketing_accepted | boolean | optional | — | Default: false |
This consent object covers data sharing and processing consent
only (GDPR). The Letter of Authority — authorising HelloBill to act on
the customer's behalf with utility suppliers — is a separate signature
captured later inside the embed flow.
context optional, array #
Typed key–value metadata. Echoed verbatim in all webhook payloads under data.context.
"context": [
{ "name": "device_type", "type": "string", "value": "iPhone 16 Pro" },
{ "name": "utm_source", "type": "string", "value": "email" },
{ "name": "gl_tenancy_id", "type": "string", "value": "TEN-89234" },
{ "name": "session_seconds","type": "number", "value": 42 },
{ "name": "marketing_opt_in","type": "boolean","value": false }
]
Each entry is one of:
| { name: string; type: 'string'; value: string }
| { name: string; type: 'number'; value: number }
| { name: string; type: 'boolean'; value: boolean }
Constraints: max 50 entries · name max 100 chars · string value max 500 chars · name unique within the array.
Idempotency #
POST /partner/sessions (and the other state-changing POSTs —
/loa, /bank-details, /customers) support an
explicit Idempotency-Key request header. There is no
automatic deduplication based on customer email or address; idempotency is keyed
solely on the header you send.
| Property | Behaviour |
|---|---|
| Key transport | Idempotency-Key request header (opaque string; UUID v4 recommended) |
| Storage | The key is SHA-256 hashed and persisted server-side with the response |
| Window | 24 hours |
| Same key + same body | Returns the original stored response (200 OK for the repeat; the first call returned 201 Created) |
| Same key + different body | Returns 409 idempotency.fingerprint_mismatch — the key is bound to the exact request body it was first used with |
| No key supplied | Each call creates a new resource — no deduplication |
Generate one Idempotency-Key per logical operation (e.g. one per customer's
session-creation attempt) and reuse it on network-failure retries. Do not reuse a key
across genuinely different requests, or you will get a 409.
Response — 201 Created (or 200 on idempotent match) #
{
"session_id": "ses_01HX7G2K...",
"session_token": "eyJhbGciOiJIUzI1NiIs...",
"embed_base_url": "https://embed-sandbox.hellobill.app",
"discovery_status": "running",
"estimated_ready_seconds": 8,
"provided_fields": [
"customer.first_name", "customer.last_name", "customer.email",
"addresses.current.postcode", "addresses.current.address_line_1",
"addresses.current.bedrooms", "move.in.move_in_date",
"addresses.previous.postcode", "addresses.previous.address_line_1",
"move.out.move_out_date", "move.out.forwarding_address.postcode",
"move.out.notify.energy", "move.out.notify.water"
],
"expires_at": "2026-05-11T14:30:00Z",
"gmaps_key": "AIza..."
}
| Field | Type | Always present | Notes |
|---|---|---|---|
session_id | string | yes | Stable reference for API calls and webhooks |
session_token | string | yes | JWT, 300 s. Pass to embed SDK. |
embed_base_url | string | yes | Embed origin only (e.g. https://embed-sandbox.hellobill.app), no path or token. The SDK composes the iframe URL as {embed_base_url}/{sdk_version}/onboard?token={session_token}. Origin-only is enforced (a URL with a path is rejected). |
discovery_status | enum | yes | running | complete |
estimated_ready_seconds | integer | no | Omitted when discovery_status is complete |
provided_fields | string[] | yes | Dot-notation paths of recognised fields |
expires_at | string | yes | Session expiry — 24 h from creation. After this the session_id returns 404. |
gmaps_key | string | no | Google Maps Embed API key for the embed's address/map screen. Present when configured for the partner/environment; the SDK forwards it to the iframe as integrations.gmapsKey. Never required for Direct API integrations. |
List sessions #
GET /api/v1/partner/sessions
Returns recently-created sessions for the authenticated partner, filterable by tenant email and/or postcode. Useful when a partner has lost a session_id (e.g. support ticket) or wants to reconcile their own records against HelloBill.
Query parameters
| Parameter | Required | Default | Notes |
|---|---|---|---|
email | optional | — | Filter by customer.email (exact match) |
postcode | optional | — | Filter by addresses.current.postcode (normalised) |
created_after | optional | 30 days ago | ISO 8601 datetime lower bound |
created_before | optional | now | ISO 8601 datetime upper bound |
status | optional | all | created | customer_created | expired |
cursor | optional | null | Opaque cursor from previous response |
limit | optional | 50 | Integer 1–200 |
{
"sessions": [
{
"session_id": "ses_01HX7G2K...",
"referral_id": "REF-2026-05-10-abc123",
"email": "anya@example.com",
"postcode": "SW8 2JB",
"address_line_1": "14 Clapham Road",
"status": "customer_created",
"customer_id": "CU-7310-5480-8",
"created_at": "2026-05-10T14:30:00Z",
"expires_at": "2026-05-11T14:30:00Z"
}
],
"next_cursor": "eyJvZmZzZXQiOjUwfQ=="
}
Listing returns a SessionSummary projection — not the full session payload. Each entry contains: session_id, referral_id?, email, postcode, address_line_1, status, customer_id?, created_at, expires_at, discovery_status?. The flat postcode and address_line_1 fields refer to addresses.current.
Single session fetch #
GET /api/v1/partner/sessions/:id
Retrieves the summary for a single session by ID. Returns a single SessionSummary projection — the same shape as one element of the sessions[] array from the list endpoint: session_id, referral_id?, email, postcode, address_line_1, status, customer_id?, created_at, expires_at, discovery_status?. This is not a full payload echo of the session creation request.
GET /api/v1/partner/sessions/ses_01HX7G2K...
Authorization: Bearer {access_token}
A session belonging to a different partner returns 404 session.not_found (see resource scoping).
Bank details optional #
Some integrations need to capture the customer's UK bank account details so HelloBill can set up Direct Debit mandates with utility suppliers on the customer's behalf. This endpoint is optional — sessions can complete account creation without it (e.g. when DD setup is deferred to supplier portals).
The endpoint accepts standard UK bank account credentials (sort code + account number) plus consent confirmation that the customer accepts the Direct Debit Guarantee. Account number is validated against the UK BACS modulus-checking algorithm (Vocalink standard) in addition to shape validation.
The browser Embed SDK (@hello-bill/sdk) includes a Direct Debit screen
in the embed iframe. The embed collects the customer's sort code, account number, and
Direct Debit Guarantee consent, then submits them itself (after LoA signing, before
account creation). Partners using the embed do not build their own DD
collection screen and do not call this endpoint themselves.
The POST /partner/sessions/:id/bank-details endpoint documented below
is for Direct API / server-side partners who are not using the embed
iframe.
POST bank-details #
POST /api/v1/partner/sessions/:id/bank-details
Request headers
| Header | Required | Notes |
|---|---|---|
Authorization | required | Bearer {access_token} |
Content-Type | required | application/json |
Idempotency-Key | recommended | UUID v4. Safe to retry on network failure. |
Request body
{
"sort_code": "089999",
"account_number": "66374958",
"account_holder_name": "John Smith",
"consent": {
"direct_debit_authorised": true,
"authorised_at": "2026-05-19T10:00:00Z",
"consent_text_version": "ddg-v1"
}
}
| Field | Type | Required | Notes |
|---|---|---|---|
sort_code | string | required | 6 digits. May be supplied with hyphens (08-99-99) or spaces; HelloBill normalises to digits-only. |
account_number | string | required | 6–10 digits. Most UK accounts are 8 digits. |
account_holder_name | string | required | 1–100 characters. |
consent.direct_debit_authorised | boolean | required | Must be literal true. Submissions with false or omitted are rejected with 400 validation.failed. Field name follows UK DDIM terminology. |
consent.authorised_at | string | required | ISO 8601 timestamp of when the customer authorised the mandate. |
consent.consent_text_version | string | required | String identifier for the consent copy shown to the customer (allows audit replay). |
Response — 200 OK
{
"session_id": "ses_abc123",
"bank_details": {
"id": "bnk_xyz789",
"account_number_ending": "4958",
"sort_code_masked": "08-**-99",
"account_holder_name": "John Smith",
"bank_name": "Barclays",
"modulus_check_passed": true,
"status": "collected",
"collected_at": "2026-05-19T10:00:01Z",
"consent": {
"direct_debit_authorised": true,
"authorised_at": "2026-05-19T10:00:00Z",
"consent_text_version": "ddg-v1"
}
},
"expires_at": "2026-05-20T10:00:00Z"
}
Full account number is never returned. Only account_number_ending
(last 4 digits) appears in subsequent responses. sort_code_masked reveals
first 2 + last 2 digits with middle 2 redacted (format NN-**-NN).
bank_name is HelloBill's best-effort lookup from the sort code (may be
null for non-mainstream banks).
The status field is 'collected' immediately after a successful
POST. Future revisions may introduce 'pending' (async second-line bank
verification queued) and 'failed' (post-collection verification failure
detected before the first Direct Debit attempt). Handle the three-state union to be
forward-compatible: 'collected' | 'pending' | 'failed'.
GET bank-details #
GET /api/v1/partner/sessions/:id/bank-details
Retrieve previously-collected bank details (masked). Returns the same BankDetailsResponse shape as the POST response.
GET /api/v1/partner/sessions/ses_abc123/bank-details
Authorization: Bearer {access_token}
Validation & BACS modulus check #
Two-stage validation is applied on every POST:
- Stage 1 — Shape.
sort_codematches/^\d{6}$/after normalisation.account_numbermatches/^\d{6,10}$/.account_holder_namelength 1–100 after trimming. - Stage 2 — UK BACS Modulus Check. Applies Vocalink's modulus-checking algorithm using current
valacdos.txtweight tables andscsubtab.txtsort-code substitutions. Algorithms applied per sort-code range:MOD10,MOD11, orDBLAL, with exceptions 1–14 as published. Failures return422 bank_details.invalid_modulus.
Idempotency
Follows the standard idempotency contract (see Idempotency). Supply an Idempotency-Key header. Replaying the same key with the same body returns the previously created BankDetailsObject without re-validating; the same key with a different body returns 409 idempotency.fingerprint_mismatch.
Without a key, re-submitting bank details for the same session replaces the stored record (latest write wins) — until the session is finalised (post-account-creation), after which submissions return 409 bank_details.session_locked.
Errors
| HTTP | error.code | When |
|---|---|---|
| 400 | validation.failed | Missing/invalid fields, or consent.direct_debit_authorised not true |
| 422 | bank_details.invalid_modulus | Account number fails the BACS modulus check |
| 404 | session.not_found | Session does not exist or is expired |
| 404 | bank_details.not_found | (GET only) No bank details collected for this session |
| 409 | bank_details.session_locked | Session already finalised (post-account-creation) |
| 409 | idempotency.fingerprint_mismatch | Idempotency-Key reused with a different body |
Earlier spec revisions listed bank_details.invalid_sort_code, bank_details.invalid_account_number, bank_details.modulus_check_failed, and consent.required. The current implementation does not emit those: shape problems return validation.failed (400) and modulus failures return bank_details.invalid_modulus (422).
Webhook
On successful collection, bank_details.collected is emitted. See Event reference for the full payload shape.
How the Embed SDK works #
Two moving parts: the server middleware (mounts under a base path on your backend) and the client embed (renders in your frontend, calls the middleware). Credentials live server-side. The browser never sees them.
- Server start. Your backend mounts
createHellobillRouter({clientId, clientSecret, buildSessionPayload})once at boot. - Customer clicks "Set up". Your frontend calls
HelloBill.init({baseEndpoint, sessionData}). - Embed → your middleware. The embed POSTs to
{baseEndpoint}/sessionwith the session data your frontend supplied. - Middleware → HelloBill API. Your
buildSessionPayloadruns server-side, transforms the data, and the middleware callsPOST /api/v1/partner/sessionswith yourclient_secret-derivedaccess_token. - HelloBill → middleware. Returns
session_id+session_token+provided_fields. Async product discovery starts in the background. - Middleware → embed. Returns only the
session_token— credentials stay on your server. - Embed renders. Exchanges the token for session context; adaptive flow skips screens for any field listed in
provided_fields. - Customer journey. Confirm details → select products → draw LoA signature on screen.
- HelloBill → your webhook endpoint. At each stage:
session.embed_opened,session.products_selected,session.loa_signed,session.account_created. - Embed → your
onComplete. Returns{customer_id, selected_products, loa_signed_at}. You redirect the customer to your next screen.
The flow above maps to four concrete pieces of code in your repo: one server
mount, one frontend mount, one webhook handler, and an optional
onComplete callback. We'll write each.
Server — middleware setup #
Mount @hello-bill/node under a single base path on your server. The
package exposes adapters for the major Node frameworks; pick yours.
Express · Hono · Koa
import express from 'express';
import { createHellobillRouter } from '@hello-bill/node/express';
const app = express();
app.use(express.json());
app.use('/api/hellobill', createHellobillRouter({
clientId: process.env.HELLOBILL_CLIENT_ID, // sb_ = sandbox, live_ = production
clientSecret: process.env.HELLOBILL_CLIENT_SECRET,
buildSessionPayload: async (req) => ({
referral_id: req.body.referralId,
customer: {
type: 'tenant',
first_name: req.body.firstName,
last_name: req.body.lastName,
email: req.body.email,
phone: req.body.phone,
// Optional B2B: account holder's address differs from the property
address: req.body.customerAddress ?? undefined,
},
addresses: {
current: {
address_line_1: req.body.addressLine1,
address_line_2: req.body.addressLine2,
address_line_3: req.body.addressLine3, // NEW v11 — third line
city: req.body.city,
postcode: req.body.postcode,
bedrooms: req.body.bedrooms,
number_occupants: req.body.numberOfOccupants,
},
// Optional: previous property for move-out journeys
previous: req.body.previousAddress ?? undefined,
},
move: {
in: {
move_in_date: req.body.moveInDate,
bill_type: req.body.billType ?? 'single',
},
// Optional: when partner wants HelloBill to notify incumbents at the previous address
out: req.body.moveOut ?? undefined,
},
consent: {
data_sharing_accepted: true,
data_sharing_accepted_at: new Date().toISOString(),
},
context: [
{ name: 'device_type', type: 'string', value: req.headers['x-device-type'] ?? 'unknown' },
{ name: 'utm_source', type: 'string', value: req.body.utmSource ?? '' },
{ name: 'gl_tenancy_id', type: 'string', value: req.body.tenancyId },
],
}),
}));
import { Hono } from 'hono';
import { createHellobillRouter } from '@hello-bill/node/hono';
const app = new Hono();
app.route('/api/hellobill', createHellobillRouter({
clientId: process.env.HELLOBILL_CLIENT_ID!,
clientSecret: process.env.HELLOBILL_CLIENT_SECRET!,
buildSessionPayload: async (c) => {
const body = await c.req.json();
return {
customer: { type: 'tenant', email: body.email, first_name: body.firstName, last_name: body.lastName },
addresses: {
current: { address_line_1: body.addressLine1, city: body.city, postcode: body.postcode, bedrooms: body.bedrooms },
previous: body.previousAddress ?? undefined,
},
move: {
in: { move_in_date: body.moveInDate, bill_type: body.billType ?? 'single' },
out: body.moveOut ?? undefined,
},
consent: { data_sharing_accepted: true, data_sharing_accepted_at: new Date().toISOString() },
context: [{ name: 'gl_tenancy_id', type: 'string', value: body.tenancyId }],
};
},
}));
import Koa from 'koa';
import Router from '@koa/router';
import { createHellobillRouter } from '@hello-bill/node/koa';
const app = new Koa();
const router = new Router();
router.use('/api/hellobill', createHellobillRouter({
clientId: process.env.HELLOBILL_CLIENT_ID!,
clientSecret: process.env.HELLOBILL_CLIENT_SECRET!,
buildSessionPayload: async (ctx) => ({
customer: { type: 'tenant', email: ctx.request.body.email },
addresses: {
current: { address_line_1: ctx.request.body.addressLine1, city: ctx.request.body.city, postcode: ctx.request.body.postcode },
},
move: { in: { move_in_date: ctx.request.body.moveInDate } },
consent: { data_sharing_accepted: true, data_sharing_accepted_at: new Date().toISOString() },
}),
}).routes());
app.use(router.routes());
This mounts a small set of routes under /api/hellobill —
session creation, products, LoA capture, status polling, customers, property enrichment, and bank details — that the embed calls
from the browser. You only write buildSessionPayload; the SDK
handles everything else.
HellobillHandlerSet routes
| Route | Method | Handler | Purpose |
|---|---|---|---|
{base}/session | POST | session | Creates session, returns session_token |
{base}/products | GET | products | Proxies GET /partner/sessions/:id/products |
{base}/loa | GET | loa | Proxies GET /partner/sessions/:id/loa. Fetch only — the middleware does not proxy POST /loa. Submit signatures inline via {base}/customers (or call POST /loa directly over HTTP for raw-HTTP Direct API flows). |
{base}/status | GET | status | Proxies GET /partner/customers/:id/status |
{base}/customers | POST | customers | Proxies POST /partner/sessions/:id/customers |
{base}/property | GET | property | Proxies GET /partner/sessions/:id/property — enriched property characteristics |
{base}/bank-details | POST | bankDetails.submit | Proxies POST /partner/sessions/:id/bank-details |
{base}/bank-details | GET | bankDetails.get | Proxies GET /partner/sessions/:id/bank-details |
Next.js (App Router)
// app/api/hellobill/[...slug]/route.ts
import { createHellobillHandler } from '@hello-bill/node/next';
export const POST = createHellobillHandler({
clientId: process.env.HELLOBILL_CLIENT_ID!,
clientSecret: process.env.HELLOBILL_CLIENT_SECRET!,
buildSessionPayload: async (req) => {
const body = await req.json();
return {
referral_id: body.referralId,
customer: { type: 'tenant', email: body.email, first_name: body.firstName, last_name: body.lastName },
addresses: {
current: { address_line_1: body.addressLine1, city: body.city, postcode: body.postcode, bedrooms: body.bedrooms },
previous: body.previousAddress ?? undefined,
},
move: {
in: { move_in_date: body.moveInDate, bill_type: body.billType ?? 'single' },
out: body.moveOut ?? undefined,
},
consent: { data_sharing_accepted: true, data_sharing_accepted_at: new Date().toISOString() },
context: [{ name: 'gl_tenancy_id', type: 'string', value: body.tenancyId }],
};
},
});
export const GET = POST; // Same handler serves all sub-paths
Mount the middleware behind your existing auth/session check.
The middleware doesn't know who your logged-in user is — it trusts you to
have authenticated them. Implement CSRF / Origin checks on the mount path, and
bind customer.email to your own session record. Don't trust
sessionData.email from the browser blind.
Client — mounting the embed #
The frontend SDK takes a baseEndpoint pointing at your middleware
mount path. The embed appends its own relative paths (/session,
/loa, /status) — you do not configure each one.
<script src="https://embed-sandbox.hellobill.app/v1/sdk.js"></script>
<div id="hellobill-mount"></div>
<script>
HelloBill.init({
baseEndpoint: '/api/hellobill',
mountTo: '#hellobill-mount',
sessionData: {
email: currentCustomer.email,
firstName: currentCustomer.firstName,
lastName: currentCustomer.lastName,
// Current property (move-in target)
addressLine1: property.addressLine1,
addressLine2: property.addressLine2,
addressLine3: property.addressLine3, // NEW v11 — third line
city: property.city,
postcode: property.postcode,
bedrooms: property.bedrooms,
moveInDate: occupancy.moveInDate,
// Optional B2B — supply when account holder's address differs from property
customerAddress: currentCustomer.address,
// Optional move-out — supply for move-in + move-out journeys
previousAddress: previousProperty, // PropertyObject; null for move-in-only
moveOut: undefined, // MoveOutObject; omit for move-in-only
tenancyId: occupancy.id,
utmSource: getUtmParam('utm_source'),
referralPage: window.location.pathname,
},
onComplete(result) {
// result: { customer_id, selected_products, loa_signed_at }
window.location.href = '/move/complete?account=' + result.customer_id;
},
onClose(reason) {
if (reason === 'user_dismissed') showReEngagePrompt();
},
// Optional — see Theming & customisation
appearance: {
theme: 'system', // 'hellobill' | 'light' | 'dark' | 'system'
variables: {
colorPrimary: '#1a73e8',
colorOnPrimary: '#ffffff',
borderRadius: '10px',
},
},
locale: 'en-GB',
});
</script>
import { HelloBillEmbed } from '@hello-bill/sdk';
const embed = HelloBillEmbed.init({
baseEndpoint: '/api/hellobill',
mountTo: '#hellobill-mount',
sessionData: {
email: customer.email, firstName: customer.firstName, lastName: customer.lastName,
postcode: property.postcode, addressLine1: property.addressLine1,
moveInDate: occupancy.moveInDate, tenancyId: occupancy.id,
},
onComplete: (r) => router.push(`/done?customer=${r.customer_id}`),
onClose: (reason) => reason === 'user_dismissed' && setShowReminder(true),
onEvent: (event) => analytics.track(event.type, event.data),
});
// Programmatic teardown
embed.destroy();
import { HelloBillWidget } from '@hello-bill/sdk/react';
<HelloBillWidget
baseEndpoint="/api/hellobill"
sessionData={{ email: customer.email, postcode: property.postcode, tenancyId: occupancy.id }}
onComplete={(r) => navigate(`/done?customer=${r.customer_id}`)}
onClose={(reason) => reason === 'user_dismissed' && setShowReminder(true)}
/>
<HelloBillWidget
base-endpoint="/api/hellobill"
:session-data="{ email: customer.email, postcode: property.postcode }"
@complete="(r) => navigateTo(`/done?customer=${r.customer_id}`)"
/>
<!-- Obtain session_token server-side via client.sessions.create() first -->
<iframe src="https://embed-sandbox.hellobill.app/v1/onboard?token={{ session_token }}"
style="width:100%;height:700px;border:none" allow="payment" />
Callbacks aren't available with the iframe approach — use webhooks instead. The embed works with no pre-filled session data (the customer fills everything in), but expect the longest journey (~17 screens, ~4 min). Pass any data you have for the shortest path.
/v1/onboard is a deploy-time alias
The bare-iframe path above uses /v1/onboard, which is a
deploy-time alias to the current embed version. It is distinct
from the SDK-composed path /{sdk_version}/onboard, where the version
is pinned into the SDK build at release time. The alias exists specifically for
the no-JS case. Do not hardcode a versioned path (e.g. /v18/onboard)
in a bare iframe — use the /v1/onboard alias so the path
stays valid across embed releases.
Callbacks
| Callback | Fires when | Signature |
|---|---|---|
onComplete | Customer finishes the flow (account created, LoA signed) | (result: CompletionResult) => void |
onClose | Embed is dismissed before completion | (reason: 'user_dismissed' | 'timeout' | 'error') => void |
onEvent | Lifecycle milestones (10 stable events). See Lifecycle events. | (event: { type: LifecycleEventType; data }) => void |
onUserAction | Granular user-interaction events (higher cardinality, evolving). See User-action events. | (action: { type: string; data }) => void |
result.customer_id ·
result.selected_products: { product_id, location }[] ·
result.loa_signed_at? ·
result.setup_status? ·
result.move_out_status?
Note: selected_products is an array of objects (not strings) — each entry carries both product_id and location: 'current' | 'previous'.
embedOrigin — enforced, not advisory
InitOptions accepts an optional embedOrigin (default
'https://embed-sandbox.hellobill.app'). The SDK honors
embedOrigin only when NODE_ENV !== 'production';
in production builds it is ignored and the compiled default
(https://embed-sandbox.hellobill.app) is used. When honored
(dev/UAT), the value is still validated against the compiled origin allowlist
— an unrecognised origin is rejected. Example non-prod override:
embedOrigin: 'https://embed.uat.the-bunch.co.uk'.
Events & callbacks #
The embed surfaces two complementary event channels. onEvent carries
stable lifecycle markers — a small, contract-stable set of well-defined journey
milestones that map closely to Partner API state transitions. onUserAction
carries granular user-interaction telemetry — richer, higher-cardinality, and
additive over time. Partners can subscribe to either or both; the channels are independent.
| Channel | Cardinality | Purpose | Use for |
|---|---|---|---|
onEvent | Low (10 events) | Lifecycle: well-defined milestones, mostly correlated with Partner API state changes | Funnel analytics, server-side logging, lifecycle-driven UI updates |
onUserAction | Higher (~15 events; non-stable) | Granular: in-flow user choices, screen transitions, micro-interactions | Detailed product analytics, A/B test instrumentation, replay reconstruction |
Lifecycle events (onEvent) #
| Event type | Fired when |
|---|---|
ready | Embed has mounted and is ready to receive interaction |
session.created | Session record created upstream (post-POST /partner/sessions) |
consent.granted | GDPR/data-sharing consent received from the customer |
screen.shown | A named screen has become visible. Use for funnel step gating. |
loa.signed | Customer signature captured on the Letter of Authority |
account.creating | POST /partner/sessions/:id/customers in flight |
account.created | Customer record created upstream; result.customer_id available |
error | Recoverable error visible to the customer (network, validation, etc.) |
complete | Flow completed successfully; mirrors onComplete for partners that prefer a single channel |
close | Flow closed (user-dismissed, timeout, or error). Mirrors onClose |
User-action events (onUserAction) #
User-action events surface granular in-flow choices. The catalogue evolves as new screens are added; treat this stream as informational, not contractual. Names are guaranteed stable within a major release but may grow over time.
screen.entered— internal screen-transition marker (usescreen.shownlifecycle event for funnel logic)service.selected— customer toggles a service category on the service-select screenproduct.chosen— customer commits to a specific tariff/productcard_preview.activated— customer expands a product-preview cardcouncil.reduction_toggle— customer toggles council tax reduction declarationdiscovery.started/discovery.complete— async discovery progresssetup.status/setup.complete— async setup progress (also surfaces via webhooks)
HelloBillEmbed.init({
// ...
onEvent: (event) => {
// Stable, low-cardinality — feed your funnel.
analytics.track('hellobill.lifecycle', event);
},
onUserAction: (action) => {
// Information-dense, evolving — feed your product analytics.
if (action.type === 'product.chosen') {
productAnalytics.track('product_selected', action.data);
}
},
});
onUserAction is additive in revision 1.12. Integrations on revision 1.11
that only consume onEvent continue to work. Granular events that previously
surfaced via onEvent (e.g. service.selected,
product.chosen) now route to onUserAction only. If you
previously relied on receiving these via onEvent, add an
onUserAction callback to keep observing them.
Property endpoint optional #
GET /api/v1/partner/sessions/:id/property
Returns enriched property characteristics for the session — building typology, energy performance, floor, parking, room counts — discovered or inferred after session creation (UPRN lookups, address-data providers, EPC register).
You do not need to call this endpoint as part of the embed integration. The embed uses property data internally where relevant. Use this endpoint only if your own flow benefits from richer property context (rent-affordability tooling, home-insurance prefill, property-onboarding flows, energy advisory).
Request
GET /api/v1/partner/sessions/ses_01HX.../property
Authorization: Bearer {access_token}
Query parameters
| Parameter | Required | Default | Notes |
|---|---|---|---|
force_refresh | optional | false | Re-run property discovery, bypassing the 24 h cache. Same 5-min per-session throttle as GET /products. |
Response — 200 OK
All fields inside property are optional / nullable — discovery may resolve any subset. Treat every field as possibly absent.
{
"session_id": "ses_01HX...",
"property": {
"property_type_code": "house",
"property_type_subcode": "terraced",
"building_type": "residential",
"building_style": "victorian",
"number_of_bedrooms": 3,
"number_of_bathrooms": 1.5,
"floor_number": 0,
"energy_performance_rating": "D",
"has_parking": true,
"property_has_off_road_parking": true,
"property_has_underground_parking": false,
"property_has_a_driveway": true,
"bin_collection": {
"primary_collection_day": "wednesday",
"primary_collection_types": ["general_waste", "recycling"],
"collection_frequency": "fortnightly",
"next_collection_date": "2026-06-03",
"days_after_move_in": 2,
"reminder_eve_label": "Tuesday evening",
"secondary_collection_day": "wednesday",
"secondary_collection_types": ["food_waste"]
}
},
"data_completeness": "partial",
"extracted_at": "2026-05-13T14:50:00Z",
"expires_at": "2026-05-14T14:50:00Z",
"broadband": {
"max_down_speed_mbps": 1000,
"max_up_speed_mbps": 220
}
}
Top-level response fields
| Field | Type | Always present | Notes |
|---|---|---|---|
session_id | string | yes | Echo of the path parameter |
property | object | yes | The property record. May be empty ({}) when data_completeness: 'empty'. |
data_completeness | enum | yes | complete · partial · empty — qualitative signal of how much of the schema was populated |
extracted_at | string | yes | ISO 8601 — when the property data was resolved |
expires_at | string | yes | ISO 8601 — 24 h after extracted_at |
broadband | object | no | (Added 1.16) Broadband availability at the property. Present when the postcode resolves a serviceability record. Fields: max_down_speed_mbps? (number ≥ 0), max_up_speed_mbps? (number ≥ 0). |
property sub-fields
| Field | Type | Notes |
|---|---|---|
property_type_code | enum | house · flat · maisonette · bungalow · other |
property_type_subcode | enum | detached · semi_detached · terraced · end_terrace · purpose_built_flat · converted_flat · studio · penthouse · other |
building_type | enum | residential · mixed_use · commercial · unknown |
building_style | enum | victorian · edwardian · georgian · interwar · post_war · modern · new_build · period · unknown |
number_of_bedrooms | number | ≥ 0, decimals allowed for studios (0.5) |
number_of_bathrooms | number | ≥ 0, decimals allowed for half-baths (1.5) |
floor_number | integer | −5 to 200. 0 = ground floor. Negative = basement. Omit / null for houses. |
energy_performance_rating | enum | A–G |
has_parking | boolean | Roll-up: true if any of the three specific parking flags below is true |
property_has_off_road_parking | boolean | Off-road parking present (private bay, garage, etc.) |
property_has_underground_parking | boolean | Underground / basement parking |
property_has_a_driveway | boolean | Driveway present |
bin_collection | object | (Added 1.16) Refuse-collection schedule. Present for supported local authorities only. Fields: primary_collection_day (monday–sunday), primary_collection_types[] (general_waste/recycling/food_waste/garden_waste/glass), collection_frequency (weekly/fortnightly/monthly), next_collection_date (ISO date), days_after_move_in (integer), reminder_eve_label (string), secondary_collection_day?, secondary_collection_types?[]. |
has_parking is a roll-up convenience flag. A null on any
specific parking flag means unknown (discovery couldn't determine it), not
false. has_parking may be true while some specific
flags are null — the roll-up reflects the known positives.
Clients that only need "is there any parking?" should read has_parking.
Embed SDK integration
The SDK auto-fetches property data internally. Partners can use data_completeness to gate their own UI:
complete— confirmation-only flow; all fields resolvedpartial— pre-filled quiz; some fields resolvedempty— full quiz; no data resolved
Error cases
| HTTP | code | Notes |
|---|---|---|
| 404 | session.not_found | Session ID not found for this partner |
| 425 | property.discovery_in_progress | First-time discovery still running. Response includes Retry-After (seconds). |
| 429 | rate.limited | Rate limit hit |
When the property record exists but is empty (discovery completed and resolved nothing), the response is 200 OK with data_completeness: "empty" and property: {} — not an error.
Letter of Authority (LoA) flow #
The LoA authorises HelloBill to act on the customer's behalf with utility suppliers. With the Embed SDK, the LoA is captured inside the embed flow — the customer reviews the LoA text and signs with a drawn signature on screen. You do not need to render, capture, or submit it yourself.
loa-template-v4)
A single signature covers both directions: switching at the current
address (location: 'current' products) and closure / final read
submission / change-of-tenancy notification at the previous address
(location: 'previous' products, when the customer has opted in via
move.out.notify.*). Partners do not need to capture two LoAs.
POST /loa endpoint (Direct API callers)
As of revision 1.16 there is a standalone POST /partner/sessions/:id/loa endpoint
to submit a signed LoA before account creation. This is intended for raw-HTTP Direct API flows
only. loa_signatures[] on POST /customers is now optional
— submit the signature via either path, not both.
SDK behaviour: both the @hello-bill/node client (sessions.loa)
and the embed iframe submit signatures inline via /customers. The node middleware
wires only GET /loa (not POST /loa) and there is no
sessions.loa.submit() method on the typed client. Call POST /loa
directly over HTTP if you prefer the standalone path.
What happens behind the scenes (Embed SDK)
- Customer selects products inside the embed. If a selected product carries
requires_slot_scheduling: true(e.g. a removals Van), the embed fetches available slots viaGET /partner/sessions/:id/service-slots?service=removals&provider=any_vanand presents a slot-picker screen. The chosen slot is submitted as a per-productselected_products[].selected_slotobject on account creation. Theselected_slot.product_idmust equal the parentselected_products[].product_idof the slot-scheduling product it belongs to — a mismatch or a missing slot on arequires_slot_schedulingproduct is rejected (400 customer.missing_selected_slot), and a slot on a non-slot product is rejected (400 customer.unexpected_selected_slot). - Embed fetches the appropriate LoA(s) via
GET /loa?product_ids=.... - Embed fetches the LoA HTML for display via
GET /api/v1/partner/loa/:id/document(returns{ body_html, checksum }), using the embedsession_tokenBearer with scopeloa:read. The embed sanitisesbody_htmlbefore rendering it. - LoA text is displayed; customer reviews and signs (PNG or JPEG drawn signature — SVG is not accepted).
- Embed submits signed LoA(s) inline as
loa_signatures[]on account creation — includingloa_id,signed_checksum,signed_at_client, and the base64 signature image. - Your webhook handler receives
session.loa_signedwithloa_id,signed_at_server(the authoritative server timestamp), andsigned_by_full_name.
signed_at_server is authoritative
The webhook payload uses signed_at_server (not signed_at).
The server stamps this at the moment the signature is validated and stored.
Partner-supplied signed_at_client is advisory audit metadata only;
signed_at_server determines the LoA's 12-month validity window.
LoA response fields
| Field | Always present | Notes |
|---|---|---|
loa_id | yes | Stable reference. Must be passed back when submitting the signature on account creation. |
loa_text_url | yes | URL to the PDF rendering. Requires Authorization: Bearer {access_token}. Never include as a bare link in HTML, emails, or logs — proxy server-side only. |
loa_text_html_url | yes | Document URL for the LoA HTML the embed shows for signing. (1.17) Fetch via GET /api/v1/partner/loa/:id/document using the embed session_token Bearer (scope loa:read); returns { body_html, checksum }. The embed sanitises body_html before rendering. The endpoint responds with strict security headers (Cache-Control: no-store, Referrer-Policy: no-referrer, X-Content-Type-Options: nosniff, Content-Security-Policy: default-src 'none', X-Frame-Options: DENY) — do not cache or proxy this URL. |
covers_product_ids | yes | The product IDs this LoA authorises. Every product in the request must be covered by exactly one LoA. |
checksum | yes | SHA-256 of the LoA text the customer is shown. Capture client-side and submit as signed_checksum to prove what was signed. |
expires_at | yes | LoA template expiry — re-fetch after this time. |
version | yes | Template version identifier, e.g. loa-template-v4. |
Legal & retention
- LoAs expire after 12 months from
signed_at_server. - Signed PDFs retained for 7 years (regulatory minimum).
- Customer can revoke at any time — revocation blocks any further switching/relay.
- Full audit trail captured: signature method, IP, user-agent,
signed_at_server.
Theming & customisation #
appearance is an optional configuration block on
HelloBill.init({ … }) that visually aligns the embed with
your brand. The shipped surface today is a theme
preset, disableAnimations, a partial variables set,
mode, locale, integrations.gmapsKey, and
the updateAppearance/setLocale/destroy
handle methods. A rules allowlist is declared in the SDK types but
is roadmap — not yet enforced by the current embed build.
There is no CSS escape hatch — partners cannot inject
arbitrary CSS into the embed.
The embed renders content that has legal weight in the UK (Letter of Authority, GDPR consent, PSR notice, Ofgem cooling-off disclosures). A curated surface lets partners brand the embed without being able to hide, shrink, or visually demote anything a regulator requires the customer to read.
The appearance object #
HelloBill.init({
baseEndpoint: '/api/hellobill',
mountTo: '#hellobill-mount',
sessionData: { /* … */ },
appearance: {
theme: 'hellobill', // 'hellobill' | 'light' | 'dark' | 'system'
disableAnimations: false, // honours OS prefers-reduced-motion regardless
variables: {
colorPrimary: '#1a73e8',
colorOnPrimary: '#ffffff',
colorBackground: '#ffffff',
colorSurface: '#f7f9fc',
colorBorder: '#e4e8ee',
colorText: '#0f172a',
colorTextSecondary: '#475569',
colorTextPlaceholder: '#94a3b8',
colorTextDisabled: '#cbd5e1',
colorDanger: '#c43c3c',
colorSuccess: '#16a34a',
colorWarning: '#b97900',
fontFamily: 'Inter, -apple-system, sans-serif',
fontSizeBase: '16px',
fontLineHeight: '1.55',
fontWeightNormal: '400',
fontWeightMedium: '500',
fontWeightBold: '700',
spacingUnit: '4px',
gridRowSpacing: '16px',
gridColumnSpacing: '16px',
borderRadius: '8px',
buttonBorderRadius: '8px',
focusBoxShadow: '0 0 0 3px rgba(26,115,232,0.25)',
focusOutline: '2px solid #1a73e8',
iconColor: '#475569',
iconHoverColor: '#0f172a',
iconCheckmarkColor: '#ffffff',
},
rules: {
'.HBButton': { fontWeight: '600' },
'.HBButton:hover': { boxShadow: '0 2px 8px rgba(0,0,0,0.08)' },
'.HBInput': { borderColor: '#dbe2ea' },
'.HBInput:focus': { borderColor: '#1a73e8' },
'.HBInput--invalid': { borderColor: '#c43c3c' },
'.HBCard': { boxShadow: '0 1px 3px rgba(0,0,0,0.04)' },
'.HBCard--selected': { borderColor: '#1a73e8' },
},
},
locale: 'en-GB', // 'auto' | 'en' | 'en-GB' | 'cy-GB'
});
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
theme | enum | optional | 'hellobill' | 'hellobill' | 'light' | 'dark' | 'system' |
disableAnimations | boolean | optional | false | When false, the OS prefers-reduced-motion: reduce setting still disables animations. |
variables | object | optional | {} | Design-token overrides. Merged on top of the chosen theme. |
rules | object | optional | {} | Roadmap — not yet enforced. Curated selector → CSS-property map. Declared in SDK types but silently ignored by the current embed build. See Roadmap below. |
Shipped surface (current build)
These options take effect today in the current embed build:
Theme presets #
| Preset | Behaviour |
|---|---|
'hellobill' default | HelloBill's branded palette — mint accent on light surfaces. Use when you want a “powered by HelloBill” look. |
'light' | Neutral light theme. Partner-agnostic palette ready for token overrides. |
'dark' | Neutral dark theme — colorBackground: #0f172a, colorText: #e2e8f0, etc. |
'system' | Resolves to 'light' or 'dark' based on prefers-color-scheme. The embed subscribes to changes via MediaQueryList.addEventListener('change', …) and updates live — no page reload. |
A theme is a starting set of variables values; anything you provide in appearance.variables overrides the theme defaults. Merge order: theme defaults → variables → rules.
Variable reference #
All values are CSS strings. Hex, rgb(), rgba(), and CSS-variable references (var(--my-token)) are accepted; CSS expressions, url(), and @import are stripped.
| Category | Tokens |
|---|---|
| Colour — base | colorPrimary, colorOnPrimary, colorBackground, colorSurface, colorBorder |
| Colour — text | colorText, colorTextSecondary, colorTextPlaceholder, colorTextDisabled |
| Colour — semantic | colorDanger, colorSuccess, colorWarning |
| Colour — icon | iconColor, iconHoverColor, iconCheckmarkColor |
| Typography | fontFamily, fontSizeBase, fontLineHeight, fontWeightNormal, fontWeightMedium, fontWeightBold |
| Spacing | spacingUnit, gridRowSpacing, gridColumnSpacing |
| Radius & focus | borderRadius, buttonBorderRadius, focusBoxShadow, focusOutline |
This is the text/icon colour used on the primary fill (e.g. on the primary CTA button). Partners must set both; the SDK does not auto-derive a readable foreground because brand teams typically prefer to pick it deliberately.
fontFamily accepts any valid CSS font-family value, but the SDK does not fetch external font files on your behalf. To use a custom web font, either: (a) ensure the font is already loaded on the page where the embed mounts, then reference it by name; or (b) preload a self-hosted font via your own <link rel="preload">. This protects your CSP and avoids third-party-font GDPR exposure.
Roadmap (declared in types, not yet enforced)
The following theming surfaces are present in the SDK's TypeScript types and documented design intent, but are not enforced by the current embed build. Do not rely on them in production yet; they will be honoured in a future revision.
Rules — curated selector allowlist #
rules is a map: selector → { cssProperty: value }. Selectors are HelloBill-specific class names (prefix HB). Selectors not in the allowlist are silently ignored.
| Selector | Targets | Notable allowed properties |
|---|---|---|
.HBLabel | Field labels | color, fontWeight, fontSize, letterSpacing, textTransform |
.HBInput | Text inputs | borderColor, borderWidth, backgroundColor, color, padding, boxShadow |
.HBInput:focus, .HBInput:hover, .HBInput--invalid, .HBInput::placeholder | Input states | same as .HBInput |
.HBButton, .HBButton:hover, .HBButton:focus, .HBButton:disabled | Primary CTA buttons | backgroundColor, color, borderColor, boxShadow, fontWeight, transform |
.HBCard, .HBCard--selected, .HBCard:hover | Product cards in the picker | backgroundColor, borderColor, boxShadow, borderWidth |
.HBCheckbox, .HBCheckboxLabel, .HBCheckboxInput | Checkboxes (excluding consent — see lock list) | borderColor, color, backgroundColor |
.HBRadioIcon, .HBRadioIconOuter, .HBRadioIconInner | Radios | borderColor, backgroundColor, fill |
.HBTab, .HBTab:hover, .HBTab--selected | Section tabs | color, backgroundColor, borderBottomColor, fontWeight |
.HBAccordionItem | Collapsible disclosure rows | backgroundColor, borderColor |
.HBError | Inline validation messages | color, backgroundColor, borderColor |
.HBMenu, .HBMenuAction | Dropdown menus | backgroundColor, borderColor, color, boxShadow |
rulesRegardless of selector: position, display, visibility, opacity, pointer-events, z-index, transform: scale(0), width: 0, height: 0, overflow: hidden on layout containers, clip-path, font-size: 0. Attempts to use these are silently dropped. This list exists to prevent partners from accidentally (or deliberately) hiding required disclosures.
Lock list — what cannot be themed #
The following elements are rendered with HelloBill's own styles and ignore appearance.variables and appearance.rules. This is by design and not configurable.
| Element | Reason |
|---|---|
| Letter of Authority disclosure text and signature canvas | Legal — customer must clearly see what they are authorising. Re-styling could undermine consent quality. |
GDPR consent screens (data_sharing_accepted, marketing opt-in) | UK GDPR Art. 7(2) + ICO guidance: consent must be clear, prominent, and the reject path visually equivalent to the accept path. No “dark patterns”. |
| Priority Services Register (PSR) disclosure | Ofgem/Ofwat customer-information rules require prominent, accessible presentation. |
| Switching cooling-off notice | Ofgem switching rules require the 14-day cooling-off period to be visible at the point of decision. |
| Tariff Comparison Rate, where shown | Ofgem tariff disclosure standard requires consistent presentation across suppliers. |
Supplier logos rendered from supplier_logo_url | Trust signal — partners cannot swap or restyle real supplier marks. |
| “Powered by HelloBill” footer | Trust + auditability — customers must know who their data is being shared with. |
| Error / failure UI for switching | Customers must clearly understand when something failed; we keep this UI consistent across all partners. |
Accessibility & contrast guarantees #
Roadmap. WCAG 2.2 AA runtime contrast guarantees are declared in the SDK's TypeScript types but are not yet enforced by the current embed build. The behaviour below is the intended future behaviour and will ship in a forthcoming revision.
| Pair | Minimum ratio | Behaviour on fail |
|---|---|---|
colorText on colorBackground | 4.5:1 (normal text) | Dev-console warning; locked surfaces use fallback colorText from the active theme. |
colorOnPrimary on colorPrimary | 4.5:1 | Dev-console warning; the SDK falls back to white or black (whichever satisfies the ratio) for the locked surfaces. |
Focus indicators (focusOutline, focusBoxShadow) on colorBackground | 3:1 | Dev-console warning; SDK overlays its default focus ring on locked surfaces. |
Non-text UI (.HBInput border, .HBCheckbox icon) | 3:1 | Dev-console warning. |
Disabled-state elements are exempt from contrast minimums per WCAG 2.2 SC 1.4.3.
The SDK disables non-essential animations even when disableAnimations: false. There is no partner override for this.
Live theme switching #
HelloBill.init(…) returns an instance that exposes:
embed.updateAppearance({ theme: 'dark' }); // merges; partial updates supported
embed.updateAppearance({ variables: { colorPrimary: '#ff8800' } });
embed.setLocale('cy-GB'); // also re-renders strings
embed.destroy(); // unmounts and tears down listeners
When theme: 'system', the embed subscribes to matchMedia('(prefers-color-scheme: dark)') itself — partners do not need to wire this up. Use embed.updateAppearance(…) only when your app has its own theme picker that should override the OS preference.
Locale #
| Value | Behaviour |
|---|---|
'auto' default | Resolved from navigator.language, falling back to 'en-GB'. |
'en' | Generic English. |
'en-GB' | UK English with UK-specific copy (postcodes, supplier names, regulator references). |
'cy-GB' | Welsh — recommended whenever you serve Welsh-speaking customers, and required for partners with obligations under the Welsh Language (Wales) Measure 2011 (typically public bodies or designated organisations). All embed strings are translated; LoA legal text is presented bilingually (English alongside Welsh). |
Locale changes via embed.setLocale(…) re-render strings in place; no page reload.
Implementation notes #
- CSP. The embed is served from
https://embed-sandbox.hellobill.app. Add this origin to yourframe-srcandconnect-src. Inline styles inside the iframe are scoped — no CSP exception needed on the parent page. - Iframe transport. Appearance is posted to the iframe via
postMessageon init and on everyupdateAppearance(…). The embed never reads from your stylesheet — partner and embed live in isolated style scopes. - No third-party fonts. The SDK never fetches fonts on your behalf. Preload any custom font on the parent page or the iframe origin.
- No
theme: 'auto'. Usetheme: 'system'instead;'auto'is reserved forlocale. - Defaults are good. The
'hellobill'theme works without any other configuration —appearanceis entirely optional. - M1/M2 security guards. The SDK enforces two client-side security layers, transparent to partners. M1 (tier pinning): the SDK build is compiled for a specific tier and rejects embed origins that don't match. M2 (token-tier binding): the session token carries a tier/mode claim; the embed refuses to mount if the token's tier doesn't match the compiled tier. Both surface as an
onEvent'error'event — there is no HTTP status code, as these are client-side guards. Together they form a defense-in-depth layer; no partner action is required.
Google Maps integration (integrations.gmapsKey) #
The embed uses a Google Maps embed to display the current property on the
address-confirmation screen. Pass your Maps Embed API key via
integrations.gmapsKey:
HelloBill.init({
baseEndpoint: '/api/hellobill',
mountTo: '#hellobill-mount',
sessionData: { /* ... */ },
integrations: {
gmapsKey: process.env.NEXT_PUBLIC_GMAPS_KEY,
},
});
| Behaviour | Detail |
|---|---|
| Key handling | Embedded client-side in the Maps Embed URL (https://www.google.com/maps/embed/v1/place?key=…). Never sent to HelloBill servers. |
| Production keys | Must use HTTP referrer restrictions scoped to your partner domain(s) (e.g. https://yourapp.com/*). An unrestricted key is a billing risk. |
| No key provided | Embed falls back to an inline SVG map placeholder. No error is thrown and the journey continues normally — degradation only. |
integrations field | Entirely optional. Omitting it is equivalent to integrations: {}. |
Recommended Google Cloud Console setup
- Create a Maps Embed API key (not Maps JavaScript API — the embed uses the simpler Embed API).
- Under "Application restrictions", select "HTTP referrers (web sites)".
- Add your partner domain(s):
https://yourapp.com/*andhttps://staging.yourapp.com/*. - Under "API restrictions", restrict to "Maps Embed API" only.
Webhooks #
Webhooks are how you observe what happens after the embed hands off: switching progress, supplier confirmations, subscription state changes. Register your endpoint in PartnerDock; we'll send signed POST requests.
Delivery contract
| Property | Guarantee |
|---|---|
| Delivery | At-least-once. Same event may be delivered more than once. |
| Ordering | Not guaranteed. Events may arrive out of order. |
| Latency | Best-effort within 30 s of the triggering action. |
| Idempotency | Use the id field to deduplicate. |
Verify signature → respond 2xx within 10 s → process asynchronously
→ deduplicate on id → do not rely on ordering; use
created_at to sequence if needed.
Signature verification #
Every webhook includes a signed timestamp + HMAC in the X-HelloBill-Signature header:
X-HelloBill-Signature: t=1715349660,v1=8a3f9c2b7e4d...
| Component | Meaning |
|---|---|
t=<unix> | Webhook generation time in Unix seconds. Used for replay defence. |
v1=<hex> | HMAC-SHA256(secret, "{t}.{raw_request_body}") as hex. |
The signed payload is the literal string {t}.{raw_body}.
Reject any webhook whose t is older than 300 seconds
(5 min tolerance) from your server's wall clock. This stops captured webhooks
being replayed indefinitely. If your clock drift exceeds 5 min you have a
bigger problem.
The v1= prefix is the scheme version. Future rotations introduce
v2=... etc. During rotation, both signatures are
included on the header — accept the request if either verifies. We give
30 days notice via X-HelloBill-Deprecation before retiring an old
scheme.
import crypto from 'node:crypto';
import express from 'express';
const TOLERANCE_SECONDS = 300;
function verifyWebhook(rawBody: Buffer, header: string, secret: string): boolean {
// Parse header: "t=1715349660,v1=8a3f..."
const parts = Object.fromEntries(
header.split(',').map(p => p.split('=', 2) as [string, string])
);
const t = parseInt(parts.t, 10);
if (!t || Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;
// Iterate over all v* schemes present; accept if any verifies
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.`).update(rawBody) // raw bytes — do NOT parse JSON first
.digest('hex');
for (const [k, v] of Object.entries(parts)) {
if (k.startsWith('v') && crypto.timingSafeEqual(Buffer.from(v), Buffer.from(expected))) {
return true;
}
}
return false;
}
app.post('/webhooks/hellobill',
express.raw({ type: 'application/json' }),
(req, res) => {
const sig = req.headers['x-hellobill-signature'] as string;
if (!verifyWebhook(req.body, sig, process.env.HELLOBILL_WEBHOOK_SECRET!)) {
return res.status(401).json({ error: 'invalid_signature' });
}
const event = JSON.parse(req.body.toString());
// deduplicate on event.id, then process
res.json({ received: true });
}
);
Compute HMAC over raw request body bytes, not over re-serialised JSON. Verify the timestamp before the HMAC — a malicious actor with stolen signature material would still need to win the timing window.
Retry schedule #
| Attempt | Delay | Timeout |
|---|---|---|
| 1 | Immediate | 10 s |
| 2 | 30 s | 10 s |
| 3 (final) | 5 min | 10 s |
Failed events retained 30 days. Manual replay available in PartnerDock → Webhook Health.
Event reference #
Common envelope
| Field | Type | Always present | Notes |
|---|---|---|---|
id | string | yes | Use as idempotency key |
type | string | yes | Primary event-type field — see table below. Use this field. |
event | string | yes | Alias for type — same value, emitted for backward compatibility with v1.10 consumers. |
created_at | string | yes | ISO 8601 |
api_version | string | yes | Webhook payload schema version, e.g. "2026-05-10". Pin your handler to a known api_version; new fields may appear, existing ones won't change shape until a new api_version is announced. |
livemode | boolean | yes | true when environment === 'production', false otherwise. |
environment | string | yes | "production" | "sandbox" |
data.session_id | string | yes | — |
data.referral_id | string | null | yes | — |
data.context | ContextEntry[] | yes | Echo of session context[] |
Events (12 variants)
Event (type) | Additional data fields |
|---|---|
session.embed_opened | — |
session.details_confirmed | corrections: string[] |
session.products_selected | products: array of { product_id, category, sub_categories, location } |
session.loa_signed | loa_id, signed_at_server (authoritative server timestamp), signed_by_full_name |
session.account_created | customer_id |
session.app_installed | customer_id, device_type: "ios"|"android", installed_at |
session.app_deferred | customer_id, deferred_reason |
subscription.changed | customer_id, previous_status, new_status, trial_ends_at? |
setup_status.changed | customer_id, product_id, setup_id, category, sub_categories, supplier_name, location, previous_status, new_status, max_retries_exceeded, details? |
move_out_notification.sent | customer_id, setup_id, product_id, category, supplier_name, location: "previous", notified_at, channel: "email"|"api"|"postal" |
move_out_notification.failed | customer_id, setup_id, product_id, category, supplier_name, location: "previous", failed_at, reason, retry_scheduled_at? |
bank_details.collected | bank_details_id, account_number_ending, sort_code_masked, modulus_check_passed, collected_at |
session.loa_signed uses signed_at_server
The session.loa_signed webhook payload uses signed_at_server
(the authoritative server-stamped time), not signed_at.
This is the timestamp that determines the LoA's 12-month legal validity window.
Example: bank_details.collected
{
"type": "bank_details.collected",
"event": "bank_details.collected",
"id": "evt_01HX...",
"created_at": "2026-05-19T10:00:01Z",
"api_version": "2026-05-10",
"livemode": false,
"environment": "sandbox",
"data": {
"session_id": "ses_abc123",
"referral_id": "REF-2026-05-10-abc123",
"context": [],
"bank_details_id": "bnk_xyz789",
"account_number_ending": "4958",
"sort_code_masked": "08-**-99",
"modulus_check_passed": true,
"collected_at": "2026-05-19T10:00:01Z"
}
}
setup_status.changed fires for both current-address switches
(location: 'current') and previous-address closure setups
(location: 'previous'). Read data.location to know which.
move_out_notification.sent / .failed fire only
for previous-address closure notifications — they are emitted in addition to
setup_status.changed for the same setup.
Error model #
Error envelope
{
"error": {
"type": "invalid_request",
"code": "validation.failed",
"message": "addresses.current.postcode: String must contain at least 3 character(s)",
"request_id": "req_01HX7G2K..."
}
}
| Field | Always present | Notes |
|---|---|---|
error.type | yes | Broad category — see error.type enum below |
error.code | yes | Machine-readable specific code |
error.message | yes | Human-readable description. For schema-validation failures this is the validation issues joined into one string. |
error.request_id | yes | Include in support requests (also returned as X-HelloBill-Request-Id) |
error.param | no | Reserved — present on selected errors only |
error.missing_fields | no | Reserved — present on selected errors only |
error.details | no | Reserved — optional structured detail object |
Schema (Zod) validation failures return a single flattened message (issues joined with ; ) under code: "validation.failed". There is no per-field param/missing_fields array on validation errors in the current implementation — parse the message for human display, and validate client-side against the schemas in the Schema reference to avoid round-trips.
error.type categories
error.type | Used for |
|---|---|
invalid_request | Validation and business-rule rejections (4xx, mostly 400) |
invalid_credentials | Authentication failures (401) |
not_found | Missing / cross-partner resources (404) |
idempotency_conflict | Idempotency-Key reused with a different body (409) |
rate_limited | Rate-limit rejections (429) |
mode_not_implemented | live_ request to an unimplemented backend (503) |
internal | Unexpected server errors (500/502) |
Error code reference #
Error codes use a category.specific namespace. Match error.code.startsWith('loa.') for category-level handling without coupling to specific codes.
| HTTP | error.code | error.type | Retryable | When |
|---|---|---|---|---|
| 400 | validation.failed | invalid_request | no | Request body/query fails schema validation |
| 400 | loa.products_required | invalid_request | no | GET /loa with empty/missing product_ids |
| 400 | signature_image.invalid | invalid_request | no | Signature image malformed or fails server checks (only error code for image issues; PNG/JPEG accepted, SVG is not) |
| 400 | customer.products_required | invalid_request | no | selected_products missing/empty on account creation |
| 400 | customer.product_not_in_catalog | invalid_request | no | A selected product_id is not available for this session |
| 401 | auth.invalid_credentials | invalid_credentials | no (re-auth) | Bad client credentials, or missing/malformed/expired Bearer token |
| 404 | session.not_found | not_found | no | Session ID not found for this partner (scoped — see resource scoping) |
| 404 | customer.not_found | not_found | no | Customer ID not found |
| 404 | loa.not_found | not_found | no | loa_id not found for this session |
| 404 | bank_details.not_found | not_found | no | No bank details collected for this session |
| 409 | idempotency.fingerprint_mismatch | idempotency_conflict | no | Idempotency-Key reused with a different request body |
| 409 | loa.already_signed | invalid_request | no | LoA already signed with different attributes |
| 409 | loa.selection_changed | invalid_request | yes (re-fetch + re-sign) | Product selection changed since the LoA was issued — re-fetch GET /loa and re-sign |
| 409 | bank_details.session_locked | invalid_request | no | Session finalised; bank details can no longer be submitted |
| 409 | customer.session_already_has_customer | invalid_request | no | Session already has a customer; a different body was submitted |
| 422 | loa.template_changed | invalid_request | yes (re-sign) | signed_checksum no longer matches the current LoA text — re-fetch and re-sign |
| 422 | bank_details.invalid_modulus | invalid_request | no | Account number fails the UK BACS modulus check |
| 425 | property.discovery_in_progress | invalid_request | yes (retry) | Property discovery still running. Retry-After header set. |
| 429 | rate.limited | rate_limited | yes | Rate limit hit. Retry-After header set — see Rate limits. |
| 500 | internal.error | internal | yes | Transient/unexpected server error |
| 502 | internal.error | internal | yes | Upstream (Keycloak) unreachable on POST /auth/partner/token |
| 503 | mode.live_not_implemented | mode_not_implemented | no | live_ request to a backend not yet enabled for this partner |
Retry guidance
- Retryable (425, 429, 500, 502): exponential backoff with jitter. Start 1 s, cap 60 s, max 5 attempts.
- Re-auth (401): obtain a new token, retry once.
- Re-fetch/re-sign (409
loa.selection_changed, 422loa.template_changed): re-runGET /loaand re-capture the signature. - Non-retryable (other 4xx): fix the request before retrying.
Rate-limit headers #
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 43
X-RateLimit-Reset: 1715349660
X-HelloBill-Request-Id: req_01HX7G2K...
On 429, Retry-After: {seconds} is set.
Security & compliance #
Logging restrictions
Must NOT log: client_secret · access_token (full value) · session_token (full value) · customer.date_of_birth
Safe to log: session_id · customer_id · product_id · setup_id · error.request_id · referral_id · HTTP status codes · latency · endpoint paths
Credential security
| Credential | Where it lives | Must NOT appear in |
|---|---|---|
client_secret | Server env var | Browser code, mobile bundles, git repos, logs |
access_token | Server memory (cached) | Browser localStorage, logs |
session_token | Passed server→client once | Logs, analytics, third-party scripts |
webhook_secret | Server env var | Browser code, logs |
LoA handling
Signed LoAs are stored as PDFs in S3 with the loa_id reference. Audit trail captured: signature method, IP address, user agent, signed_at. LoAs expire after 12 months and can be revoked by the customer at any time. Revoked or expired LoAs block any further switching or supplier communication.
Data retention
| Data | Retention | Deletion trigger |
|---|---|---|
| Customer PII | Account duration + 7 years (UK Companies Act) | Customer deletion request |
| Signed LoA PDFs | 7 years | Regulatory minimum |
| Session data (pre-account session payload) | 24 h from creation | Automatic expiry — the session record is purged and session_id returns 404 thereafter |
| Webhook delivery logs | 30 days | Automatic expiry |
| Audit logs | 7 years | — |
Deletion requests processed within 30 days per UK GDPR Article 17.
Rate limits #
The API uses a single shared bucket per partner_org_id for all general requests, plus a separate higher-capacity bucket for the OAuth token endpoint.
| Bucket | Production | Sandbox | Scope |
|---|---|---|---|
| General requests (all endpoints except token) | 60 req/min | 300 req/min | Per partner_org_id |
POST /auth/partner/token | 1000 req/min | 1000 req/min | Per client_id |
The token endpoint limit is intentionally high to support rapid sandbox iteration (automated test suites that re-authenticate frequently). The general bucket is shared across all endpoints — there are no per-endpoint sub-limits in the current implementation.
force_refresh=true on GET /partner/sessions/:id/products is additionally throttled to 1 call per session per 5 minutes, independent of the general bucket.
Every response includes X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset. On 429, Retry-After: {seconds} is also set.
Sandbox & testing #
Key prefixes & isolation
| Prefix | Discovery | Emails | Switches | Webhooks |
|---|---|---|---|---|
sb_ | Mock [4 energy, 6 broadband] | Suppressed | Simulated state machine | Real payloads, "environment": "sandbox" |
live_ | Real Electralink + water + council | Real | Real switching chain | Production events |
Sandbox / production isolation contract
- Separate ID realms. All resource IDs are scoped to environment. A
session_idcreated undersb_cannot be fetched with alive_token (and vice versa) — the response is404 session.not_found, identical to a non-existent ID, to prevent cross-environment enumeration. - Separate webhook endpoints. Sandbox and production each have their own webhook URL registration in PartnerDock.
- Separate webhook secrets. A sandbox secret will not validate a production signature.
- Separate rate limits. Sandbox limits (300 req/min) are independent of production limits (60 req/min).
- No data flow between environments. Discovery cache, customer records, LoAs, audit logs are all environment-partitioned at rest.
A live_ token presenting an sb_-realm ID gets 404. A sb_ token presenting a live_-realm ID also gets 404. No special error code — the discrimination is silent to keep the boundary tight.
Test data
| What | Value | Expected behaviour |
|---|---|---|
addresses.current.postcode — full success | SW1A 1AA | All categories return mock products at location: 'current' |
addresses.current.postcode — partial fail | EC1A 1BB | Broadband discovery fails; others succeed |
addresses.current.postcode — all fail | ZZ99 9ZZ | All discovery fails; products: [] |
addresses.previous.postcode — successful move-out | LS24 9PX | Returns 2 mock incumbent products at location: 'previous' (energy + water) |
addresses.previous.postcode — closure failure | BS1 6XX | Move-out notifications always fail (deterministic, for repeatable testing). Use to test move_out_notification.failed webhook. |
move.out.move_out_date — too far future | any >180 d ahead | Returns 400 move_out.invalid_date |
final_reads[].reading — implausible | 99999999 | Returns 400 final_read.implausible |
| Email — existing session | test@hellobill.app | Returns existing session (idempotency test) |
Simulated switch lifecycle
T+0s pending_setup → in_progress
T+5s in_progress → awaiting_supplier
T+30s awaiting_supplier→ confirmed (80%) or failed (20%)
T+40s failed → in_progress (auto-retry)
T+60s in_progress → confirmed (always succeeds on retry in sandbox)
Bank details test vectors
The sandbox recognises the following sort code + account number combinations for testing POST /partner/sessions/:id/bank-details. All other well-formed combinations return 422 bank_details.invalid_modulus unless they happen to pass the real Vocalink algorithm.
| Sort code | Account number | Expected result |
|---|---|---|
08-99-99 | 66374958 | Valid — modulus passes |
10-79-99 | 88837491 | Valid — modulus passes |
20-29-59 | 63748472 | Valid — modulus passes |
93-80-63 | 15764273 | Invalid — modulus fails (422 bank_details.invalid_modulus) |
12-34-56 | 12345 | Invalid — fails schema validation (400 validation.failed) |
Simulated closure notification lifecycle
T+0s pending_setup → in_progress
T+10s in_progress → notified (90% — supplier accepts notification)
T+10s in_progress → failed (10% — supplier rejects, e.g. account not found)
T+20s failed → in_progress (auto-retry)
T+30s in_progress → notified (always succeeds on retry in sandbox)
Webhook testing
PartnerDock includes "Send Test Webhook" buttons per event type — fires a real signed payload to your registered endpoint before go-live.
Integration checklist #
Run this list before flipping sb_ to live_.
- ☐ Receive
client_id(sb_prefix) +client_secret - ☐
npm install @hello-bill/node @hello-bill/sdk - ☐ Mount
createHellobillRouter/createHellobillHandler(1 line) - ☐ Implement CSRF / Origin checks on the mount path; bind
customer.emailto your own session — don't trustsessionData.emailblind - ☐ Write
buildSessionPayload— populatecontext[]with available metadata - ☐ Capture GDPR consent on partner site before calling
POST /sessions(sessionData bootstrap) - ☐ Mount
HelloBill.init({ baseEndpoint, sessionData, mountTo })(1 line) - ☐ (B2B integrations only) Supply
customer.addresswhen the account holder's address differs fromaddresses.current(otherwise omit and the server defaults it) - ☐ (Move-out journeys only) Supply
addresses.previousANDmove.outtogether; usemove.out.notify.*flags to opt the customer in to per-service supplier notifications; supplymove.out.final_reads[]when available (energy / water) - ☐ (Optional) Pass
appearanceto brand the embed; if your app has a theme picker, usetheme: 'system'or callembed.updateAppearance(…)on theme change (Theming) - ☐ (Optional) Set
locale: 'cy-GB'when serving Welsh-speaking customers - ☐ Register webhook endpoint + implement HMAC verification with timestamp check (
t=within 5 min) and raw-body bytes - ☐ Pin webhook handler to a known
api_version - ☐ Deduplicate webhooks using
id - ☐ Handle
session.loa_signed,setup_status.changed,subscription.changed - ☐ (Move-out journeys only) Handle
move_out_notification.sentandmove_out_notification.failed; readdata.locationonsetup_status.changedto disambiguate current vs previous address - ☐ Verify
provided_fieldsmatches expected skips (paths now useaddresses.current.*/move.in.*/addresses.previous.*/move.out.*) - ☐ Test:
SW1A 1AA·EC1A 1BB·ZZ99 9ZZ·LS24 9PX(previous-address success) ·BS1 6XX(closure failure) - ☐ Switch to
live_credentials after HelloBill approval
Schema reference #
Complete TypeScript type definitions for all request/response shapes. These types
are exported from @hello-bill/sdk and @hello-bill/node.
Session payload #
interface SessionPayload {
referral_id?: string;
customer: CustomerObject; // required
occupants?: OccupantObject[];
addresses: AddressesObject; // required — grouped current + optional previous
move: MoveObject; // required — grouped in (required) + out (optional)
meters?: MetersObject; // optional — current-address meter identifiers
consent: ConsentObject; // required
context?: ContextEntry[]; // optional — max 50 entries
}
Session response #
interface SessionResponse {
session_id: string;
session_token: string; // HS256 JWT, 300 s
embed_base_url: string; // embed ORIGIN only; SDK composes {embed_base_url}/{version}/onboard?token=...
discovery_status: 'running' | 'complete';
estimated_ready_seconds?: number; // omitted when discovery_status is 'complete'
provided_fields: string[]; // dot-notation paths
expires_at: string; // ISO 8601, 24 h from creation
gmaps_key?: string; // Google Maps Embed API key; SDK forwards to iframe
}
Address types #
interface AddressObject {
address_line_1: string; // required
address_line_2?: string;
address_line_3?: string; // optional — rural / multi-building / industrial estate
city: string; // required
postcode: string; // required, UK format
uprn?: string; // canonical when supplied
country?: 'GB'; // reserved for future expansion
}
interface PropertyObject extends AddressObject {
bedrooms?: number;
number_occupants?: number;
property_type?: PropertyType;
epc_rating?: 'A'|'B'|'C'|'D'|'E'|'F'|'G';
heating_type?: HeatingType;
}
type PropertyType = 'detached'|'semi_detached'|'terraced'|'flat'|'bungalow'|'maisonette'|'other';
type HeatingType = 'gas_central'|'electric'|'oil'|'heat_pump'|'district'|'solid_fuel'|'other';
interface AddressesObject {
current: PropertyObject;
previous?: PropertyObject;
}
// Returned by GET /partner/sessions/:id/property
type CollectionDay = 'monday'|'tuesday'|'wednesday'|'thursday'|'friday'|'saturday'|'sunday';
type BinType = 'general_waste'|'recycling'|'food_waste'|'garden_waste'|'glass';
interface BinCollectionPayload {
primary_collection_day: CollectionDay;
primary_collection_types: BinType[];
collection_frequency: 'weekly'|'fortnightly'|'monthly';
next_collection_date: string; // ISO date
days_after_move_in: number; // int >= 0
reminder_eve_label: string;
secondary_collection_day?: CollectionDay;
secondary_collection_types?: BinType[];
}
interface BroadbandAvailability {
max_down_speed_mbps?: number; // >= 0
max_up_speed_mbps?: number; // >= 0
}
interface PropertyDetailResponse {
session_id: string;
property: PropertyDetailObject; // may be {} when data_completeness === 'empty'
data_completeness: 'complete' | 'partial' | 'empty';
extracted_at: string;
expires_at: string;
broadband?: BroadbandAvailability; // added 1.16
}
interface PropertyDetailObject {
property_type_code?: PropertyTypeCode;
property_type_subcode?: PropertyTypeSubcode;
building_type?: BuildingType;
building_style?: BuildingStyle;
number_of_bedrooms?: number;
number_of_bathrooms?: number;
floor_number?: number;
energy_performance_rating?: 'A'|'B'|'C'|'D'|'E'|'F'|'G';
has_parking?: boolean;
property_has_off_road_parking?: boolean;
property_has_underground_parking?: boolean;
property_has_a_driveway?: boolean;
bin_collection?: BinCollectionPayload; // added 1.16
}
type PropertyTypeCode = 'house'|'flat'|'maisonette'|'bungalow'|'other';
type PropertyTypeSubcode = 'detached'|'semi_detached'|'terraced'|'end_terrace'|'purpose_built_flat'|'converted_flat'|'studio'|'penthouse'|'other';
type BuildingType = 'residential'|'mixed_use'|'commercial'|'unknown';
type BuildingStyle = 'victorian'|'edwardian'|'georgian'|'interwar'|'post_war'|'modern'|'new_build'|'period'|'unknown';
type LocationRole = 'current' | 'previous';
Customer & occupant #
interface CustomerObject {
type: CustomerType;
email: string;
first_name?: string;
last_name?: string;
phone?: string;
date_of_birth?: string; // YYYY-MM-DD
is_primary_occupant?: boolean; // default: true
on_psr?: boolean;
address?: AddressObject; // optional — defaults to addresses.current
}
type CustomerType = 'owner_occupier'|'tenant'|'licensee'|'lodger'|'management_company'|'other';
interface OccupantObject {
type: OccupantType;
first_name?: string;
last_name?: string;
email?: string;
phone?: string;
is_primary_occupant?: boolean;
}
type OccupantType = 'owner'|'tenant'|'licensee'|'lodger'|'guarantor'|'other';
Move objects #
interface MoveObject {
in: MoveInObject;
out?: MoveOutObject;
}
interface MoveInObject {
move_in_date: string; // required, YYYY-MM-DD
bill_type?: 'single'|'split'; // default: 'single'
rent_pcm?: number; // integer GBP/month
tenancy_start_date?: string;
tenancy_end_date?: string;
}
interface MoveOutObject {
move_out_date: string; // required, YYYY-MM-DD
tenancy_end_date?: string;
reason?: MoveOutReason;
forwarding_address?: AddressObject;
notify?: {
energy?: boolean;
water?: boolean;
council_tax?: boolean;
tv_licence?: boolean;
broadband?: boolean;
mobile?: boolean;
};
final_reads?: FinalReadObject[];
}
type MoveOutReason = 'end_of_tenancy'|'sale'|'transfer'|'bereavement'|'other';
interface FinalReadObject {
category: 'energy'|'water';
fuel?: 'electricity'|'gas'; // only for energy
meter_serial_number?: string;
reading: number;
read_date: string; // YYYY-MM-DD
photo_data_base64?: string; // PNG, max 500 KB decoded
estimated?: boolean;
}
interface MetersObject {
mpan?: string;
mprn?: string;
current_supplier_electricity?: string;
current_supplier_gas?: string;
}
interface ConsentObject {
data_sharing_accepted: true;
data_sharing_accepted_at: string;
marketing_accepted?: boolean;
}
type ContextEntry =
| { name: string; type: 'string'; value: string }
| { name: string; type: 'number'; value: number }
| { name: string; type: 'boolean'; value: boolean };
Products #
type Category =
| 'energy' | 'water' | 'broadband' | 'council_tax' | 'mobile' | 'tv_licence'
| 'home_insurance' | 'breakdown_cover';
interface ProductBase {
product_id: string; // PR-NNNN-NNNN-C
category: Category;
sub_categories: string[];
supplier_name: string;
supplier_logo_url?: string | null;
location: LocationRole; // 'current' | 'previous' — server-derived
is_incumbent: boolean;
monthly_amount_pence?: number;
annual_amount_pence?: number;
confidence: 'high'|'medium'|'low';
tariff_name?: string | null;
note?: string;
extracted_at: string;
expires_at: string;
}
type CacheStatus = 'fresh' | 'partial' | 'throttled'; // 'cached' is not emitted
interface ProductsResponse {
session_id: string;
products: Product[];
products_complete: boolean;
cache_status: CacheStatus;
total_suppliers_queried: number;
total_suppliers_responded: number;
next_cursor: string | null;
}
Incumbent pagination: Products with is_incumbent: true are always returned on page 1 across both current and previous locations, and they do not count against the limit query parameter. This guarantees you always see what the household pays today, regardless of pagination.
interface ProductSetup {
product_id: string;
setup_id: string;
category: string;
sub_categories: string[];
supplier_name: string;
location: LocationRole;
status: SetupStatus;
status_updated_at: string;
max_retries_exceeded: boolean;
details?: {
account_number?: string;
switch_date?: string;
direct_debit_setup?: boolean;
notified_at?: string;
channel?: 'email'|'api'|'postal';
final_reads_submitted?: boolean;
};
expected_resolution_at?: string;
}
type SetupStatus =
| 'pending_setup' | 'in_progress' | 'awaiting_supplier'
| 'confirmed' // location: 'current' only
| 'notified' // location: 'previous' only
| 'acknowledged' // location: 'previous' only
| 'failed';
LoA types #
interface LoaRecord {
loa_id: string;
loa_text_url: string; // Bearer-gated PDF URL — proxy server-side only
loa_text_html_url: string; // (1.17) session-token-gated document URL → GET /api/v1/partner/loa/:id/document → { body_html, checksum }
covers_product_ids: string[];
checksum: string; // SHA-256 of LoA body — submit as signed_checksum
expires_at: string;
version: string; // e.g. 'loa-template-v4'
}
interface LoaListResponse {
session_id: string;
loas: LoaRecord[];
}
interface SignatureImage {
mime_type: 'image/png' | 'image/jpeg'; // SVG is not accepted
data_base64: string; // base64 payload only (strip data: URI prefix)
width?: number; // 1 <= w <= 2000
height?: number; // 1 <= h <= 1000
}
interface LoaSignature { // used in POST /loa body and in loa_signatures[] on /customers
loa_id: string;
signed_checksum: string;
signed_at_client: string; // ISO 8601 datetime — advisory audit metadata only
signed_by_full_name: string; // max 200 chars
signature_image: SignatureImage;
ip_address?: string;
user_agent?: string; // max 500 chars
}
interface SubmitLoaResponse {
loa_id: string;
signed_at_server: string; // authoritative UTC timestamp — determines 12-month validity
signed_by_full_name: string;
}
Bank details types #
interface BankDetailsRequest {
sort_code: string; // 6 digits, may include hyphens/spaces
account_number: string; // 6-10 digits
account_holder_name: string;
consent: BankDetailsConsent;
}
interface BankDetailsConsent {
direct_debit_authorised: true;
authorised_at: string; // ISO-8601
consent_text_version: string;
}
interface BankDetailsObject {
id: string;
account_number_ending: string; // last 4 digits
sort_code_masked: string; // format "NN-**-NN"
account_holder_name: string;
bank_name?: string; // best-effort lookup; may be null
modulus_check_passed: boolean;
status: 'collected' | 'pending' | 'failed';
collected_at: string; // ISO-8601
consent: BankDetailsConsent;
}
interface BankDetailsResponse {
session_id: string;
bank_details: BankDetailsObject;
expires_at: string; // ISO-8601
}
Status response #
type OverallStatus = 'pending' | 'in_progress' | 'confirmed' | 'partial' | 'failed';
type MoveOutStatus = 'pending' | 'in_progress' | 'notified' | 'partial' | 'failed' | null;
type SubscriptionStatus = 'not_subscribed'|'trialing'|'active'|'past_due'|'cancelled';
type IsoDateTime = string;
type CustomerId = string;
interface SavingsSummaryResponse { // (1.17) — also available via GET /partner/customers/:id/savings-summary
customer_id: string;
annual_savings_pence: number; // realized savings; 0 until setups confirm
time_saved_minutes: number;
services_set_up: number; // count of confirmed setups at snapshot time
extracted_at: IsoDateTime;
}
interface CustomerStatusResponse {
customer_id: string;
overall_status: OverallStatus;
move_out_status: MoveOutStatus; // null when no move.out
subscription_status: SubscriptionStatus;
trial_ends_at: IsoDateTime | null;
products: Array<ProductSetup & {
last_updated_at: IsoDateTime;
}>;
last_updated_at: IsoDateTime;
savings_summary: SavingsSummaryResponse | null; // (1.17) null pre-terminal; populated once overall_status === 'confirmed'
}
interface CustomerCreateResponse {
customer_id: CustomerId;
interstitial_url?: string; // optional — handoff page to install the app
app_deeplinks?: {
ios?: string;
android?: string;
universal: string;
};
magic_link_sent: boolean;
loa_required: boolean; // added 1.16: true when an LoA signature is still outstanding
product_setup: {
initiated: number;
products: ProductSetup[];
};
}
Webhook types #
interface WebhookEnvelope {
id: string;
type: string; // primary event-type field
event: string; // alias for `type` — same value, for v1.10 back-compat
created_at: string;
api_version: string;
livemode: boolean; // true when environment === 'production'
environment: 'production' | 'sandbox';
data: {
session_id: string;
referral_id: string | null;
context: ContextEntry[];
[key: string]: unknown;
};
}
type WebhookEvent = WebhookEnvelope & (
| { type: 'session.embed_opened';
data: WebhookEnvelope['data'] }
| { type: 'session.details_confirmed';
data: WebhookEnvelope['data'] & { corrections: string[] } }
| { type: 'session.products_selected';
data: WebhookEnvelope['data'] & {
products: Array<{ product_id: string; category: Category;
sub_categories: string[]; location: LocationRole }> } }
| { type: 'session.loa_signed';
data: WebhookEnvelope['data'] & {
loa_id: string; signed_at_server: IsoDateTime; signed_by_full_name: string } }
| { type: 'session.account_created';
data: WebhookEnvelope['data'] & { customer_id: CustomerId } }
| { type: 'session.app_installed';
data: WebhookEnvelope['data'] & {
customer_id: CustomerId; device_type: 'ios'|'android';
installed_at: IsoDateTime } }
| { type: 'session.app_deferred';
data: WebhookEnvelope['data'] & {
customer_id: CustomerId; deferred_reason: string } }
| { type: 'subscription.changed';
data: WebhookEnvelope['data'] & {
customer_id: CustomerId; previous_status: SubscriptionStatus | null;
new_status: SubscriptionStatus; trial_ends_at: IsoDateTime | null } }
| { type: 'setup_status.changed';
data: WebhookEnvelope['data'] & {
customer_id: CustomerId; product_id: string; setup_id: string;
category: Category; sub_categories: string[]; supplier_name: string;
location: LocationRole; previous_status: SetupStatus; new_status: SetupStatus;
max_retries_exceeded: boolean; details?: ProductSetup['details'] } }
| { type: 'move_out_notification.sent';
data: WebhookEnvelope['data'] & {
customer_id: CustomerId; setup_id: string; product_id: string;
category: Category; supplier_name: string; location: 'previous';
notified_at: IsoDateTime; channel: 'email'|'api'|'postal' } }
| { type: 'move_out_notification.failed';
data: WebhookEnvelope['data'] & {
customer_id: CustomerId; setup_id: string; product_id: string;
category: Category; supplier_name: string; location: 'previous';
failed_at: IsoDateTime; reason: string; retry_scheduled_at?: IsoDateTime } }
| { type: 'bank_details.collected';
data: WebhookEnvelope['data'] & {
bank_details_id: string; account_number_ending: string;
sort_code_masked: string; modulus_check_passed: boolean;
collected_at: IsoDateTime } }
);
Error types #
type ErrorType =
| 'invalid_request' | 'invalid_credentials' | 'not_found'
| 'rate_limited' | 'idempotency_conflict' | 'mode_not_implemented' | 'internal';
type ErrorCode =
| 'validation.failed'
| 'loa.products_required'
| 'signature_image.invalid'
| 'customer.products_required'
| 'customer.product_not_in_catalog'
| 'auth.invalid_credentials'
| 'session.not_found'
| 'customer.not_found'
| 'loa.not_found'
| 'bank_details.not_found'
| 'idempotency.fingerprint_mismatch'
| 'loa.already_signed'
| 'loa.selection_changed'
| 'bank_details.session_locked'
| 'customer.session_already_has_customer'
| 'loa.template_changed'
| 'bank_details.invalid_modulus'
| 'property.discovery_in_progress'
| 'rate.limited'
| 'internal.error'
| 'mode.live_not_implemented';
interface ErrorEnvelope {
error: {
type: ErrorType;
code: ErrorCode;
message: string;
request_id: string;
param?: string;
missing_fields?: string[];
details?: Record<string, unknown>;
};
}
Embed SDK types #
type ThemePreset = 'hellobill' | 'light' | 'dark' | 'system';
type Locale = 'auto' | 'en' | 'en-GB' | 'cy-GB';
interface AppearanceObject {
theme?: ThemePreset;
disableAnimations?: boolean;
variables?: AppearanceVariables;
rules?: AppearanceRules;
}
interface AppearanceVariables {
colorPrimary?: string; colorOnPrimary?: string;
colorBackground?: string; colorSurface?: string;
colorBorder?: string; colorText?: string;
colorTextSecondary?: string; colorTextPlaceholder?: string;
colorTextDisabled?: string; colorDanger?: string;
colorSuccess?: string; colorWarning?: string;
iconColor?: string; iconHoverColor?: string;
iconCheckmarkColor?: string; fontFamily?: string;
fontSizeBase?: string; fontLineHeight?: string;
fontWeightNormal?: string; fontWeightMedium?: string;
fontWeightBold?: string; spacingUnit?: string;
gridRowSpacing?: string; gridColumnSpacing?: string;
borderRadius?: string; buttonBorderRadius?: string;
focusBoxShadow?: string; focusOutline?: string;
}
type AppearanceSelector =
| '.HBLabel'
| '.HBInput' | '.HBInput:focus' | '.HBInput:hover'
| '.HBInput--invalid' | '.HBInput::placeholder'
| '.HBButton' | '.HBButton:hover' | '.HBButton:focus' | '.HBButton:disabled'
| '.HBCard' | '.HBCard--selected' | '.HBCard:hover'
| '.HBCheckbox' | '.HBCheckboxLabel' | '.HBCheckboxInput'
| '.HBRadioIcon' | '.HBRadioIconOuter' | '.HBRadioIconInner'
| '.HBTab' | '.HBTab:hover' | '.HBTab--selected'
| '.HBAccordionItem' | '.HBError' | '.HBMenu' | '.HBMenuAction';
type AppearanceRules = Partial<Record<AppearanceSelector, Record<string, string>>>;
type LifecycleEventType =
| 'ready' | 'session.created' | 'consent.granted' | 'screen.shown'
| 'loa.signed' | 'account.creating' | 'account.created'
| 'error' | 'complete' | 'close';
interface CompletionResult {
customer_id: string;
selected_products: { product_id: string; location: 'current' | 'previous' }[];
loa_signed_at?: string;
setup_status?: OverallStatus;
move_out_status?: MoveOutStatus | null;
}
interface InitOptions {
baseEndpoint: string;
mountTo: string | HTMLElement;
sessionData: SessionData;
embedOrigin?: string; // default 'https://embed-sandbox.hellobill.app'; dev/UAT override only — ignored when NODE_ENV==='production'
locale?: Locale;
appearance?: AppearanceObject;
integrations?: { gmapsKey?: string };
onComplete?: (result: CompletionResult) => void;
onClose?: (reason: 'user_dismissed' | 'timeout' | 'error') => void;
onEvent?: (event: { type: LifecycleEventType; data: Record<string, unknown> }) => void;
onUserAction?: (action: { type: string; data: Record<string, unknown> }) => void;
}
interface EmbedHandle {
updateAppearance(patch: Partial<AppearanceObject>): void;
setLocale(locale: Locale): void;
destroy(): void;
}
GET /api/v1/partner/customers (list) was removed in Revision 1.13.
The endpoint returns 404 on all environments. Use
GET /partner/customers/:id/status to poll per-customer setup state,
or use webhooks to receive push notifications as status changes occur.
Partners track customer IDs themselves via the customer_id returned
from POST /partner/sessions/:id/customers.
Versioning #
- Current:
/api/v1/ - Breaking changes →
/api/v2/with 12-month/api/v1/overlap - Non-breaking additions deployed without version bump
- Deprecation: 90 days notice via email +
X-HelloBill-Deprecationresponse header
expires_at reference
Several fields named expires_at (or expires_in) appear in this guide with different semantics. Keep them straight:
| Field | Object | Duration | Meaning |
|---|---|---|---|
expires_in | Token response | 300 s | Bearer access_token lifetime — refresh before this |
expires_at | Session response | 24 h | Session record usability — after this the session_id returns 404 and the record is purged. Changed in 1.16 (was 90 days). |
expires_at | Product | 24 h | product_id validity |
expires_at | LoA template | ~30 days | LoA template expiry |
signed_at_server + 12 mo | LoA signature | 12 months | Signed LoA legal validity — signed_at_server is the authoritative server timestamp |
Idempotency-Key TTL | Header | 24 h | Same key + same request → same response |
force_refresh throttle | Per session | 5 min | Cooldown between forced cache busts |
Changelog #
Partner-facing highlights since Revision 1.11. For the full internal spec changelog, see the canonical spec document.
Revision 1.18 — June 2026
Breaking change for partners consuming embed_url directly. SDK-mount partners are unaffected.
- BREAKING:
embed_url→embed_base_url— thePOST /sessionsresponse field is renamed. The value is now the embed origin only (e.g.https://embed-sandbox.hellobill.app), with no path or token. The SDK composes the full iframe URL as{embed_base_url}/{sdk_version}/onboard?token={session_token}. Partners must not construct the iframe URL themselves. - API-authoritative embed origin + SDK-composed version path — the origin comes from the API response (
embed_base_url); the version path segment is pinned into the SDK build. This separates origin authority (API) from version pinning (SDK build). - Embed host →
embed-sandbox.hellobill.app— the SDK script, iframe origin, and CSP directives now referencehttps://embed-sandbox.hellobill.app. embedOriginnow enforced — theInitOptions.embedOriginoption is honored only whenNODE_ENV !== 'production'; in production builds it is ignored and the compiled default is used. When honored, the value is validated against the compiled origin allowlist.- M1/M2 security scaffolds documented — M1 (tier pinning: SDK build compiled for a tier, rejects mismatched origins) and M2 (token-tier binding: session token carries a tier/mode claim, embed refuses to mount on mismatch) are now documented. Both surface as
onEvent 'error'; no partner action required.
Revision 1.17 — June 2026
Additive release. No breaking changes for partners on 1.16.
- Service slots (removals) — the embed now fetches schedulable slots via
GET /partner/sessions/:id/service-slots?service=removals&provider=any_van(replaces the unbuiltanyvan-slotsroute). Products that require slot scheduling carryrequires_slot_scheduling: trueandslot_service: 'removals'. The chosen slot is submitted as a per-productselected_products[].selected_slotobject on account creation. - LoA document endpoint — the embed fetches the LoA HTML for signing via
GET /api/v1/partner/loa/:id/document, which returns{ body_html, checksum }. Auth is the embedsession_tokenBearer with scopeloa:read. The embed sanitisesbody_htmlbefore rendering.loa_text_html_urlon theLoaRecordnow points to this endpoint. - Savings summary — the embed success screen shows realized savings tiles ("£X/yr" + services set up), populated from the new
savings_summaryfield on the customer status response (nullpre-terminal, populated onceoverall_status === 'confirmed'). The embed polls status to terminal before showing the tiles, readingsavings_summaryinline; if it isnullit falls back toGET /partner/customers/:id/savings-summary, and shows placeholders (--) if neither is available yet. Also available as a standalone resource via that endpoint. - Schema reference updated:
SavingsSummaryResponsetype added;CustomerStatusResponsegainssavings_summary: SavingsSummaryResponse | null;LoaRecord.loa_text_html_urlcomment updated to reflect the document endpoint.
Revision 1.16 — May 2026
Reconciliation release bringing the specification into 1:1 alignment with the shipped implementation. Several contract clarifications and one corrected lifetime.
- New endpoint:
POST /partner/sessions/:id/loa— submit a single signed Letter of Authority.loa_signatures[]onPOST /customersis now optional; submit via either path. Account-creation response addsloa_required: boolean. - Per-environment base URLs — Production
partnerapi.hellobill.app, UAT, Dev. Mode is determined by theclient_idprefix, not the host. gmaps_keyadded to thePOST /sessionsresponse (forwarded by the SDK to the embed asintegrations.gmapsKey).- Session record lifetime corrected to 24 h (was documented as 90 days in 1.15). The embed
session_tokenJWT remains 300 s. - Idempotency clarified: deduplication is via an explicit
Idempotency-Keyheader only (no automatic email+address dedup). Same key + different body →409 idempotency.fingerprint_mismatch. GET /partner/sessions/:idreturns aSessionSummaryprojection, not a full payload echo.- Property response gains
bin_collection(BinCollectionPayload) and a top-levelbroadband(BroadbandAvailability). cache_statusvalues arefresh | partial | throttled(thecachedvalue is not emitted).- Signature image MIME types are
image/pngandimage/jpeg(SVG is not accepted). Signature error code issignature_image.invalidonly. - Error codes corrected to the shipped set: shape/consent failures →
400 validation.failed; bank modulus failures →422 bank_details.invalid_modulus. Legacy codesbank_details.invalid_sort_code,bank_details.invalid_account_number,bank_details.modulus_check_failed,consent.required,auth.token_expired,auth.token_malformed,signature_image.too_large,signature_image.dimensions_invalidare not emitted by the current implementation. error.typeenumerated:invalid_request | invalid_credentials | not_found | rate_limited | idempotency_conflict | mode_not_implemented | internal. Validation errors return a single flattenedmessageundervalidation.failed(no per-fieldparam/missing_fields).503 mode.live_not_implementeddocumented forlive_requests to backends not yet enabled for a partner.- Theming split into Shipped surface (
theme,disableAnimations, partialvariables,mode,locale,gmapsKey, handle methods) and Roadmap (rulesallowlist, full variable set, lock-list enforcement, WCAG 2.2 AA, fullcy-GBtranslation). InitOptions.embedOrigindocumented (defaulthttps://embed-sandbox.hellobill.app; honored only in non-prod — ignored in production builds).- Schema reference updated:
SignatureImageandLoaSignaturetypes added;BinCollectionPayload,BroadbandAvailabilityadded;CacheStatusremoves'cached';CustomerCreateResponseaddsloa_required.
Revision 1.15 — May 2026
Quality release. No request/response shape changes since 1.14. 22 internal fixes resolving inconsistencies, dangling cross-references, and missing type definitions.
- Expanded §16 with all 12 webhook variants,
ErrorCodeunion (32 codes),ErrorEnvelope,LoaRecord,LoaListResponse,CustomerStatusResponse,CustomerCreateResponse,SessionData, and other missing utility types session.loa_signedwebhook payload corrected to usesigned_at_server(notsigned_at)- Rate-limit code corrected to
rate.limitedthroughout (wasrate_limit.exceededin some places) - Added
GET /partner/sessions/:iddedicated section - Added
§9.4 Events & Callbacksparent heading
Revision 1.14 — May 2026
Spec-only reconciliation. No API contract changes.
- Bank-details field renames:
direct_debit_guarantee_accepted→direct_debit_authorised,accepted_at→authorised_at,validated_atremoved and replaced bystatus: 'collected' | 'pending' | 'failed'+collected_at - Webhook envelope:
typedocumented as primary field;eventas back-compat alias;livemode: booleanadded - GMaps integration:
InitOptions.integrations.gmapsKeyadded; embed falls back to inline SVG when absent - GET /partner/customers removed: endpoint returns 404; §8.7 stub added
- CompletionResult:
selected_productscorrected fromstring[]to{ product_id, location }[] - Property handler:
HellobillHandlerSettable updated withpropertyandbank-detailsroutes
Revision 1.13 — May 2026
Additive minor release. No breaking changes for partners on v1.12.
- New endpoint:
POST /partner/sessions/:id/bank-details— optional UK bank account collection with BACS modulus checking - New endpoint:
GET /partner/sessions/:id/bank-details— masked read-back - New webhook:
bank_details.collected - New error codes:
bank_details.invalid_sort_code,bank_details.invalid_account_number,bank_details.modulus_check_failed,bank_details.session_locked - Sandbox: bank details test vectors added (§15)
Revision 1.12 — May 2026
Additive at the API surface. Behavioural change: granular events moved from onEvent to onUserAction.
- New callback:
onUserAction— granular user-interaction events (service.selected, product.chosen, screen.entered, etc.) - Event taxonomy split: lifecycle events remain on
onEvent(10 stable types); granular events route toonUserActiononly - New §9.4.1: Event taxonomy documentation with comparison table
- AppearanceObject: full theming API formalised in §16
- Migration: integrations that consumed granular events via
onEventmust addonUserActioncallback