Webhooks
Callbacks are the primary delivery mechanism. Everything an assessment produces reaches
you this way, signed, with the GET endpoints as the recovery path.
Delivery model#
Every create returns 202 and the outcome arrives here, on a signed callback.
One response shape, one delivery path, and nothing to branch on.
Event catalogue#
| Event | Fires when, and what to do |
|---|---|
assessment.screening.completed |
The first stage finished, in either mode. In screening mode,
assessment.completed follows with the result. In full
mode this is progress only: result is null until the
assessment is complete
|
assessment.bank_data.required |
The assessment reached awaiting_bank_data. Start the borrower's
account-information journey and submit the result before expires_at
|
assessment.bank_data.received |
Your submission was accepted and evaluation resumed. No action needed |
assessment.completed |
Terminal. A result exists. This fires for a rejection too, because a rejection is a successful assessment whose answer is no |
assessment.failed |
Terminal. No assessment was produced. Not a credit outcome. Treat as an operational event, not a decision |
Payload shape#
Every event has the same envelope. data.object is the full assessment, the
same one GET /v1/assessments/{id} returns, so a consumer that has verified the
signature does not need a second call to act.
Your metadata is echoed on every event, so a callback can be correlated with
your own case record without keeping a lookup table of Credit Atlas ids.
Signature verification#
Every callback carries:
CreditAtlas-Signature: t=<unix_timestamp>,v1=<hex>
The signature is HMAC-SHA256 over <timestamp> + "." + <raw body>,
keyed with your signing secret. Three rules, and each one is there because skipping it
breaks the guarantee:
| Rule | What goes wrong without it |
|---|---|
| Compute over the raw body | Parsing and re-serialising JSON changes whitespace and key order. The bytes you sign stop being the bytes we signed, and every callback fails verification |
| Compare in constant time | A short-circuiting comparison leaks how much of the signature was correct, which is enough to forge one given time |
| Reject a stale timestamp (300 s) | Without it a valid callback captured once can be replayed at you indefinitely. The signature alone proves authenticity, not freshness |
Retry schedule#
Seven attempts, at 0, 60, 300, 1800, 7200, 28800 and 86400 seconds after the event. That is immediately, then spreading out to a final try 24 hours later. Any 2xx within ten seconds stops them. A non-2xx, a timeout or a connection failure schedules the next attempt.
Acknowledge first and process afterwards. If your handler does the work before replying, a slow database turns into a duplicate delivery, and then into a second duplicate.
Deduplication#
event.id
An acknowledgement that times out is retried even though the first delivery arrived
and was processed. This is not an edge case. It is the normal behaviour of every
at-least-once delivery system, including this one. Store event.id and
ignore repeats.
When a callback is missed#
If your endpoint was down for all seven attempts, the event is gone. Recovery is
GET /v1/assessments filtered by created_after and
status.
Reconcile from the list, not from memory.
Your endpoint#
- HTTPS only. A valid certificate, not self-signed.
- Reachable from the public internet, or allowlisted to the addresses given at onboarding.
- Answers within ten seconds. Acknowledge first, work afterwards.
- Returns any 2xx to acknowledge. We do not read the body.
- One endpoint receives all event types. Branch on
type.
Common mistakes#
| Mistake | Consequence |
|---|---|
| Verifying against parsed-and-reserialised JSON | Every signature fails. The most common integration bug in any signed-webhook system |
| Processing before acknowledging | Duplicate deliveries under load, exactly when you can least afford them |
Not storing event.id | The same assessment is actioned twice |
Treating assessment.failed as a rejection | An applicant is declined because a registry timed out |
Treating assessment.completed as an approval | It fires for every terminal outcome. Read policy_outcome.value |
Waiting for expired | It is a terminal state, not a signal to wait for. Stop waiting at expires_at |
| Skipping the timestamp check | Captured callbacks can be replayed at you later |