Webhooks¶
Everything Noja decides reaches you as an event. Nothing in this API requires polling.
Events¶
| Event | Fires when |
|---|---|
client.status_changed |
A client moves between any two client statuses |
client.onboarding_completed |
Every signatory has signed, in either onboarding mode |
client.credit_conditions_updated |
Limit, pricing, APR, or terms state changes — including the first indicative terms after history lands |
invoice.financing_decision |
An invoice is approved or declined |
invoice.status_changed |
Payment status or financing state changed, including automatic pickup after client approval |
invoice.financed |
Funds paid out to the client |
invoice.settled |
Debtor paid; the advance is closed |
Payload¶
Every event shares the same envelope. Only data differs.
{
"eventId": "evt_0193f34a2c917e55",
"type": "invoice.financing_decision",
"occurredAt": "2026-09-14T10:02:47Z",
"data": {
"invoiceId": "0193f31a-77bd-7c02-bb14-9e0f2a4c8d31",
"invoiceNumber": "2026-0417",
"client": { "countryCode": "NL", "registrationNumber": "68123456" },
"partnerReference": "ptr-user-88213",
"financingState": "financing_approved",
"amountApproved": { "amount": 339400, "currency": "EUR" },
"fee": { "amount": 10182, "currency": "EUR" },
"expectedPayoutAt": "2026-09-14T16:00:00Z"
}
}
{
"eventId": "evt_0193f34b91a0cc12",
"type": "client.credit_conditions_updated",
"occurredAt": "2026-09-14T11:15:03Z",
"data": {
"client": { "countryCode": "NL", "registrationNumber": "68123456" },
"partnerReference": "ptr-user-88213",
"changed": ["state", "totalCreditLimit", "financingRate"],
"state": "approved",
"totalCreditLimit": { "amount": 3000000, "currency": "EUR" },
"availableCredit": { "amount": 2340000, "currency": "EUR" },
"financingRate": "2.2% / 30 days",
"previous": {
"totalCreditLimit": { "amount": 2500000, "currency": "EUR" },
"financingRate": "2.4% / 30 days"
}
}
}
changed tells you which fields moved, and previous carries the prior values — so you can show a user that their limit went up without holding your own copy of the old state.
{
"eventId": "evt_0193f34c0b7e4419",
"type": "client.status_changed",
"occurredAt": "2026-09-19T08:40:12Z",
"data": {
"client": { "countryCode": "NL", "registrationNumber": "68123456" },
"partnerReference": "ptr-user-88213",
"status": "active",
"previousStatus": "pending_approval",
"reason": null
}
}
On suspended or inactive, reason carries a short machine-readable code. It is deliberately coarse — the detailed grounds for a credit refusal are not disclosed through this channel.
status and previousStatus both take values from the client lifecycle and nothing else. An underwriting refusal does not produce a distinct status — the client stays pending_approval, and the decision surfaces as credit-conditions state: declined.
Verifying a delivery¶
Each request carries Noja-Signature and Noja-Timestamp. The signature is HMAC-SHA256(timestamp + "." + rawBody) using your endpoint's signing secret.
import hmac
import hashlib
import time
def verify(raw_body: str, timestamp: str, signature: str, secret: str) -> bool:
# Reject anything older than five minutes — replay protection.
if abs(time.time() - int(timestamp)) > 300:
return False
expected = hmac.new(
secret.encode(),
f"{timestamp}.{raw_body}".encode(),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature)
Verify against the raw body
Compute the signature over the exact bytes received, before any JSON parsing or re-serialisation. Parsing and re-encoding changes key order and whitespace, and the signature will never match.
Use a constant-time comparison. A plain == on the signature leaks timing information.
Delivery guarantees¶
- Respond
2xxwithin 10 seconds. Anything else counts as a failure. - Retries run at 1m, 5m, 30m, 2h, 6h, then hourly for 24 hours.
- Delivery is at-least-once. Deduplicate on
eventId. - Ordering is not guaranteed.
occurredAtis authoritative — if you receive a stale event after a newer one, discard it.
After 24 hours of failure the endpoint is marked failing and we email your technical contact. Events are retained for 7 days and can be replayed from the partner console.
Acknowledge fast, process later
Write the event to your own queue and return 200 immediately. Doing real work inside the webhook handler is what causes timeouts, which causes retries, which causes duplicate processing.
Register an endpoint¶
{
"url": "https://api.partner.example.com/hooks/noja",
"events": ["invoice.financing_decision", "client.status_changed"],
"description": "Production"
}
{
"endpointId": "whe_0193f350c1d24a77",
"url": "https://api.partner.example.com/hooks/noja",
"events": ["invoice.financing_decision", "client.status_changed"],
"signingSecret": "whsec_9f2a41c8b07e...",
"status": "active",
"createdAt": "2026-09-14T09:20:00Z"
}
The signing secret is shown once
signingSecret is returned only at creation. Store it immediately. If it is lost, delete the endpoint and create a new one.
Subscribe to ["*"] to receive every event type, including ones added later.
Manage endpoints¶
Lists registered endpoints with their status and recent delivery success rate. Secrets are never returned.
Stops delivery immediately. Undelivered events for this endpoint are dropped.
Fires a synthetic event of any type at the endpoint, with realistic but fictional data. The payload carries "test": true at the top level. Use it to check signature verification before going live.