Skip to content
WorkspaceDarkLight
Contents

Get started

Webhooks

Creditcrest tells your own systems when something happens: an application submitted or decided, a contract signed, a loan funded, a repayment collected or missed, hardship asked for, a complaint received. Each notice carries ids and what changed. Your system reads the rest from your own records, where your borrowers’ records always are.

Your endpoints

A notice

A POST of one JSON event, with these headers:

Header
Creditcrest-Signaturet=<unix seconds>,v1=<hex>: the HMAC-SHA256 of <t>.<body>, keyed by your endpoint’s secret. A v1= for each secret while one is being rotated.
Creditcrest-Event-IdThe event’s id. The same on every attempt.
Creditcrest-Event-TypeThe event’s type.
Creditcrest-AttemptWhich attempt this is: 1, then 2, …
Content-Typeapplication/json
User-AgentCreditcrest-Webhooks/1 (+https://app.creditcresttechnologies.com.au/developers/webhooks)
Body
{
  "id": "evt_3f9a1c2e7b4d5a6f8e0c1b2a",
  "object": "event",
  "type": "loan.funded",
  "version": 1,
  "created": "2026-10-19T02:00:00.000Z",
  "livemode": false,
  "lender": "example-lending",
  "data": {
    "loan": "LN-A-20261015-4F7K2Q9C1D",
    "on": "2026-10-19",
    "recordedBy": "provider"
  },
  "record": {
    "stream": "contract:LN-A-20261015-4F7K2Q9C1D",
    "seq": 7,
    "hash": "5b1e9d0c3a7f2e4b6c8d0a1f3e5b7c9d1e3f5a7b9c1d3e5f7a9b1c3d5e7f9a1b"
  }
}
Field
idevt_ and 24 hex characters. The same for the same thing, however many times it is sent: drop one you have handled.
typeWhat happened, from the catalogue below.
versionThe version of that event’s data. A field added is not a new version; one removed or changed is.
createdWhen it happened, as recorded.
livemodetrue for your real records, at your live endpoint; false for sandbox and pre-launch records, at your test endpoint.
lenderYour lender id.
dataIds and what changed. Never a name, a contact, an address, a date of birth, an account number, an amount or anything a person typed.
recordThe event in your own records it was made from: stream, seq and hash. Read the detail there, or through the API. null for a test.

Check the signature

  1. Read the body exactly as it arrived, before any JSON parser touches it.
  2. Split Creditcrest-Signature at its commas: t is the time, each v1 a signature.
  3. Refuse a t more than 5 minutes from your clock: that stops a captured notice being replayed later.
  4. Work out the HMAC-SHA256 of <t>.<body> with your secret, in hex, and compare it with each v1 in constant time. One matching is enough.
  5. Drop an id you have handled: that stops a replay inside the 5 minutes, and absorbs our retries.

A whole receiver, nothing to install. The suite that tests Creditcrest runs both, against the code that sends.

// A Creditcrest webhook receiver: Node 18 or later, nothing to install.
import { createServer } from 'node:http';
import { createHmac, timingSafeEqual } from 'node:crypto';

const SECRET = process.env.CREDITCREST_WEBHOOK_SECRET; // whsec_…, from Creditcrest
const TOLERANCE = 300; // seconds either side of your clock
const seen = new Set(); // ids already handled: keep them in your database

function verify(rawBody, header, secret, now = Date.now()) {
  const parts = String(header || '').split(',').map((p) => p.split('='));
  const t = Number(parts.find(([k]) => k === 't')?.[1]);
  const signatures = parts.filter(([k]) => k === 'v1').map(([, v]) => v);
  if (!Number.isInteger(t) || !signatures.length) return false;
  if (Math.abs(Math.floor(now / 1000) - t) > TOLERANCE) return false; // too old: a replay
  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
  return signatures.some((s) => {
    const got = Buffer.from(s, 'hex');
    return got.length === expected.length && timingSafeEqual(got, expected);
  });
}

createServer((req, res) => {
  const chunks = [];
  req.on('data', (c) => chunks.push(c));
  req.on('end', () => {
    const raw = Buffer.concat(chunks).toString('utf8'); // the body exactly as sent
    if (!verify(raw, req.headers['creditcrest-signature'], SECRET)) return res.writeHead(400).end();
    const event = JSON.parse(raw);
    if (seen.has(event.id)) return res.writeHead(200).end(); // a retry: handled already
    seen.add(event.id);
    res.writeHead(200).end(); // answer at once, then do the work
    console.log(event.type, JSON.stringify(event.data)); // read the rest from event.record
  });
}).listen(Number(process.env.PORT || 3000), () => console.log('listening'));
# A Creditcrest webhook receiver: Python 3.9 or later, the standard library only.
import hashlib, hmac, json, os, time
from http.server import BaseHTTPRequestHandler, HTTPServer

SECRET = os.environ["CREDITCREST_WEBHOOK_SECRET"]  # whsec_..., from Creditcrest
TOLERANCE = 300  # seconds either side of your clock
seen = set()  # ids already handled: keep them in your database


def verify(raw_body, header, secret, now=None):
    parts = [p.split("=", 1) for p in (header or "").split(",") if "=" in p]
    t = next((v for k, v in parts if k == "t"), "")
    signatures = [v for k, v in parts if k == "v1"]
    if not t.isdigit() or not signatures:
        return False
    if abs(int(now if now is not None else time.time()) - int(t)) > TOLERANCE:
        return False  # too old: a replay
    expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, s) for s in signatures)


