Download OpenAPI specification:
applications:create, applications:read, applications:amend) and, optionally,
the IP ranges it may be used from. The key is shown once. Send it on every request as
Authorization: Bearer <key>. The server in this page is a placeholder: use the base URL you
are given, which ends in /partner. Every path here, such as /v1/applications, follows it.POST /v1/applications: your partnerReference, the customer,
the property and the quote. A 201 returns the Application and a handoffUrl.handoffUrl. It works once and expires 30 minutes after it is issued.
For another, call issueHandoff (or repeat the create): each call issues a new one.getApplication at any time.All three are whole numbers; nothing in this API is a decimal.
{ "amount", "currency" }, amount in pence: 2345 = £23.45, from 0 to
1000000000 (£10,000,000.00). currency is GBP, the only one accepted for now.100 = one item, 150 = 1.5 (an hour and a half), from
1 to 100000. Leave it out for one item.1000 = 10.00%, from
1 to 10000.How each line is priced, which is how your figures are checked:
amount must be within 1p of
gross × percentage ÷ 10000.vat must be within 1p of net × the rate for 5% and 20%, and exactly 0 for
exempt and 0%. A line outside these rules is refused with 422 invalid_line_item.The total including VAT is the sum of every line's net and vat. expectedTotal must equal it
exactly, or the quote is refused with 422 total_mismatch and totals carries ours. That total
must be above zero (total_out_of_range) and inside the loan amounts of a finance product offered
to you now (amount_out_of_bounds).
Worked example (Package plus add-ons under createApplication): the package is 100 × 149900 ÷ 100
= 149900 gross, less 10% (1000) = 14990, so net 134910 and VAT 26982; 1.5 hours (150) at 6000 is
9000 net and 1800 VAT; the search fee, quantity left out, is 2500 net and exempt. The total is
146410 + 28782 = 175192 pence, £1,751.92: the expectedTotal sent.
Each delivery carries X-Signature: t=<unix seconds>,v1=<hex>. To verify it:
whsec_
included, as UTF-8), over t, a full stop, then the raw body.v1 in constant time. Accept the delivery only if one
matches and t is within 5 minutes of your clock, either side.For 24 hours after you rotate the secret, the header carries two v1 entries, one per secret.
Worked example. With the example secret whsec_example_do_not_use and this raw body:
{"eventId":"0b6f3c1e-7d2a-4f58-9c41-3e8a5d2b7f90","type":"application.status_changed","occurredAt":"2026-10-12T09:41:07.000Z","applicationId":"5f0c7d2e-8a41-4c3b-9e6f-2d1a7b9c4e10","partnerReference":"PB-104231","from":"created","to":"in_progress"}
the signed string is 1791798067. followed by that body, and the header is:
X-Signature: t=1791798067,v1=5cd52388091b18ca49ccffe17dd8d15475426045c29dcf9bd45ea48d1bebef53
so the HMAC's hex is 5cd52388091b18ca49ccffe17dd8d15475426045c29dcf9bd45ea48d1bebef53.
Node:
import { createHmac, timingSafeEqual } from "node:crypto";
// header: X-Signature. rawBody: the request body's bytes, before any JSON parsing (a Buffer).
export function verifySignature(header, rawBody, secret, nowSeconds = Math.floor(Date.now() / 1000)) {
const parts = header.split(",").map((p) => p.split("="));
const t = parts.find(([k]) => k === "t")?.[1];
if (!t || !/^\d+$/.test(t) || Math.abs(nowSeconds - Number(t)) > 300) return false;
const expected = createHmac("sha256", Buffer.from(secret, "utf8"))
.update(Buffer.concat([Buffer.from(`${t}.`, "utf8"), rawBody]))
.digest();
return parts
.filter(([k, v]) => k === "v1" && /^[0-9a-f]{64}$/.test(v ?? ""))
.some(([, v]) => timingSafeEqual(Buffer.from(v, "hex"), expected));
}
Answer 2XX within 10 seconds. Every retry and replay of an event has the
same eventId: use it to ignore a delivery you have already handled.
createApplication is idempotent on partnerReference. If a create times out, send the same
request again. For a partnerReference you have already used:
200 with the Application and a fresh handoffUrl,
creating nothing. A changed title, name, mobile or property updates what we hold.409 email_conflict.applications:amend as well (otherwise
403 insufficient_scope, nothing changed), and the application must be created or in progress
(otherwise 409 invalid_state).409 invalid_state.amendApplication replaces the whole quote. It answers 201 while the application is created
or in progress. From submitted or approved an amend will move the application back to in progress;
that is not yet available and answers 501. From any other status it is refused with
409 invalid_state.
fulfilApplication takes an Idempotency-Key; its rules are on the operation. It answers
501 until it is available.
Webhooks are retried with growing gaps for 24 hours, then marked failed. Read the application
with getApplication to catch up; Ideal can replay any event.
At the end of the journey we can send the customer back to your site, to one of two addresses a partner admin sets in the partner admin. There is no API endpoint for them.
No other ending sends the customer back, and either address may be left empty, in which case we
do not offer the way back for that ending. Each must be an https address on the standard port
(443): no other port, and no name or password before the host.
The placeholder. Put {{ref}} where your partnerReference goes, at most once, in the path
or the query, never in the host. We replace it with the reference, URL-encoded, so a reference
such as PB 1/2&3 arrives as PB%201%2F2%263:
https://www.example.com/finance/done?ref={{ref}}
https://www.example.com/finance/done?ref=PB-12345
Nothing else is added to the address, and nothing about the customer is put in it. A reference
of . or .. would change the address's path, so for one of those we do not offer the way back.
Arriving is not proof. Anyone can open either address, with any reference. Confirm the
outcome from the webhook, or with getApplication, before acting on it.
Every error is application/problem+json:
{ "type": "about:blank", "title": "Conflict", "status": 409, "code": "email_conflict",
"detail": "This partnerReference belongs to a different customer." }
Branch on status and code. title and detail are written for people and may change;
detail is sometimes absent.
| Status | code | When |
|---|---|---|
| 400 | bad_request |
The request does not match the contract. detail usually lists each problem in the first part of the request that fails (query, path, headers, then body) as <where>.<field>: <message>, separated by ; , where <where> is json, query, param or header. A body that is not JSON, or not sent as application/json, and a value we refuse after those checks, get a sentence instead. |
| 401 | unauthorized |
The key is missing, wrong or revoked, or used from outside its IP ranges. |
| 403 | insufficient_scope |
The key is valid but lacks a scope the operation needs (detail names it), or a create replay changes the quote and the key lacks applications:amend. Nothing was written. |
| 404 | not_found |
No application of yours has that id or partnerReference. |
| 409 | email_conflict, invalid_state, idempotency_key_conflict |
See Idempotency and retries and each operation. |
| 422 | currency_mismatch, invalid_line_item, total_out_of_range, total_mismatch, product_unavailable, amount_out_of_bounds |
The quote is refused: see Money and quantities. invalid_line_item's detail names the item (quote.items[0]) and the rule. |
| 429 | rate_limited |
Too many requests in a short time. Nothing was done. |
| 501 | not_implemented |
The operation is not yet available. |
Each operation lists its own responses, with an example of each.
Rate limits. Each API key, and each calling address, may send only so many requests in a short
time; past that, a request is refused with 429 rate_limited before it does anything, so it is
always safe to retry. The Retry-After header says how many whole seconds to wait. Wait at least
that long, then retry, backing off further if you are refused again. The limits are well above
normal use and may change, so do not build to a number.
Every response, an error or not, carries an X-Request-Id header. Quote it when you ask us about
a request. You may send your own X-Request-Id as a UUID and we use it; any other value is
replaced with one of ours.
| partnerReference required | string [ 1 .. 128 ] characters |
required | object (Customer) |
required | object (PropertyRequest) |
required | object (QuoteRequest) |
{- "partnerReference": "PB-104231",
- "customer": {
- "title": "ms",
- "firstName": "Jane",
- "lastName": "Seller",
- "email": "jane.seller@example.com",
- "mobile": "+447700900123"
}, - "property": {
- "flat": "Flat 2",
- "buildingName": "Mill House",
- "buildingNumber": "22",
- "line1": "Mill Lane",
- "town": "York",
- "postcode": "YO1 7HH",
- "country": "GB"
}, - "quote": {
- "items": [
- {
- "title": "Standard listing",
- "description": "Includes:\n- Listing on the major property portals\n- For-sale board",
- "quantity": 100,
- "unitPrice": {
- "amount": 99900,
- "currency": "GBP"
}, - "vatRate": "20%",
- "vat": {
- "amount": 19980,
- "currency": "GBP"
}
}
], - "expectedTotal": {
- "amount": 119880,
- "currency": "GBP"
}
}
}{- "id": "5f0c7d2e-8a41-4c3b-9e6f-2d1a7b9c4e10",
- "partnerReference": "PB-104231",
- "status": "created",
- "customer": {
- "title": "ms",
- "firstName": "Jane",
- "lastName": "Seller",
- "email": "jane.seller@example.com",
- "mobile": "+447700900123"
}, - "property": {
- "flat": "Flat 2",
- "buildingName": "Mill House",
- "buildingNumber": "22",
- "line1": "Mill Lane",
- "town": "York",
- "postcode": "YO1 7HH",
- "country": "GB",
- "line2": null,
- "line3": null,
- "county": null,
- "uprn": null
}, - "quote": {
- "items": [
- {
- "description": "Includes:\n- Listing on the major property portals\n- For-sale board",
- "quantity": 100,
- "discount": null,
- "title": "Standard listing",
- "unitPrice": {
- "amount": 99900,
- "currency": "GBP"
}, - "vatRate": "20%",
- "vat": {
- "amount": 19980,
- "currency": "GBP"
}, - "net": {
- "amount": 99900,
- "currency": "GBP"
}
}
], - "total": {
- "amountVatExclusive": {
- "amount": 99900,
- "currency": "GBP"
}, - "amountVat": {
- "amount": 19980,
- "currency": "GBP"
}, - "amountVatInclusive": {
- "amount": 119880,
- "currency": "GBP"
}
}, - "createdAt": "2026-10-12T09:30:00.000Z"
}, - "timestamps": {
- "createdAt": "2026-10-12T09:30:00.000Z",
- "updatedAt": "2026-10-12T09:30:00.000Z",
- "statusChangedAt": "2026-10-12T09:30:00.000Z",
- "fulfilledAt": null,
- "paidOutAt": null
},
}| partnerReference required | string [ 1 .. 128 ] characters |
curl -X GET 'https://api.example.invalid/partner/v1/applications?partnerReference=PB-104231' \ -H "Authorization: Bearer $PARTNER_API_KEY"
{- "id": "5f0c7d2e-8a41-4c3b-9e6f-2d1a7b9c4e10",
- "partnerReference": "PB-104231",
- "status": "created",
- "customer": {
- "title": "ms",
- "firstName": "Jane",
- "lastName": "Seller",
- "email": "jane.seller@example.com",
- "mobile": "+447700900123"
}, - "property": {
- "flat": "Flat 2",
- "buildingName": "Mill House",
- "buildingNumber": "22",
- "line1": "Mill Lane",
- "town": "York",
- "postcode": "YO1 7HH",
- "country": "GB",
- "line2": null,
- "line3": null,
- "county": null,
- "uprn": null
}, - "quote": {
- "items": [
- {
- "description": "Includes:\n- Listing on the major property portals\n- For-sale board",
- "quantity": 100,
- "discount": null,
- "title": "Standard listing",
- "unitPrice": {
- "amount": 99900,
- "currency": "GBP"
}, - "vatRate": "20%",
- "vat": {
- "amount": 19980,
- "currency": "GBP"
}, - "net": {
- "amount": 99900,
- "currency": "GBP"
}
}
], - "total": {
- "amountVatExclusive": {
- "amount": 99900,
- "currency": "GBP"
}, - "amountVat": {
- "amount": 19980,
- "currency": "GBP"
}, - "amountVatInclusive": {
- "amount": 119880,
- "currency": "GBP"
}
}, - "createdAt": "2026-10-12T09:30:00.000Z"
}, - "timestamps": {
- "createdAt": "2026-10-12T09:30:00.000Z",
- "updatedAt": "2026-10-12T09:30:00.000Z",
- "statusChangedAt": "2026-10-12T09:30:00.000Z",
- "fulfilledAt": null,
- "paidOutAt": null
}
}| applicationId required | string <uuid> |
curl -X GET 'https://api.example.invalid/partner/v1/applications/5f0c7d2e-8a41-4c3b-9e6f-2d1a7b9c4e10' \ -H "Authorization: Bearer $PARTNER_API_KEY"
{- "id": "5f0c7d2e-8a41-4c3b-9e6f-2d1a7b9c4e10",
- "partnerReference": "PB-104231",
- "status": "created",
- "customer": {
- "title": "ms",
- "firstName": "Jane",
- "lastName": "Seller",
- "email": "jane.seller@example.com",
- "mobile": "+447700900123"
}, - "property": {
- "flat": "Flat 2",
- "buildingName": "Mill House",
- "buildingNumber": "22",
- "line1": "Mill Lane",
- "town": "York",
- "postcode": "YO1 7HH",
- "country": "GB",
- "line2": null,
- "line3": null,
- "county": null,
- "uprn": null
}, - "quote": {
- "items": [
- {
- "description": "Includes:\n- Listing on the major property portals\n- For-sale board",
- "quantity": 100,
- "discount": null,
- "title": "Standard listing",
- "unitPrice": {
- "amount": 99900,
- "currency": "GBP"
}, - "vatRate": "20%",
- "vat": {
- "amount": 19980,
- "currency": "GBP"
}, - "net": {
- "amount": 99900,
- "currency": "GBP"
}
}
], - "total": {
- "amountVatExclusive": {
- "amount": 99900,
- "currency": "GBP"
}, - "amountVat": {
- "amount": 19980,
- "currency": "GBP"
}, - "amountVatInclusive": {
- "amount": 119880,
- "currency": "GBP"
}
}, - "createdAt": "2026-10-12T09:30:00.000Z"
}, - "timestamps": {
- "createdAt": "2026-10-12T09:30:00.000Z",
- "updatedAt": "2026-10-12T09:30:00.000Z",
- "statusChangedAt": "2026-10-12T09:30:00.000Z",
- "fulfilledAt": null,
- "paidOutAt": null
}
}The quote sent replaces the Quote in force, whole. Allowed before signing: created, in progress, submitted or approved. From submitted or approved, an amend moves the application back to in_progress and withdraws any submission the lender holds, so the customer is asked again; that is not yet available and answers 501. From any other status, an amend is refused.
| applicationId required | string <uuid> |
required | object (QuoteRequest) |
{- "quote": {
- "items": [
- {
- "title": "Standard listing",
- "description": "Includes:\n- Listing on the major property portals\n- For-sale board",
- "quantity": 100,
- "unitPrice": {
- "amount": 99900,
- "currency": "GBP"
}, - "vatRate": "20%",
- "vat": {
- "amount": 19980,
- "currency": "GBP"
}
}, - {
- "title": "Professional photography",
- "quantity": 100,
- "unitPrice": {
- "amount": 15000,
- "currency": "GBP"
}, - "vatRate": "20%",
- "vat": {
- "amount": 3000,
- "currency": "GBP"
}
}
], - "expectedTotal": {
- "amount": 137880,
- "currency": "GBP"
}
}
}{- "id": "5f0c7d2e-8a41-4c3b-9e6f-2d1a7b9c4e10",
- "partnerReference": "PB-104231",
- "status": "created",
- "customer": {
- "title": "ms",
- "firstName": "Jane",
- "lastName": "Seller",
- "email": "jane.seller@example.com",
- "mobile": "+447700900123"
}, - "property": {
- "flat": "Flat 2",
- "buildingName": "Mill House",
- "buildingNumber": "22",
- "line1": "Mill Lane",
- "town": "York",
- "postcode": "YO1 7HH",
- "country": "GB",
- "line2": null,
- "line3": null,
- "county": null,
- "uprn": null
}, - "quote": {
- "items": [
- {
- "description": "Includes:\n- Listing on the major property portals\n- For-sale board",
- "quantity": 100,
- "discount": null,
- "title": "Standard listing",
- "unitPrice": {
- "amount": 99900,
- "currency": "GBP"
}, - "vatRate": "20%",
- "vat": {
- "amount": 19980,
- "currency": "GBP"
}, - "net": {
- "amount": 99900,
- "currency": "GBP"
}
}, - {
- "description": null,
- "quantity": 100,
- "discount": null,
- "title": "Professional photography",
- "unitPrice": {
- "amount": 15000,
- "currency": "GBP"
}, - "vatRate": "20%",
- "vat": {
- "amount": 3000,
- "currency": "GBP"
}, - "net": {
- "amount": 15000,
- "currency": "GBP"
}
}
], - "total": {
- "amountVatExclusive": {
- "amount": 114900,
- "currency": "GBP"
}, - "amountVat": {
- "amount": 22980,
- "currency": "GBP"
}, - "amountVatInclusive": {
- "amount": 137880,
- "currency": "GBP"
}
}, - "createdAt": "2026-10-12T10:02:44.000Z"
}, - "timestamps": {
- "createdAt": "2026-10-12T09:30:00.000Z",
- "updatedAt": "2026-10-12T10:02:44.000Z",
- "statusChangedAt": "2026-10-12T09:30:00.000Z",
- "fulfilledAt": null,
- "paidOutAt": null
}
}Each call issues a new single-use URL; earlier ones stay valid until they expire or are used. Refused once the application has ended: declined, withdrawn, cancelled, expired or paid out.
| applicationId required | string <uuid> |
curl -X POST 'https://api.example.invalid/partner/v1/applications/5f0c7d2e-8a41-4c3b-9e6f-2d1a7b9c4e10/handoff' \ -H "Authorization: Bearer $PARTNER_API_KEY"
{- "expiresAt": "2026-10-12T10:00:00.000Z"
}Tells Ideal the customer has received what they financed, which is what allows the finance to be paid out. Calling this is the only way to start fulfilment: nothing fulfils an application automatically. The request is accepted with 202 and the application stays signed until the lender acknowledges it; it then moves to fulfilled, and your webhook receives application.status_changed from signed to fulfilled. Only an accepted request (202) binds its Idempotency-Key: a retry with the same key then always answers that original 202, whatever the application's state is now. A refused request (404 or 409) stores nothing, so the same key can be used again once the application is ready, and that call gets a fresh answer. While a request is pending, another call with a new key answers 202 with the same state and starts nothing new. Not yet available: this operation currently answers 501.
| applicationId required | string <uuid> |
| Idempotency-Key required | string [ 1 .. 255 ] characters A key you choose for this fulfilment request. Only an accepted request (202) binds the key: a retry with the same key then answers that original 202 again, whatever the application's state is now, and starts nothing new. A refused request (404 or 409) stores nothing, so the same key can be used again once the application is ready and gets a fresh answer. A key bound by a 202 for a different application of yours is refused with 409 idempotency_key_conflict. |
curl -X POST 'https://api.example.invalid/partner/v1/applications/5f0c7d2e-8a41-4c3b-9e6f-2d1a7b9c4e10/fulfil' \ -H "Authorization: Bearer $PARTNER_API_KEY" \ -H 'Idempotency-Key: PB-104231-fulfil-1'
{- "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
- "partnerReference": "string",
- "status": "created",
- "customer": {
- "title": "mr",
- "firstName": "string",
- "lastName": "string",
- "email": "user@example.com",
- "mobile": "+447700900123"
}, - "property": {
- "flat": "string",
- "buildingName": "string",
- "buildingNumber": "string",
- "line1": "string",
- "line2": "string",
- "line3": "string",
- "town": "string",
- "county": "string",
- "postcode": "string",
- "country": "string",
- "uprn": "string"
}, - "quote": {
- "items": [
- {
- "title": "string",
- "description": "string",
- "quantity": 100,
- "unitPrice": {
- "amount": 2345,
- "currency": "string"
}, - "discount": {
- "type": "amount",
- "amount": {
- "amount": 2345,
- "currency": "string"
}
}, - "vatRate": "string",
- "vat": {
- "amount": 2345,
- "currency": "string"
}, - "net": {
- "amount": 2345,
- "currency": "string"
}
}
], - "total": {
- "amountVatExclusive": {
- "amount": 2345,
- "currency": "string"
}, - "amountVat": {
- "amount": 2345,
- "currency": "string"
}, - "amountVatInclusive": {
- "amount": 2345,
- "currency": "string"
}
}, - "createdAt": "2019-08-24T14:15:22Z"
}, - "timestamps": {
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z",
- "statusChangedAt": "2019-08-24T14:15:22Z",
- "fulfilledAt": "2019-08-24T14:15:22Z",
- "paidOutAt": "2019-08-24T14:15:22Z"
}
}Ideal POSTs each event to the https webhook URL your partner_admin registers in the partner admin. Each delivery is signed: X-Signature is t=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256 keyed with your webhook secret (the whole string shown, whsec_ included, as UTF-8 bytes) over the string <t>.<raw body>, in lowercase hex. To verify: take the raw request bytes before parsing, compute the HMAC over t, a full stop and those bytes, compare it with each v1 in constant time, and accept the delivery only if one matches and t is at most 5 minutes old. The webhook secret is your partner's own, shown once to a partner_admin in the partner admin, and can be rotated there. For 24 hours after a rotation the header carries two v1 entries, one per secret, so you can switch secrets without missing a delivery. A delivery that does not answer 2XX within 10 seconds is retried with growing gaps for 24 hours, then marked failed and Ideal's operations team is alerted; read the application with getApplication to catch up, and Ideal can replay any event. Redirects are not followed. Events for one application arrive in the order they happened, each after the one before it has been delivered or has failed; events for different applications arrive independently. Every retry and replay carries the same eventId.
| X-Signature required | string Examples:
t= |
| eventId required | string <uuid> This event's own id. It is the same on every retry and replay of the event: use it to de-duplicate. |
| type required | string Value: "application.status_changed" |
| occurredAt required | string <date-time> |
| applicationId required | string <uuid> Ideal's Application id, the id createApplication returned. Never a lender's reference. |
| partnerReference required | string |
| from required | string (Status) Enum: "created" "in_progress" "submitted" "approved" "declined" "referred" "signed" "fulfilled" "paid_out" "withdrawn" "cancelled" "expired" referred: the lender is looking more closely before it decides; approved, declined or expired follows. A customer who leaves the lender's site before finishing returns the application from submitted, referred or approved to in_progress, to submit again; only the customer, the partner or Ideal cancels it. |
| to required | string (Status) Enum: "created" "in_progress" "submitted" "approved" "declined" "referred" "signed" "fulfilled" "paid_out" "withdrawn" "cancelled" "expired" referred: the lender is looking more closely before it decides; approved, declined or expired follows. A customer who leaves the lender's site before finishing returns the application from submitted, referred or approved to in_progress, to submit again; only the customer, the partner or Ideal cancels it. |
{- "eventId": "0b6f3c1e-7d2a-4f58-9c41-3e8a5d2b7f90",
- "type": "application.status_changed",
- "occurredAt": "2026-10-12T09:41:07.000Z",
- "applicationId": "5f0c7d2e-8a41-4c3b-9e6f-2d1a7b9c4e10",
- "partnerReference": "PB-104231",
- "from": "created",
- "to": "in_progress"
}