HelloBill Partner Integration · Embed SDK
Revision 1.18 Versioning

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.

Backend setup
1 line
Mount the middleware
Frontend setup
1 line
Init the embed
Customer journey
~10 screens
~2 min with full data
Session expiry
24 h
Token TTL: 5 min

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:

EnvironmentPartner API base
Productionhttps://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.

Why the middleware pattern?

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

bash
npm install @hello-bill/node @hello-bill/sdk

2. Mount the middleware (server)

typescript
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)

html
<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>
How the SDK composes the embed URL

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.

That's it

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 CredentialsCreate Key Pair:

  • client_id — prefixed sb_ (sandbox) or live_ (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

http
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:

json
{
  "access_token": "eyJhbGciOiJSUzI1NiJ9...",
  "token_type": "Bearer",
  "expires_in": 300
}
  • Cache the token. Refresh at expires_in − 30 s.
  • The @hello-bill/node SDK 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

HTTPCodeRetryableCause
400validation.failednoMissing or invalid grant_type, client_id, or client_secret in the request body
401auth.invalid_credentialsno (re-auth)Keycloak rejected the credentials (wrong client_id/client_secret), or a /partner/* request presented a missing, malformed, or expired Bearer token
403auth.insufficient_scopeno(target) Key authenticated but lacks permission for the resource
429rate.limitedyes(target) Token endpoint rate limit — see Rate limits
502internal.erroryesKeycloak unreachable, or returned a non-token response
Token validation is uniform

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:

SurfaceConventionExample
JSON body field namessnake_casecustomer.first_name, data_sharing_accepted
Query parameterssnake_case?force_refresh=true&product_ids=…
HTTP header namesKebab-Case-Title (RFC 7230)Authorization, Idempotency-Key, X-HelloBill-Signature
Path segmentslowercase, hyphenated/partner/sessions/:id/products
Enum string valuessnake_case"dual_fuel", "owner_occupier"
TypeScript SDK fieldscamelCaseclient.sessions.create({ … }) — language-idiomatic
Time valuesISO 8601 UTC"2026-05-10T14:32:00Z"
Date valuesISO 8601 date"2026-05-10"
Monetary valuesInteger pence, GBPmonthly_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 (not 403), 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 prefixModeSide effects
sb_SandboxMock discovery, no real emails, no real switches
live_ProductionReal 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

HeaderRequiredNotes
AuthorizationrequiredBearer {access_token} — handled by SDK
Content-Typerequiredapplication/json — handled by SDK
Idempotency-KeyrecommendedUUID v4. Safe to retry on network failure.

Request payload #

The full shape you return from buildSessionPayload:

json
{
  "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 }
  ]
}
Minimum required

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.

Move-out is optional

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.

FieldTypeRequiredDefaultValidationNotes
typeenumrequiredCustomerTypeLegal relationship to property
emailstringrequiredRFC 5322, max 254 charsAccount creation + magic link
first_namestringoptionalnullMax 100 charsSkips name screen
last_namestringoptionalnullMax 100 charsSkips name screen
phonestringoptionalnullE.164 formatSMS auth fallback
date_of_birthstringoptionalnullISO 8601 YYYY-MM-DDRequired by some energy suppliers
is_primary_occupantbooleanoptionaltrueFalse if creating on behalf of another
on_psrbooleanoptionalnullPriority Services Register status
addressAddressObjectoptionaladdresses.currentSee AddressObjectCustomer'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:

ValueWhen to use
owner_occupierOwns and occupies (freehold or long leasehold)
tenantRenting under tenancy agreement (AST, periodic, regulated)
licenseeLicence to occupy — legally distinct from a tenancy
lodgerLodger or sub-tenant in another person's home
management_companyProfessional managing agent acting on behalf of owner
otherAny relationship not covered above

occupants optional, array #

Additional people living at the property. May be empty [] or omitted.

FieldTypeRequiredDefaultValidation
typeenumrequiredOccupantType
first_namestringoptionalnullMax 100 chars
last_namestringoptionalnullMax 100 chars
emailstringoptionalnullRFC 5322
phonestringoptionalnullE.164
is_primary_occupantbooleanoptionalfalseAt 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.

FieldTypeRequiredDefaultValidationNotes
address_line_1stringrequiredFree text, max 255 chars (server normalises whitespace, casing, BS 7666 punctuation)First display line — usually the building name/number + street
address_line_2stringoptionalnullMax 255 charsSecond display line — usually a flat/unit reference
address_line_3stringoptionalnullMax 255 charsThird 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)
citystringrequiredMax 100 charsPost town
postcodestringrequiredUK postcode, 3–8 chars
uprnstringoptionalnull12-digit UPRNRoyal Mail / OS Unique Property Reference. Canonical when supplied.
countryenumoptional"GB""GB" (reserved for future expansion)Currently UK addresses only

addresses required #

Grouped current property (required) + optional previous property for move-out journeys.

FieldTypeRequiredNotes
addresses.currentPropertyObjectrequiredThe property the customer is moving INTO. Where utilities are set up.
addresses.previousPropertyObjectoptionalThe 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 (AG), 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

FieldTypeRequiredDefaultValidationNotes
move_in_datestringrequiredISO 8601 YYYY-MM-DDSkips move-in screen
bill_typeenumoptional"single"single | splitHow bills are apportioned
rent_pcmintegeroptionalnullPositive integer, GBPAffordability context
tenancy_start_datestringoptionalnullISO 8601Tenancy commencement (may pre-date physical move-in)
tenancy_end_datestringoptionalnullISO 8601, after tenancy_start_dateTenancy 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.

FieldTypeRequired (when move.out present)Notes
move_out_datestringrequiredISO 8601. Accepted up to 90 days in the past and 180 days in the future.
tenancy_end_datestringoptionalMay differ from physical move-out (council-tax liability often follows tenancy end)
reasonenumoptionalend_of_tenancy · sale · transfer · bereavement · other
forwarding_addressAddressObjectoptionalWhere final bills, refund cheques, and Royal Mail redirection should go
notifyobjectoptionalPer-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_readsFinalReadObject[]optionalMax 8 entries. Final meter readings at the previous property.

FinalReadObject

FieldTypeRequiredNotes
categoryenumrequiredenergy | water
fuelenumrequired when category: 'energy'electricity | gas. Omit for water.
meter_serial_numberstringoptionalMax 50 chars
readingintegerrequiredReading value in the supplier's natural units (kWh / m³)
read_datestringrequiredISO 8601 YYYY-MM-DD, on or before move.out.move_out_date
photo_data_base64stringoptionalPNG, max 500 KB decoded. Audit evidence; supplier may request for disputed readings.
estimatedbooleanoptionaltrue if estimated rather than physically taken

meters optional #

All sub-fields optional.

FieldTypeValidationEffect
mpanstring13 digitsSkips Electralink electricity lookup
mprnstring6–10 digitsSkips Electralink gas lookup
current_supplier_electricitystringMax 100 charsSkips Electralink lookup
current_supplier_gasstringMax 100 charsSkips Electralink lookup
FieldTypeRequiredValidationNotes
data_sharing_acceptedbooleanrequiredMust be literal trueGDPR lawful basis. Rejected if false.
data_sharing_accepted_atstringrequiredISO 8601 datetimeWhen consent was captured
marketing_acceptedbooleanoptionalDefault: false
Consent vs LoA

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.

json
"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:

typescript
| { 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.

PropertyBehaviour
Key transportIdempotency-Key request header (opaque string; UUID v4 recommended)
StorageThe key is SHA-256 hashed and persisted server-side with the response
Window24 hours
Same key + same bodyReturns the original stored response (200 OK for the repeat; the first call returned 201 Created)
Same key + different bodyReturns 409 idempotency.fingerprint_mismatch — the key is bound to the exact request body it was first used with
No key suppliedEach call creates a new resource — no deduplication
Practical guidance

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) #

json
{
  "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..."
}
FieldTypeAlways presentNotes
session_idstringyesStable reference for API calls and webhooks
session_tokenstringyesJWT, 300 s. Pass to embed SDK.
embed_base_urlstringyesEmbed 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_statusenumyesrunning | complete
estimated_ready_secondsintegernoOmitted when discovery_status is complete
provided_fieldsstring[]yesDot-notation paths of recognised fields
expires_atstringyesSession expiry — 24 h from creation. After this the session_id returns 404.
gmaps_keystringnoGoogle 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

ParameterRequiredDefaultNotes
emailoptionalFilter by customer.email (exact match)
postcodeoptionalFilter by addresses.current.postcode (normalised)
created_afteroptional30 days agoISO 8601 datetime lower bound
created_beforeoptionalnowISO 8601 datetime upper bound
statusoptionalallcreated | customer_created | expired
cursoroptionalnullOpaque cursor from previous response
limitoptional50Integer 1–200
json
{
  "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.

http
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.

Embed SDK: Direct Debit is handled by the embed

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

HeaderRequiredNotes
AuthorizationrequiredBearer {access_token}
Content-Typerequiredapplication/json
Idempotency-KeyrecommendedUUID v4. Safe to retry on network failure.

Request body

json
{
  "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"
  }
}
FieldTypeRequiredNotes
sort_codestringrequired6 digits. May be supplied with hyphens (08-99-99) or spaces; HelloBill normalises to digits-only.
account_numberstringrequired6–10 digits. Most UK accounts are 8 digits.
account_holder_namestringrequired1–100 characters.
consent.direct_debit_authorisedbooleanrequiredMust be literal true. Submissions with false or omitted are rejected with 400 validation.failed. Field name follows UK DDIM terminology.
consent.authorised_atstringrequiredISO 8601 timestamp of when the customer authorised the mandate.
consent.consent_text_versionstringrequiredString identifier for the consent copy shown to the customer (allows audit replay).

Response — 200 OK

json
{
  "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"
}
Privacy & masking

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).

status field — forward compatibility

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.

http
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:

  1. Stage 1 — Shape. sort_code matches /^\d{6}$/ after normalisation. account_number matches /^\d{6,10}$/. account_holder_name length 1–100 after trimming.
  2. Stage 2 — UK BACS Modulus Check. Applies Vocalink's modulus-checking algorithm using current valacdos.txt weight tables and scsubtab.txt sort-code substitutions. Algorithms applied per sort-code range: MOD10, MOD11, or DBLAL, with exceptions 1–14 as published. Failures return 422 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

HTTPerror.codeWhen
400validation.failedMissing/invalid fields, or consent.direct_debit_authorised not true
422bank_details.invalid_modulusAccount number fails the BACS modulus check
404session.not_foundSession does not exist or is expired
404bank_details.not_found(GET only) No bank details collected for this session
409bank_details.session_lockedSession already finalised (post-account-creation)
409idempotency.fingerprint_mismatchIdempotency-Key reused with a different body
Legacy codes not emitted

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.

  1. Server start. Your backend mounts createHellobillRouter({clientId, clientSecret, buildSessionPayload}) once at boot.
  2. Customer clicks "Set up". Your frontend calls HelloBill.init({baseEndpoint, sessionData}).
  3. Embed → your middleware. The embed POSTs to {baseEndpoint}/session with the session data your frontend supplied.
  4. Middleware → HelloBill API. Your buildSessionPayload runs server-side, transforms the data, and the middleware calls POST /api/v1/partner/sessions with your client_secret-derived access_token.
  5. HelloBill → middleware. Returns session_id + session_token + provided_fields. Async product discovery starts in the background.
  6. Middleware → embed. Returns only the session_token — credentials stay on your server.
  7. Embed renders. Exchanges the token for session context; adaptive flow skips screens for any field listed in provided_fields.
  8. Customer journey. Confirm details → select products → draw LoA signature on screen.
  9. HelloBill → your webhook endpoint. At each stage: session.embed_opened, session.products_selected, session.loa_signed, session.account_created.
  10. 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

RouteMethodHandlerPurpose
{base}/sessionPOSTsessionCreates session, returns session_token
{base}/productsGETproductsProxies GET /partner/sessions/:id/products
{base}/loaGETloaProxies 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}/statusGETstatusProxies GET /partner/customers/:id/status
{base}/customersPOSTcustomersProxies POST /partner/sessions/:id/customers
{base}/propertyGETpropertyProxies GET /partner/sessions/:id/property — enriched property characteristics
{base}/bank-detailsPOSTbankDetails.submitProxies POST /partner/sessions/:id/bank-details
{base}/bank-detailsGETbankDetails.getProxies GET /partner/sessions/:id/bank-details

Next.js (App Router)

typescript
// 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
Critical: CSRF + auth binding

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" />
iframe fallback

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.

No-JS 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

CallbackFires whenSignature
onCompleteCustomer finishes the flow (account created, LoA signed)(result: CompletionResult) => void
onCloseEmbed is dismissed before completion(reason: 'user_dismissed' | 'timeout' | 'error') => void
onEventLifecycle milestones (10 stable events). See Lifecycle events.(event: { type: LifecycleEventType; data }) => void
onUserActionGranular user-interaction events (higher cardinality, evolving). See User-action events.(action: { type: string; data }) => void
CompletionResult shape

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.

ChannelCardinalityPurposeUse for
onEventLow (10 events)Lifecycle: well-defined milestones, mostly correlated with Partner API state changesFunnel analytics, server-side logging, lifecycle-driven UI updates
onUserActionHigher (~15 events; non-stable)Granular: in-flow user choices, screen transitions, micro-interactionsDetailed product analytics, A/B test instrumentation, replay reconstruction

Lifecycle events (onEvent) #

Event typeFired when
readyEmbed has mounted and is ready to receive interaction
session.createdSession record created upstream (post-POST /partner/sessions)
consent.grantedGDPR/data-sharing consent received from the customer
screen.shownA named screen has become visible. Use for funnel step gating.
loa.signedCustomer signature captured on the Letter of Authority
account.creatingPOST /partner/sessions/:id/customers in flight
account.createdCustomer record created upstream; result.customer_id available
errorRecoverable error visible to the customer (network, validation, etc.)
completeFlow completed successfully; mirrors onComplete for partners that prefer a single channel
closeFlow 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 (use screen.shown lifecycle event for funnel logic)
  • service.selected — customer toggles a service category on the service-select screen
  • product.chosen — customer commits to a specific tariff/product
  • card_preview.activated — customer expands a product-preview card
  • council.reduction_toggle — customer toggles council tax reduction declaration
  • discovery.started / discovery.complete — async discovery progress
  • setup.status / setup.complete — async setup progress (also surfaces via webhooks)
typescript
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);
    }
  },
});
Migration from v1.11

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).

Optional for Embed SDK partners

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

http
GET /api/v1/partner/sessions/ses_01HX.../property
Authorization: Bearer {access_token}

Query parameters

ParameterRequiredDefaultNotes
force_refreshoptionalfalseRe-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.

json
{
  "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

FieldTypeAlways presentNotes
session_idstringyesEcho of the path parameter
propertyobjectyesThe property record. May be empty ({}) when data_completeness: 'empty'.
data_completenessenumyescomplete · partial · empty — qualitative signal of how much of the schema was populated
extracted_atstringyesISO 8601 — when the property data was resolved
expires_atstringyesISO 8601 — 24 h after extracted_at
broadbandobjectno(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

FieldTypeNotes
property_type_codeenumhouse · flat · maisonette · bungalow · other
property_type_subcodeenumdetached · semi_detached · terraced · end_terrace · purpose_built_flat · converted_flat · studio · penthouse · other
building_typeenumresidential · mixed_use · commercial · unknown
building_styleenumvictorian · edwardian · georgian · interwar · post_war · modern · new_build · period · unknown
number_of_bedroomsnumber≥ 0, decimals allowed for studios (0.5)
number_of_bathroomsnumber≥ 0, decimals allowed for half-baths (1.5)
floor_numberinteger−5 to 200. 0 = ground floor. Negative = basement. Omit / null for houses.
energy_performance_ratingenumAG
has_parkingbooleanRoll-up: true if any of the three specific parking flags below is true
property_has_off_road_parkingbooleanOff-road parking present (private bay, garage, etc.)
property_has_underground_parkingbooleanUnderground / basement parking
property_has_a_drivewaybooleanDriveway present
bin_collectionobject(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?[].
Parking field semantics

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 resolved
  • partial — pre-filled quiz; some fields resolved
  • empty — full quiz; no data resolved

Error cases

HTTPcodeNotes
404session.not_foundSession ID not found for this partner
425property.discovery_in_progressFirst-time discovery still running. Response includes Retry-After (seconds).
429rate.limitedRate 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 scope (template 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.

Standalone 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)

  1. 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 via GET /partner/sessions/:id/service-slots?service=removals&provider=any_van and presents a slot-picker screen. The chosen slot is submitted as a per-product selected_products[].selected_slot object on account creation. The selected_slot.product_id must equal the parent selected_products[].product_id of the slot-scheduling product it belongs to — a mismatch or a missing slot on a requires_slot_scheduling product is rejected (400 customer.missing_selected_slot), and a slot on a non-slot product is rejected (400 customer.unexpected_selected_slot).
  2. Embed fetches the appropriate LoA(s) via GET /loa?product_ids=....
  3. Embed fetches the LoA HTML for display via GET /api/v1/partner/loa/:id/document (returns { body_html, checksum }), using the embed session_token Bearer with scope loa:read. The embed sanitises body_html before rendering it.
  4. LoA text is displayed; customer reviews and signs (PNG or JPEG drawn signature — SVG is not accepted).
  5. Embed submits signed LoA(s) inline as loa_signatures[] on account creation — including loa_id, signed_checksum, signed_at_client, and the base64 signature image.
  6. Your webhook handler receives session.loa_signed with loa_id, signed_at_server (the authoritative server timestamp), and signed_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

FieldAlways presentNotes
loa_idyesStable reference. Must be passed back when submitting the signature on account creation.
loa_text_urlyesURL 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_urlyesDocument 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_idsyesThe product IDs this LoA authorises. Every product in the request must be covered by exactly one LoA.
checksumyesSHA-256 of the LoA text the customer is shown. Capture client-side and submit as signed_checksum to prove what was signed.
expires_atyesLoA template expiry — re-fetch after this time.
versionyesTemplate 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.

Why curated, not free-form

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 #

typescript
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'
});
FieldTypeRequiredDefaultNotes
themeenumoptional'hellobill''hellobill' | 'light' | 'dark' | 'system'
disableAnimationsbooleanoptionalfalseWhen false, the OS prefers-reduced-motion: reduce setting still disables animations.
variablesobjectoptional{}Design-token overrides. Merged on top of the chosen theme.
rulesobjectoptional{}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 #

PresetBehaviour
'hellobill' defaultHelloBill'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.

CategoryTokens
Colour — basecolorPrimary, colorOnPrimary, colorBackground, colorSurface, colorBorder
Colour — textcolorText, colorTextSecondary, colorTextPlaceholder, colorTextDisabled
Colour — semanticcolorDanger, colorSuccess, colorWarning
Colour — iconiconColor, iconHoverColor, iconCheckmarkColor
TypographyfontFamily, fontSizeBase, fontLineHeight, fontWeightNormal, fontWeightMedium, fontWeightBold
SpacingspacingUnit, gridRowSpacing, gridColumnSpacing
Radius & focusborderRadius, buttonBorderRadius, focusBoxShadow, focusOutline
colorOnPrimary

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.

Fonts

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)

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.

SelectorTargetsNotable allowed properties
.HBLabelField labelscolor, fontWeight, fontSize, letterSpacing, textTransform
.HBInputText inputsborderColor, borderWidth, backgroundColor, color, padding, boxShadow
.HBInput:focus, .HBInput:hover, .HBInput--invalid, .HBInput::placeholderInput statessame as .HBInput
.HBButton, .HBButton:hover, .HBButton:focus, .HBButton:disabledPrimary CTA buttonsbackgroundColor, color, borderColor, boxShadow, fontWeight, transform
.HBCard, .HBCard--selected, .HBCard:hoverProduct cards in the pickerbackgroundColor, borderColor, boxShadow, borderWidth
.HBCheckbox, .HBCheckboxLabel, .HBCheckboxInputCheckboxes (excluding consent — see lock list)borderColor, color, backgroundColor
.HBRadioIcon, .HBRadioIconOuter, .HBRadioIconInnerRadiosborderColor, backgroundColor, fill
.HBTab, .HBTab:hover, .HBTab--selectedSection tabscolor, backgroundColor, borderBottomColor, fontWeight
.HBAccordionItemCollapsible disclosure rowsbackgroundColor, borderColor
.HBErrorInline validation messagescolor, backgroundColor, borderColor
.HBMenu, .HBMenuActionDropdown menusbackgroundColor, borderColor, color, boxShadow
Properties that are never allowed inside rules

Regardless 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.

ElementReason
Letter of Authority disclosure text and signature canvasLegal — 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) disclosureOfgem/Ofwat customer-information rules require prominent, accessible presentation.
Switching cooling-off noticeOfgem switching rules require the 14-day cooling-off period to be visible at the point of decision.
Tariff Comparison Rate, where shownOfgem tariff disclosure standard requires consistent presentation across suppliers.
Supplier logos rendered from supplier_logo_urlTrust signal — partners cannot swap or restyle real supplier marks.
“Powered by HelloBill” footerTrust + auditability — customers must know who their data is being shared with.
Error / failure UI for switchingCustomers 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.

PairMinimum ratioBehaviour on fail
colorText on colorBackground4.5:1 (normal text)Dev-console warning; locked surfaces use fallback colorText from the active theme.
colorOnPrimary on colorPrimary4.5:1Dev-console warning; the SDK falls back to white or black (whichever satisfies the ratio) for the locked surfaces.
Focus indicators (focusOutline, focusBoxShadow) on colorBackground3:1Dev-console warning; SDK overlays its default focus ring on locked surfaces.
Non-text UI (.HBInput border, .HBCheckbox icon)3:1Dev-console warning.

Disabled-state elements are exempt from contrast minimums per WCAG 2.2 SC 1.4.3.

prefers-reduced-motion is always honoured

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:

typescript
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 #

ValueBehaviour
'auto' defaultResolved 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 your frame-src and connect-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 postMessage on init and on every updateAppearance(…). 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'. Use theme: 'system' instead; 'auto' is reserved for locale.
  • Defaults are good. The 'hellobill' theme works without any other configuration — appearance is 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:

typescript
HelloBill.init({
  baseEndpoint: '/api/hellobill',
  mountTo: '#hellobill-mount',
  sessionData: { /* ... */ },
  integrations: {
    gmapsKey: process.env.NEXT_PUBLIC_GMAPS_KEY,
  },
});
BehaviourDetail
Key handlingEmbedded client-side in the Maps Embed URL (https://www.google.com/maps/embed/v1/place?key=…). Never sent to HelloBill servers.
Production keysMust use HTTP referrer restrictions scoped to your partner domain(s) (e.g. https://yourapp.com/*). An unrestricted key is a billing risk.
No key providedEmbed falls back to an inline SVG map placeholder. No error is thrown and the journey continues normally — degradation only.
integrations fieldEntirely optional. Omitting it is equivalent to integrations: {}.

Recommended Google Cloud Console setup

  1. Create a Maps Embed API key (not Maps JavaScript API — the embed uses the simpler Embed API).
  2. Under "Application restrictions", select "HTTP referrers (web sites)".
  3. Add your partner domain(s): https://yourapp.com/* and https://staging.yourapp.com/*.
  4. 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

PropertyGuarantee
DeliveryAt-least-once. Same event may be delivered more than once.
OrderingNot guaranteed. Events may arrive out of order.
LatencyBest-effort within 30 s of the triggering action.
IdempotencyUse the id field to deduplicate.
Consuming webhooks correctly

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:

http
X-HelloBill-Signature: t=1715349660,v1=8a3f9c2b7e4d...
ComponentMeaning
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}.

Replay protection

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.

Key rotation

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.

typescript
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 });
  }
);
Critical

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 #

