Partner API (1.0.0)

Download OpenAPI specification:

License: LicenseRef-Proprietary

Getting started

  1. Get an API key. A partner admin creates keys in the partner admin, choosing each key's scopes (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.
  2. Create the application with POST /v1/applications: your partnerReference, the customer, the property and the quote. A 201 returns the Application and a handoffUrl.
  3. Send the customer to 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.
  4. Receive webhooks. A partner admin registers an https URL on port 443, the default (no other port is accepted), and creates the signing secret in the partner admin. Each status change and each amend is POSTed there, signed: see Verifying webhook signatures. Read the full Application with getApplication at any time.

Money and quantities

All three are whole numbers; nothing in this API is a decimal.

  • Money is { "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.
  • quantity is in hundredths: 100 = one item, 150 = 1.5 (an hour and a half), from 1 to 100000. Leave it out for one item.
  • percentage (a percentage discount) is in hundredths of a percent: 1000 = 10.00%, from 1 to 10000.

How each line is priced, which is how your figures are checked:

  1. gross = quantity × unitPrice ÷ 100, rounded half away from zero to a whole penny.
  2. A discount is at most gross. For a percentage discount, its amount must be within 1p of gross × percentage ÷ 10000.
  3. net = gross − the discount's amount.
  4. 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.

Verifying webhook signatures

Each delivery carries X-Signature: t=<unix seconds>,v1=<hex>. To verify it:

  1. Take the request body's raw bytes, before parsing the JSON (re-serialising changes them).
  2. Compute HMAC-SHA256, keyed with your webhook secret (the whole string shown, whsec_ included, as UTF-8), over t, a full stop, then the raw body.
  3. Compare its lowercase hex with each 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.

Idempotency and retries

createApplication is idempotent on partnerReference. If a create times out, send the same request again. For a partnerReference you have already used:

  • with the same customer email, it answers 200 with the Application and a fresh handoffUrl, creating nothing. A changed title, name, mobile or property updates what we hold.
  • with a different customer email, it is refused with 409 email_conflict.
  • with a changed quote, it is an amend: the key needs applications:amend as well (otherwise 403 insufficient_scope, nothing changed), and the application must be created or in progress (otherwise 409 invalid_state).
  • once the application has ended (declined, withdrawn, cancelled, expired or paid out), it is refused with 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.

Return addresses

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.

  • Success address: where we send a customer after they sign their credit agreement.
  • Failure address: where we send a customer whose application is declined.

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.

Errors

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.

applications

Applications.

Create an Application, idempotent on partnerReference.

Authorizations:
partnerApiKey
Request Body schema: application/json
required
partnerReference
required
string [ 1 .. 128 ] characters
required
object (Customer)
required
object (PropertyRequest)
required
object (QuoteRequest)

Responses

Request samples

Content type
application/json
Example
{
  • "partnerReference": "PB-104231",
  • "customer": {
    },
  • "property": {
    },
  • "quote": {
    }
}

Response samples

Content type
application/json
{
  • "id": "5f0c7d2e-8a41-4c3b-9e6f-2d1a7b9c4e10",
  • "partnerReference": "PB-104231",
  • "status": "created",
  • "customer": {
    },
  • "property": {
    },
  • "quote": {
    },
  • "timestamps": {
    },
}

Find an Application by the partner reference.

Authorizations:
partnerApiKey
query Parameters
partnerReference
required
string [ 1 .. 128 ] characters

Responses

Request samples

curl -X GET 'https://api.example.invalid/partner/v1/applications?partnerReference=PB-104231' \
  -H "Authorization: Bearer $PARTNER_API_KEY"

Response samples

Content type
application/json
{
  • "id": "5f0c7d2e-8a41-4c3b-9e6f-2d1a7b9c4e10",
  • "partnerReference": "PB-104231",
  • "status": "created",
  • "customer": {
    },
  • "property": {
    },
  • "quote": {
    },
  • "timestamps": {
    }
}

Get an Application.

Authorizations:
partnerApiKey
path Parameters
applicationId
required
string <uuid>

Responses

Request samples

curl -X GET 'https://api.example.invalid/partner/v1/applications/5f0c7d2e-8a41-4c3b-9e6f-2d1a7b9c4e10' \
  -H "Authorization: Bearer $PARTNER_API_KEY"

Response samples

Content type
application/json
{
  • "id": "5f0c7d2e-8a41-4c3b-9e6f-2d1a7b9c4e10",
  • "partnerReference": "PB-104231",
  • "status": "created",
  • "customer": {
    },
  • "property": {
    },
  • "quote": {
    },
  • "timestamps": {
    }
}

Replace the Quote.

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.

Authorizations:
partnerApiKey
path Parameters
applicationId
required
string <uuid>
Request Body schema: application/json
required
required
object (QuoteRequest)

Responses

Request samples

Content type
application/json
{
  • "quote": {
    }
}

Response samples

Content type
application/json
{
  • "id": "5f0c7d2e-8a41-4c3b-9e6f-2d1a7b9c4e10",
  • "partnerReference": "PB-104231",
  • "status": "created",
  • "customer": {
    },
  • "property": {
    },
  • "quote": {
    },
  • "timestamps": {
    }
}

Get a fresh handoff URL for the customer.

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.

Authorizations:
partnerApiKey
path Parameters
applicationId
required
string <uuid>

Responses

Request samples

curl -X POST 'https://api.example.invalid/partner/v1/applications/5f0c7d2e-8a41-4c3b-9e6f-2d1a7b9c4e10/handoff' \
  -H "Authorization: Bearer $PARTNER_API_KEY"

Response samples

Content type
application/json

Request fulfilment.

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.

Authorizations:
partnerApiKey
path Parameters
applicationId
required
string <uuid>
header Parameters
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.

Responses

Request samples

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'

Response samples

Content type
application/json
{
  • "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  • "partnerReference": "string",
  • "status": "created",
  • "customer": {
    },
  • "property": {
    },
  • "quote": {
    },
  • "timestamps": {
    }
}

webhooks

What Ideal sends to your webhook URL.

Sent to the partner's webhook URL. HMAC-signed. Webhook

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.

header Parameters
X-Signature
required
string
Examples:
  • t=1791798067,v1=5cd52388091b18ca49ccffe17dd8d15475426045c29dcf9bd45ea48d1bebef53 - application.status_changed
  • t=1791799364,v1=33281152afbfde5625cdc9ef277c0f1090d479b0888569892d6b483c8d950e60 - application.amended
  • t=1792404900,v1=45cff1c062905d67551d70b1c19d6ccf9283c630a564f460b6dbc7a1efaf2e31 - webhook.test (test only)

t=,v1=<HMAC-SHA256 of "." keyed with your webhook secret, lowercase hex>, with a second v1 for 24 hours after a rotation. The examples are signed with the example secret whsec_example_do_not_use over each example body's compact JSON.

Request Body schema: application/json
required
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.

Responses

Request samples

Content type
application/json
Example
{
  • "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"
}