Skip to content

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"
  }
}
client.credit_conditions_updated
{
  "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.

client.status_changed
{
  "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 2xx within 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. occurredAt is 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

POST /webhooks/endpoints Partner writes
Request
{
  "url": "https://api.partner.example.com/hooks/noja",
  "events": ["invoice.financing_decision", "client.status_changed"],
  "description": "Production"
}
Response · 201
{
  "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

GET /webhooks/endpoints Noja writes

Lists registered endpoints with their status and recent delivery success rate. Secrets are never returned.

DELETE /webhooks/endpoints/{endpointId} Partner writes

Stops delivery immediately. Undelivered events for this endpoint are dropped.

POST /webhooks/endpoints/{endpointId}/test Partner writes

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.

Request
{ "type": "invoice.financing_decision" }