AttemptDelayTimeout
1Immediate10 s
230 s10 s
3 (final)5 min10 s

Failed events retained 30 days. Manual replay available in PartnerDock → Webhook Health.

Event reference #

Common envelope

FieldTypeAlways presentNotes
idstringyesUse as idempotency key
typestringyesPrimary event-type field — see table below. Use this field.
eventstringyesAlias for type — same value, emitted for backward compatibility with v1.10 consumers.
created_atstringyesISO 8601
api_versionstringyesWebhook 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.
livemodebooleanyestrue when environment === 'production', false otherwise.
environmentstringyes"production" | "sandbox"
data.session_idstringyes
data.referral_idstring | nullyes
data.contextContextEntry[]yesEcho of session context[]

Events (12 variants)

Event (type)Additional data fields
session.embed_opened
session.details_confirmedcorrections: string[]
session.products_selectedproducts: array of { product_id, category, sub_categories, location }
session.loa_signedloa_id, signed_at_server (authoritative server timestamp), signed_by_full_name
session.account_createdcustomer_id
session.app_installedcustomer_id, device_type: "ios"|"android", installed_at
session.app_deferredcustomer_id, deferred_reason
subscription.changedcustomer_id, previous_status, new_status, trial_ends_at?
setup_status.changedcustomer_id, product_id, setup_id, category, sub_categories, supplier_name, location, previous_status, new_status, max_retries_exceeded, details?
move_out_notification.sentcustomer_id, setup_id, product_id, category, supplier_name, location: "previous", notified_at, channel: "email"|"api"|"postal"
move_out_notification.failedcustomer_id, setup_id, product_id, category, supplier_name, location: "previous", failed_at, reason, retry_scheduled_at?
bank_details.collectedbank_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