class Receiver(BaseHTTPRequestHandler):
    def do_POST(self):
        raw = self.rfile.read(int(self.headers.get("content-length", 0)))  # the body exactly as sent
        if not verify(raw, self.headers.get("Creditcrest-Signature"), SECRET):
            return self.answer(400)
        event = json.loads(raw)
        if event["id"] in seen:  # a retry: handled already
            return self.answer(200)
        seen.add(event["id"])
        self.answer(200)  # answer at once, then do the work
        print(event["type"], json.dumps(event["data"]), flush=True)  # read the rest from event["record"]

    def answer(self, status):
        self.send_response(status)
        self.end_headers()

    def log_message(self, *args):
        pass


server = HTTPServer(("127.0.0.1", int(os.environ.get("PORT", "3000"))), Receiver)
print("listening", flush=True)
server.serve_forever()

Answer, and retries

Events

The catalogue of 2026-10-03. A notice carries only the fields listed for its event; the rest is in your records.

Type
application.submittedAn application was submitted, read and assessed, and is in the lender’s work queue.
application.decidedA person at the lender concluded on an application, and the conclusion stands: recorded, or confirmed by a second person where the lender requires one.
application.withdrawnAn application will go no further: the applicant carried on with another one, or the lender’s staff closed the file as withdrawn.
contract.signedThe borrower signed the contract.
loan.fundedThe advance settled, and the contract is funded.
repayment.collectedA payment was put on a loan’s account.
repayment.failedA repayment did not arrive: a payment already on the account was reversed (a dishonour), or a PayTo debit failed before it was paid.
hardship.requestedA borrower asked for hardship help. The clock to answer it starts on the day received. Hardship is never default: no event says so.
hardship.arrangedA hardship arrangement started on a loan. While it runs, days past due are held and no fee is charged.
complaint.receivedA complaint was received. Its clock starts on the day received.
daily_run.completedThe daily run Creditcrest runs for the lender finished a day: the day before sealed, what is due today listed and held. Only for a lender whose configuration has Creditcrest run its day.
webhook.testA test, sent from the Webhooks page of the lender’s console. It means nothing else: answer 2xx.

application.submitted v1

An application was submitted, read and assessed, and is in the lender’s work queue.

Made from case:<application> received. The detail: Your records: application:<application> and case:<application>. Your staff: /l/<lender>/operations/<application>.

