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
- You give Creditcrest two https addresses of your own: a test endpoint and a live one. Creditcrest puts them in your configuration at onboarding, with the event types you want (every one, unless you say).
- Sandbox and pre-launch records — invented people — are told to the test endpoint only, with
livemode: false. Real records are told to the live endpoint only, and only once Creditcrest has switched webhooks on for you in production. The two never cross, and the test endpoint may not be the live one. - Each endpoint has its own signing secret, beginning
whsec_, given to you once. Your staff console’s Webhooks page shows a fingerprint of it — the first 12 hex characters of its SHA-256 — so you can check you hold the right one, never the secret itself.
A notice
A POST of one JSON event, with these headers:
| Header | |
|---|---|
Creditcrest-Signature | t=<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-Id | The event’s id. The same on every attempt. |
Creditcrest-Event-Type | The event’s type. |
Creditcrest-Attempt | Which attempt this is: 1, then 2, … |
Content-Type | application/json |
User-Agent | Creditcrest-Webhooks/1 (+https://app.creditcresttechnologies.com.au/developers/webhooks) |
{
"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 | |
|---|---|
id | evt_ and 24 hex characters. The same for the same thing, however many times it is sent: drop one you have handled. |
type | What happened, from the catalogue below. |
version | The version of that event’s data. A field added is not a new version; one removed or changed is. |
created | When it happened, as recorded. |
livemode | true for your real records, at your live endpoint; false for sandbox and pre-launch records, at your test endpoint. |
lender | Your lender id. |
data | Ids and what changed. Never a name, a contact, an address, a date of birth, an account number, an amount or anything a person typed. |
record | The 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
- Read the body exactly as it arrived, before any JSON parser touches it.
- Split
Creditcrest-Signatureat its commas:tis the time, eachv1a signature. - Refuse a
tmore than 5 minutes from your clock: that stops a captured notice being replayed later. - Work out the HMAC-SHA256 of
<t>.<body>with your secret, in hex, and compare it with eachv1in constant time. One matching is enough. - Drop an
idyou 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
- Answer any
2xxwithin 10 seconds, then do the work. Anything else — a redirect included — is a failed attempt. A notice is tried at once, then after 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, then every 10 to 12 hours, for 72 hours; after that it shows as failed on your console, where it can be sent again. - At least once: the same event can arrive more than once. Keep the ids you have handled.
- No order: one failing notice never holds up another, so they can arrive out of order. Within one record,
record.seqorders them; across records,created. - If Creditcrest pauses notices during an incident, they are kept and sent when it ends, within their 72 hours.
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.submitted | An application was submitted, read and assessed, and is in the lender’s work queue. |
application.decided | 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. |
application.withdrawn | An application will go no further: the applicant carried on with another one, or the lender’s staff closed the file as withdrawn. |
contract.signed | The borrower signed the contract. |
loan.funded | The advance settled, and the contract is funded. |
repayment.collected | A payment was put on a loan’s account. |
repayment.failed | A repayment did not arrive: a payment already on the account was reversed (a dishonour), or a PayTo debit failed before it was paid. |
hardship.requested | A borrower asked for hardship help. The clock to answer it starts on the day received. Hardship is never default: no event says so. |
hardship.arranged | A hardship arrangement started on a loan. While it runs, days past due are held and no fee is charged. |
complaint.received | A complaint was received. Its clock starts on the day received. |
daily_run.completed | 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. |
webhook.test | A 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>.
| data | Kind | |
|---|---|---|
application | ref | The application’s reference. |
customer | ref | The 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. |
product | ref | The product’s key in your configuration. |
channel | one of web, api | web: your hosted application pages; api: your own program, POST /v1/l/<lender>/applications. |
{
"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).
| data | Kind | |
|---|---|---|
application | ref | The application’s reference. |
outcome | one of not_unsuitable, unsuitable, more_information, withdrawn, not_proceeding | The conclusion a person recorded: not_unsuitable, unsuitable, more_information, withdrawn or not_proceeding. Creditcrest never decides. |
reasonCode | ref | Your own reason code the person chose. |
secondCheck | bool | Whether a second person confirmed it. |
loan | ref | The loan reference a contract for it will carry, when the outcome is not_unsuitable; otherwise null. |
{
"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>.
| data | Kind | |
|---|---|---|
application | ref | The application’s reference. |
by | one of applicant, lender | Who withdrew it. |
{
"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.
| data | Kind | |
|---|---|---|
loan | ref | The loan’s reference: LN- and the application reference without its prefix. |
on | day | The day it was signed, in Sydney. |
documentHash | hash | The SHA-256 of the contract as it was signed, to check against the copy you keep. |
{
"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>.
| data | Kind | |
|---|---|---|
loan | ref | The loan’s reference: LN- and the application reference without its prefix. |
on | day | The day the money settled. |
recordedBy | one of provider, staff | provider: the payment provider’s report; staff: a senior analyst recorded a settlement they saw. |
{
"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).
| data | Kind | |
|---|---|---|
loan | ref | The loan’s reference: LN- and the application reference without its prefix. |
payment | ref | The payment’s id on the account. |
on | day | The day it was paid. |
via | one of payto, request, staff, other | payto: 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. |
{
"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>.
| data | Kind | |
|---|---|---|
loan | ref | The loan’s reference: LN- and the application reference without its prefix. |
on | day | The day it was reported. |
failure | one of reversed, debit_failed | reversed: a payment on the account came back; debit_failed: a debit was refused and never paid. |
payment | ref | The payment reversed, when one was on the account; otherwise null. |
instruction | ref | The debit instruction, for a PayTo debit; otherwise null. |
{
"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.
| data | Kind | |
|---|---|---|
notice | ref | The notice’s acknowledgement reference (H-…). |
loan | ref | The loan, when the borrower was signed in or staff recorded it; null for the support page, where the loan is only what somebody typed. |
channel | one of phone, in_person, online_form, email, letter, sms, other | How it was given. |
receivedOn | day | The day it was received. |
via | one of account, staff, support_page | Where it was recorded. |
{
"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).
| data | Kind | |
|---|---|---|
loan | ref | The loan’s reference: LN- and the application reference without its prefix. |
arrangement | ref | The arrangement’s id on the account. |
notice | ref | The notice it answers, when it names one. |
from | day | The day it starts. |
until | day | The day it ends, when it has one. |
{
"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>.
| data | Kind | |
|---|---|---|
complaint | ref | The complaint’s acknowledgement reference (C-…). |
loan | ref | The loan, when the borrower was signed in; otherwise null. |
kind | one of standard, hardship | hardship: about hardship help, which has a shorter time to answer. |
category | code | What the person said it is about: fees, payments, hardship, service, privacy, other, or uncategorised (the account’s and the support page’s own list). |
channel | one of phone, in_person, online_form, email, letter, sms, other | How it was given. |
receivedOn | day | The day it was received. |
via | one of account, support_page | Where it was recorded. |
{
"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).
| data | Kind | |
|---|---|---|
day | day | The day run, in Sydney. |
trigger | one of schedule, watch | schedule: 6am Sydney; watch: the ten-minute watch, catching a day up. |
accounts | count | Accounts read. |
sealed | count | Accounts whose day was sealed. |
debitsDue | count | Debits due today. |
debitsHeld | count | Debits held, each with its reason in the record. |
overdue | count | Deadlines overdue: hardship, complaints, payout statements. |
dueToday | count | Deadlines due today. |
messagesDue | count | Messages due. |
problems | count | Problems the run met. |
paymentsPaused | bool | Whether Creditcrest’s pause on money movement was on. |
{
"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.
| data | Kind | |
|---|---|---|
requestedBy | one of admin | Who sent it: an operations admin. |
{
"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
- Your staff console’s Webhooks page (for an operations admin) sends a
webhook.testevent to the endpoint for the records in use, and shows every notice, each attempt, the status your endpoint answered and how long it took. - In the sandbox, work an invented application through and every event is sent to your test endpoint.
Also as data: OpenAPI 3.1 (webhooks) and llms.txt.