json
{
  "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"
  }
}
Disambiguating switch vs closure events

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

json
{
  "error": {
    "type":       "invalid_request",
    "code":       "validation.failed",
    "message":    "addresses.current.postcode: String must contain at least 3 character(s)",
    "request_id": "req_01HX7G2K..."
  }
}
FieldAlways presentNotes
error.typeyesBroad category — see error.type enum below
error.codeyesMachine-readable specific code
error.messageyesHuman-readable description. For schema-validation failures this is the validation issues joined into one string.
error.request_idyesInclude in support requests (also returned as X-HelloBill-Request-Id)
error.paramnoReserved — present on selected errors only
error.missing_fieldsnoReserved — present on selected errors only
error.detailsnoReserved — optional structured detail object
Validation errors flatten to one message

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.typeUsed for
invalid_requestValidation and business-rule rejections (4xx, mostly 400)
invalid_credentialsAuthentication failures (401)
not_foundMissing / cross-partner resources (404)
idempotency_conflictIdempotency-Key reused with a different body (409)
rate_limitedRate-limit rejections (429)
mode_not_implementedlive_ request to an unimplemented backend (503)
internalUnexpected 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.

HTTPerror.codeerror.typeRetryableWhen
400validation.failedinvalid_requestnoRequest body/query fails schema validation
400loa.products_requiredinvalid_requestnoGET /loa with empty/missing product_ids
400signature_image.invalidinvalid_requestnoSignature image malformed or fails server checks (only error code for image issues; PNG/JPEG accepted, SVG is not)
400customer.products_requiredinvalid_requestnoselected_products missing/empty on account creation
400customer.product_not_in_cataloginvalid_requestnoA selected product_id is not available for this session
401auth.invalid_credentialsinvalid_credentialsno (re-auth)Bad client credentials, or missing/malformed/expired Bearer token
404session.not_foundnot_foundnoSession ID not found for this partner (scoped — see resource scoping)
404customer.not_foundnot_foundnoCustomer ID not found
404loa.not_foundnot_foundnoloa_id not found for this session
404bank_details.not_foundnot_foundnoNo bank details collected for this session
409idempotency.fingerprint_mismatchidempotency_conflictnoIdempotency-Key reused with a different request body
409loa.already_signedinvalid_requestnoLoA already signed with different attributes
409loa.selection_changedinvalid_requestyes (re-fetch + re-sign)Product selection changed since the LoA was issued — re-fetch GET /loa and re-sign
409bank_details.session_lockedinvalid_requestnoSession finalised; bank details can no longer be submitted
409customer.session_already_has_customerinvalid_requestnoSession already has a customer; a different body was submitted
422loa.template_changedinvalid_requestyes (re-sign)signed_checksum no longer matches the current LoA text — re-fetch and re-sign
422bank_details.invalid_modulusinvalid_requestnoAccount number fails the UK BACS modulus check
425property.discovery_in_progressinvalid_requestyes (retry)Property discovery still running. Retry-After header set.
429rate.limitedrate_limitedyesRate limit hit. Retry-After header set — see Rate limits.
500internal.errorinternalyesTransient/unexpected server error
502internal.errorinternalyesUpstream (Keycloak) unreachable on POST /auth/partner/token
503mode.live_not_implementedmode_not_implementednolive_ 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, 422 loa.template_changed): re-run GET /loa and re-capture the signature.
  • Non-retryable (other 4xx): fix the request before retrying.