dataKind
applicationrefThe application’s reference.
customerrefThe customer’s id, the same on every application by the same person; null when the contacts were not proved, so no customer was matched or made.
productrefThe product’s key in your configuration.
channelone of web, apiweb: your hosted application pages; api: your own program, POST /v1/l/<lender>/applications.
Example · application.submitted
{
  "id": "evt_3f9a1c2e7b4d5a6f8e0c1b2a",
  "object": "event",
  "type": "application.submitted",
  "version": 1,
  "created": "2026-10-19T02:00:00.000Z",
  "livemode": false,
  "lender": "example-lending",
  "data": {
    "application": "EX-A-20261015-4F7K2Q9C1D",
    "customer": "C-7QK2M4XR9T",
    "product": "sacc",
    "channel": "web"
  },
  "record": {
    "stream": "case:EX-A-20261015-4F7K2Q9C1D",
    "seq": 7,
    "hash": "5b1e9d0c3a7f2e4b6c8d0a1f3e5b7c9d1e3f5a7b9c1d3e5f7a9b1c3d5e7f9a1b"
  }
}

application.decided v1

A person at the lender concluded on an application, and the conclusion stands: recorded, or confirmed by a second person where the lender requires one.

Made from case:<application> concluded (without a second check), or confirmed. The detail: Your records: case:<application> (the conclusion, its rationale and its sealed record).

dataKind
applicationrefThe application’s reference.
outcomeone of not_unsuitable, unsuitable, more_information, withdrawn, not_proceedingThe conclusion a person recorded: not_unsuitable, unsuitable, more_information, withdrawn or not_proceeding. Creditcrest never decides.
reasonCoderefYour own reason code the person chose.
secondCheckboolWhether a second person confirmed it.
loanrefThe loan reference a contract for it will carry, when the outcome is not_unsuitable; otherwise null.
Example · application.decided
{
  "id": "evt_3f9a1c2e7b4d5a6f8e0c1b2a",
  "object": "event",
  "type": "application.decided",
  "version": 1,
  "created": "2026-10-19T02:00:00.000Z",
  "livemode": false,
  "lender": "example-lending",
  "data": {
    "application": "EX-A-20261015-4F7K2Q9C1D",
    "outcome": "not_unsuitable",
    "reasonCode": "NU-01",
    "secondCheck": false,
    "loan": "LN-A-20261015-4F7K2Q9C1D"
  },
  "record": {
    "stream": "requests:LN-A-20261015-4F7K2Q9C1D",
    "seq": 7,
    "hash": "5b1e9d0c3a7f2e4b6c8d0a1f3e5b7c9d1e3f5a7b9c1d3e5f7a9b1c3d5e7f9a1b"
  }
}

application.withdrawn v1

An application will go no further: the applicant carried on with another one, or the lender’s staff closed the file as withdrawn.

Made from application:<application> withdrawn, or case:<application> moved to withdrawn. The detail: Your records: application:<application> or case:<application>.

dataKind
applicationrefThe application’s reference.
byone of applicant, lenderWho withdrew it.
Example · application.withdrawn
{
  "id": "evt_3f9a1c2e7b4d5a6f8e0c1b2a",
  "object": "event",
  "type": "application.withdrawn",
  "version": 1,
  "created": "2026-10-19T02:00:00.000Z",
  "livemode": false,
  "lender": "example-lending",
  "data": {
    "application": "EX-A-20261015-4F7K2Q9C1D",
    "by": "lender"
  },
  "record": {
    "stream": "case:EX-A-20261015-4F7K2Q9C1D",
    "seq": 7,
    "hash": "5b1e9d0c3a7f2e4b6c8d0a1f3e5b7c9d1e3f5a7b9c1d3e5f7a9b1c3d5e7f9a1b"
  }
}

contract.signed v1

The borrower signed the contract.

Made from contract:<loan> signed. The detail: Your records: contract:<loan>, and the contract document <loan> with its signed copy.

dataKind
loanrefThe loan’s reference: LN- and the application reference without its prefix.
ondayThe day it was signed, in Sydney.
documentHashhashThe SHA-256 of the contract as it was signed, to check against the copy you keep.
Example · contract.signed
{
  "id": "evt_3f9a1c2e7b4d5a6f8e0c1b2a",
  "object": "event",
  "type": "contract.signed",
  "version": 1,
  "created": "2026-10-19T02:00:00.000Z",
  "livemode": false,
  "lender": "example-lending",
  "data": {
    "loan": "LN-A-20261015-4F7K2Q9C1D",
    "on": "2026-10-16",
    "documentHash": "9f2c4e1b7a3d5c6e8f0a1b2c3d4e5f60718293a4b5c6d7e8f9a0b1c2d3e4f5a6"
  },
  "record": {
    "stream": "contract:LN-A-20261015-4F7K2Q9C1D",
    "seq": 7,
    "hash": "5b1e9d0c3a7f2e4b6c8d0a1f3e5b7c9d1e3f5a7b9c1d3e5f7a9b1c3d5e7f9a1b"
  }
}

loan.funded v1

The advance settled, and the contract is funded.

Made from contract:<loan> funded. The detail: GET /v1/l/<lender>/loans/<loan>.

dataKind
loanrefThe loan’s reference: LN- and the application reference without its prefix.
ondayThe day the money settled.
recordedByone of provider, staffprovider: the payment provider’s report; staff: a senior analyst recorded a settlement they saw.
Example · loan.funded
{
  "id": "evt_3f9a1c2e7b4d5a6f8e0c1b2a",
  "object": "event",
  "type": "loan.funded",
  "version": 1,
  "created": "2026-10-19T02:00:00.000Z",
  "livemode": false,
  "lender": "example-lending",
  "data": {
    "loan": "LN-A-20261015-4F7K2Q9C1D",
    "on": "2026-10-19",
    "recordedBy": "provider"
  },
  "record": {
    "stream": "contract:LN-A-20261015-4F7K2Q9C1D",
    "seq": 7,
    "hash": "5b1e9d0c3a7f2e4b6c8d0a1f3e5b7c9d1e3f5a7b9c1d3e5f7a9b1c3d5e7f9a1b"
  }
}

repayment.collected v1

A payment was put on a loan’s account.

Made from account:<loan> payment. The detail: GET /v1/l/<lender>/loans/<loan> (the ledger and the balance).

dataKind
loanrefThe loan’s reference: LN- and the application reference without its prefix.
paymentrefThe payment’s id on the account.
ondayThe day it was paid.
viaone of payto, request, staff, otherpayto: a PayTo debit the provider reported settled; request: a payment the borrower asked for, settled; staff: recorded on the loan’s page; other: anything else.
Example · repayment.collected
{
  "id": "evt_3f9a1c2e7b4d5a6f8e0c1b2a",
  "object": "event",
  "type": "repayment.collected",
  "version": 1,
  "created": "2026-10-19T02:00:00.000Z",
  "livemode": false,
  "lender": "example-lending",
  "data": {
    "loan": "LN-A-20261015-4F7K2Q9C1D",
    "payment": "payto-example-lending.7Q2M4XR9TK3B8W1Z",
    "on": "2026-10-29",
    "via": "payto"
  },
  "record": {
    "stream": "account:LN-A-20261015-4F7K2Q9C1D",
    "seq": 7,
    "hash": "5b1e9d0c3a7f2e4b6c8d0a1f3e5b7c9d1e3f5a7b9c1d3e5f7a9b1c3d5e7f9a1b"
  }
}

repayment.failed v1

A repayment did not arrive: a payment already on the account was reversed (a dishonour), or a PayTo debit failed before it was paid.

Made from account:<loan> reversal, or payments:<loan> payment outcome (a debit, failed). The detail: GET /v1/l/<lender>/loans/<loan>; your records: account:<loan> and payments:<loan>.