Rate-limit headers #

http
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

CredentialWhere it livesMust NOT appear in
client_secretServer env varBrowser code, mobile bundles, git repos, logs
access_tokenServer memory (cached)Browser localStorage, logs
session_tokenPassed server→client onceLogs, analytics, third-party scripts
webhook_secretServer env varBrowser 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

DataRetentionDeletion trigger
Customer PIIAccount duration + 7 years (UK Companies Act)Customer deletion request
Signed LoA PDFs7 yearsRegulatory minimum
Session data (pre-account session payload)24 h from creationAutomatic expiry — the session record is purged and session_id returns 404 thereafter
Webhook delivery logs30 daysAutomatic expiry
Audit logs7 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.

BucketProductionSandboxScope
General requests (all endpoints except token)60 req/min300 req/minPer partner_org_id
POST /auth/partner/token1000 req/min1000 req/minPer 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.

Rate limit headers

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

PrefixDiscoveryEmailsSwitchesWebhooks
sb_Mock [4 energy, 6 broadband]SuppressedSimulated state machineReal payloads, "environment": "sandbox"
live_Real Electralink + water + councilRealReal switching chainProduction events

Sandbox / production isolation contract

  • Separate ID realms. All resource IDs are scoped to environment. A session_id created under sb_ cannot be fetched with a live_ token (and vice versa) — the response is 404 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