dataKind
loanrefThe loan’s reference: LN- and the application reference without its prefix.
ondayThe day it was reported.
failureone of reversed, debit_failedreversed: a payment on the account came back; debit_failed: a debit was refused and never paid.
paymentrefThe payment reversed, when one was on the account; otherwise null.
instructionrefThe debit instruction, for a PayTo debit; otherwise null.
Example · repayment.failed
{
  "id": "evt_3f9a1c2e7b4d5a6f8e0c1b2a",
  "object": "event",
  "type": "repayment.failed",
  "version": 1,
  "created": "2026-10-19T02:00:00.000Z",
  "livemode": false,
  "lender": "example-lending",
  "data": {
    "loan": "LN-A-20261015-4F7K2Q9C1D",
    "on": "2026-11-12",
    "failure": "reversed",
    "payment": "lm-payment-4",
    "instruction": null
  },
  "record": {
    "stream": "account:LN-A-20261015-4F7K2Q9C1D",
    "seq": 7,
    "hash": "5b1e9d0c3a7f2e4b6c8d0a1f3e5b7c9d1e3f5a7b9c1d3e5f7a9b1c3d5e7f9a1b"
  }
}

hardship.requested v1

A borrower asked for hardship help. The clock to answer it starts on the day received. Hardship is never default: no event says so.

Made from requests:<loan> hardship notice, or support:<notice> hardship notice. The detail: GET /v1/l/<lender>/requests/<loan>, or GET /v1/l/<lender>/support/<notice> for one given on the support page.

dataKind
noticerefThe notice’s acknowledgement reference (H-…).
loanrefThe loan, when the borrower was signed in or staff recorded it; null for the support page, where the loan is only what somebody typed.
channelone of phone, in_person, online_form, email, letter, sms, otherHow it was given.
receivedOndayThe day it was received.
viaone of account, staff, support_pageWhere it was recorded.
Example · hardship.requested
{
  "id": "evt_3f9a1c2e7b4d5a6f8e0c1b2a",
  "object": "event",
  "type": "hardship.requested",
  "version": 1,
  "created": "2026-10-19T02:00:00.000Z",
  "livemode": false,
  "lender": "example-lending",
  "data": {
    "notice": "H-261020-ABCDEF",
    "loan": "LN-A-20261015-4F7K2Q9C1D",
    "channel": "online_form",
    "receivedOn": "2026-10-20",
    "via": "account"
  },
  "record": {
    "stream": "requests:LN-A-20261015-4F7K2Q9C1D",
    "seq": 7,
    "hash": "5b1e9d0c3a7f2e4b6c8d0a1f3e5b7c9d1e3f5a7b9c1d3e5f7a9b1c3d5e7f9a1b"
  }
}

hardship.arranged v1

A hardship arrangement started on a loan. While it runs, days past due are held and no fee is charged.

Made from account:<loan> hardship-start. The detail: GET /v1/l/<lender>/loans/<loan> (the arrangement and its plan).

dataKind
loanrefThe loan’s reference: LN- and the application reference without its prefix.
arrangementrefThe arrangement’s id on the account.
noticerefThe notice it answers, when it names one.
fromdayThe day it starts.
untildayThe day it ends, when it has one.
Example · hardship.arranged
{
  "id": "evt_3f9a1c2e7b4d5a6f8e0c1b2a",
  "object": "event",
  "type": "hardship.arranged",
  "version": 1,
  "created": "2026-10-19T02:00:00.000Z",
  "livemode": false,
  "lender": "example-lending",
  "data": {
    "loan": "LN-A-20261015-4F7K2Q9C1D",
    "arrangement": "lm-arrangement-7",
    "notice": "H-261020-ABCDEF",
    "from": "2026-10-22",
    "until": "2027-01-31"
  },
  "record": {
    "stream": "account:LN-A-20261015-4F7K2Q9C1D",
    "seq": 7,
    "hash": "5b1e9d0c3a7f2e4b6c8d0a1f3e5b7c9d1e3f5a7b9c1d3e5f7a9b1c3d5e7f9a1b"
  }
}

complaint.received v1

A complaint was received. Its clock starts on the day received.

Made from requests:<loan> complaint received, or support:<complaint> complaint received. The detail: GET /v1/l/<lender>/requests/<loan>, or GET /v1/l/<lender>/support/<complaint>.