WhatValueExpected behaviour
addresses.current.postcode — full successSW1A 1AAAll categories return mock products at location: 'current'
addresses.current.postcode — partial failEC1A 1BBBroadband discovery fails; others succeed
addresses.current.postcode — all failZZ99 9ZZAll discovery fails; products: []
addresses.previous.postcode — successful move-outLS24 9PXReturns 2 mock incumbent products at location: 'previous' (energy + water)
addresses.previous.postcode — closure failureBS1 6XXMove-out notifications always fail (deterministic, for repeatable testing). Use to test move_out_notification.failed webhook.
move.out.move_out_date — too far futureany >180 d aheadReturns 400 move_out.invalid_date
final_reads[].reading — implausible99999999Returns 400 final_read.implausible
Email — existing sessiontest@hellobill.appReturns existing session (idempotency test)

Simulated switch lifecycle

text
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 codeAccount numberExpected result
08-99-9966374958Valid — modulus passes
10-79-9988837491Valid — modulus passes
20-29-5963748472Valid — modulus passes
93-80-6315764273Invalid — modulus fails (422 bank_details.invalid_modulus)
12-34-5612345Invalid — fails schema validation (400 validation.failed)

Simulated closure notification lifecycle

text
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.email to your own session — don't trust sessionData.email blind
  • ☐ Write buildSessionPayload — populate context[] 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.address when the account holder's address differs from addresses.current (otherwise omit and the server defaults it)
  • (Move-out journeys only) Supply addresses.previous AND move.out together; use move.out.notify.* flags to opt the customer in to per-service supplier notifications; supply move.out.final_reads[] when available (energy / water)
  • (Optional) Pass appearance to brand the embed; if your app has a theme picker, use theme: 'system' or call embed.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.sent and move_out_notification.failed; read data.location on setup_status.changed to disambiguate current vs previous address
  • ☐ Verify provided_fields matches expected skips (paths now use addresses.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 #

typescript
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 #

typescript
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 #

typescript
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 #

typescript
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 #

typescript
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 #

typescript
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.

typescript
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 #

typescript
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 #

typescript
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 #

typescript
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 #

typescript
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 #

typescript
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 #

typescript
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 /partner/customers — removed in 1.13

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-Deprecation response header

expires_at reference

Several fields named expires_at (or expires_in) appear in this guide with different semantics. Keep them straight:

FieldObjectDurationMeaning
expires_inToken response300 sBearer access_token lifetime — refresh before this
expires_atSession response24 hSession record usability — after this the session_id returns 404 and the record is purged. Changed in 1.16 (was 90 days).
expires_atProduct24 hproduct_id validity
expires_atLoA template~30 daysLoA template expiry
signed_at_server + 12 moLoA signature12 monthsSigned LoA legal validity — signed_at_server is the authoritative server timestamp
Idempotency-Key TTLHeader24 hSame key + same request → same response
force_refresh throttlePer session5 minCooldown 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_urlembed_base_url — the POST /sessions response 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 reference https://embed-sandbox.hellobill.app.
  • embedOrigin now enforced — the InitOptions.embedOrigin option is honored only when NODE_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 unbuilt anyvan-slots route). Products that require slot scheduling carry requires_slot_scheduling: true and slot_service: 'removals'. The chosen slot is submitted as a per-product selected_products[].selected_slot object 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 embed session_token Bearer with scope loa:read. The embed sanitises body_html before rendering. loa_text_html_url on the LoaRecord now points to this endpoint.
  • Savings summary — the embed success screen shows realized savings tiles ("£X/yr" + services set up), populated from the new savings_summary field on the customer status response (null pre-terminal, populated once overall_status === 'confirmed'). The embed polls status to terminal before showing the tiles, reading savings_summary inline; if it is null it falls back to GET /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: SavingsSummaryResponse type added; CustomerStatusResponse gains savings_summary: SavingsSummaryResponse | null; LoaRecord.loa_text_html_url comment 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[] on POST /customers is now optional; submit via either path. Account-creation response adds loa_required: boolean.
  • Per-environment base URLs — Production partnerapi.hellobill.app, UAT, Dev. Mode is determined by the client_id prefix, not the host.
  • gmaps_key added to the POST /sessions response (forwarded by the SDK to the embed as integrations.gmapsKey).
  • Session record lifetime corrected to 24 h (was documented as 90 days in 1.15). The embed session_token JWT remains 300 s.
  • Idempotency clarified: deduplication is via an explicit Idempotency-Key header only (no automatic email+address dedup). Same key + different body → 409 idempotency.fingerprint_mismatch.
  • GET /partner/sessions/:id returns a SessionSummary projection, not a full payload echo.
  • Property response gains bin_collection (BinCollectionPayload) and a top-level broadband (BroadbandAvailability).
  • cache_status values are fresh | partial | throttled (the cached value is not emitted).
  • Signature image MIME types are image/png and image/jpeg (SVG is not accepted). Signature error code is signature_image.invalid only.
  • Error codes corrected to the shipped set: shape/consent failures → 400 validation.failed; bank modulus failures → 422 bank_details.invalid_modulus. Legacy codes bank_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_invalid are not emitted by the current implementation.
  • error.type enumerated: invalid_request | invalid_credentials | not_found | rate_limited | idempotency_conflict | mode_not_implemented | internal. Validation errors return a single flattened message under validation.failed (no per-field param/missing_fields).
  • 503 mode.live_not_implemented documented for live_ requests to backends not yet enabled for a partner.
  • Theming split into Shipped surface (theme, disableAnimations, partial variables, mode, locale, gmapsKey, handle methods) and Roadmap (rules allowlist, full variable set, lock-list enforcement, WCAG 2.2 AA, full cy-GB translation).
  • InitOptions.embedOrigin documented (default https://embed-sandbox.hellobill.app; honored only in non-prod — ignored in production builds).
  • Schema reference updated: SignatureImage and LoaSignature types added; BinCollectionPayload, BroadbandAvailability added; CacheStatus removes 'cached'; CustomerCreateResponse adds loa_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, ErrorCode union (32 codes), ErrorEnvelope, LoaRecord, LoaListResponse, CustomerStatusResponse, CustomerCreateResponse, SessionData, and other missing utility types
  • session.loa_signed webhook payload corrected to use signed_at_server (not signed_at)
  • Rate-limit code corrected to rate.limited throughout (was rate_limit.exceeded in some places)
  • Added GET /partner/sessions/:id dedicated section
  • Added §9.4 Events & Callbacks parent heading

Revision 1.14 — May 2026

Spec-only reconciliation. No API contract changes.

  • Bank-details field renames: direct_debit_guarantee_accepteddirect_debit_authorised, accepted_atauthorised_at, validated_at removed and replaced by status: 'collected' | 'pending' | 'failed' + collected_at
  • Webhook envelope: type documented as primary field; event as back-compat alias; livemode: boolean added
  • GMaps integration: InitOptions.integrations.gmapsKey added; embed falls back to inline SVG when absent
  • GET /partner/customers removed: endpoint returns 404; §8.7 stub added
  • CompletionResult: selected_products corrected from string[] to { product_id, location }[]
  • Property handler: HellobillHandlerSet table updated with property and bank-details routes

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 to onUserAction only
  • New §9.4.1: Event taxonomy documentation with comparison table
  • AppearanceObject: full theming API formalised in §16
  • Migration: integrations that consumed granular events via onEvent must add onUserAction callback