dataKind
complaintrefThe complaint’s acknowledgement reference (C-…).
loanrefThe loan, when the borrower was signed in; otherwise null.
kindone of standard, hardshiphardship: about hardship help, which has a shorter time to answer.
categorycodeWhat the person said it is about: fees, payments, hardship, service, privacy, other, or uncategorised (the account’s and the support page’s own list).
channelone of phone, in_person, online_form, email, letter, sms, otherHow it was given.
receivedOndayThe day it was received.
viaone of account, support_pageWhere it was recorded.
Example · complaint.received
{
  "id": "evt_3f9a1c2e7b4d5a6f8e0c1b2a",
  "object": "event",
  "type": "complaint.received",
  "version": 1,
  "created": "2026-10-19T02:00:00.000Z",
  "livemode": false,
  "lender": "example-lending",
  "data": {
    "complaint": "C-261020-ABCDEF",
    "loan": null,
    "kind": "standard",
    "category": "fees",
    "channel": "online_form",
    "receivedOn": "2026-10-20",
    "via": "support_page"
  },
  "record": {
    "stream": "support:C-261020-ABCDEF",
    "seq": 7,
    "hash": "5b1e9d0c3a7f2e4b6c8d0a1f3e5b7c9d1e3f5a7b9c1d3e5f7a9b1c3d5e7f9a1b"
  }
}

daily_run.completed v1

The daily run Creditcrest runs for the lender finished a day: the day before sealed, what is due today listed and held. Only for a lender whose configuration has Creditcrest run its day.

Made from daily-run:<day> daily run. The detail: Your records: daily-run:<day> (every debit due and held, by loan).

dataKind
daydayThe day run, in Sydney.
triggerone of schedule, watchschedule: 6am Sydney; watch: the ten-minute watch, catching a day up.
accountscountAccounts read.
sealedcountAccounts whose day was sealed.
debitsDuecountDebits due today.
debitsHeldcountDebits held, each with its reason in the record.
overduecountDeadlines overdue: hardship, complaints, payout statements.
dueTodaycountDeadlines due today.
messagesDuecountMessages due.
problemscountProblems the run met.
paymentsPausedboolWhether Creditcrest’s pause on money movement was on.
Example · daily_run.completed
{
  "id": "evt_3f9a1c2e7b4d5a6f8e0c1b2a",
  "object": "event",
  "type": "daily_run.completed",
  "version": 1,
  "created": "2026-10-19T02:00:00.000Z",
  "livemode": false,
  "lender": "example-lending",
  "data": {
    "day": "2026-10-20",
    "trigger": "schedule",
    "accounts": 42,
    "sealed": 42,
    "debitsDue": 3,
    "debitsHeld": 1,
    "overdue": 0,
    "dueToday": 2,
    "messagesDue": 5,
    "problems": 0,
    "paymentsPaused": false
  },
  "record": {
    "stream": "daily-run:2026-10-20",
    "seq": 7,
    "hash": "5b1e9d0c3a7f2e4b6c8d0a1f3e5b7c9d1e3f5a7b9c1d3e5f7a9b1c3d5e7f9a1b"
  }
}

webhook.test v1

A test, sent from the Webhooks page of the lender’s console. It means nothing else: answer 2xx.

Made from the console’s “Send a test event”. The detail: Nothing to read.

dataKind
requestedByone of adminWho sent it: an operations admin.
Example · webhook.test
{
  "id": "evt_3f9a1c2e7b4d5a6f8e0c1b2a",
  "object": "event",
  "type": "webhook.test",
  "version": 1,
  "created": "2026-10-19T02:00:00.000Z",
  "livemode": false,
  "lender": "example-lending",
  "data": {
    "requestedBy": "admin"
  },
  "record": null
}

What a notice never carries

A name, an email address or phone number, an address, a date of birth, an account number, an amount, a reason someone gave, or any other words a person typed. Hardship is never described as default: hardship has its own events, and no event says a loan is in default. The notices Creditcrest keeps to retry and to show on your console are the same few ids, kept 30 days.

Testing

Also as data: OpenAPI 3.1 (webhooks) and llms.txt.