Webhooks

Push, don't poll

Subscribe once and Credda pushes a signed event the moment a score moves or a dispute resolves, with retries, replay and a full delivery log. Advisory only: no webhook can change a score.

The events

10 event types today. Each delivery wraps the payload below in a common envelope: { id, type, createdAt, data }. The id is stable across retries and replays.

score.updated

A user's Reliability Score changed, or was computed for the first time. Fires whenever the score value moves. Advisory only; receiving this never affects any score.

{
  "user": {
    "externalId": "seller_88"
  },
  "score": 72,
  "band": "Good",
  "previousScore": 64,
  "previousBand": "Fair",
  "confidence": 0.9,
  "totalEvents": 18,
  "verifiedEvents": 14,
  "behavioralQuality": 0.86,
  "formulaVersion": "5.3",
  "computedAt": "2026-07-21T12:00:00.000Z",
  "reason": null
}
score.band_changed

A user crossed a score-band boundary (e.g. Fair → Good). Same payload as score.updated, but fires only when the band label actually changes.

{
  "user": {
    "externalId": "seller_88"
  },
  "score": 72,
  "band": "Good",
  "previousScore": 64,
  "previousBand": "Fair",
  "confidence": 0.9,
  "totalEvents": 18,
  "verifiedEvents": 14,
  "behavioralQuality": 0.86,
  "formulaVersion": "5.3",
  "computedAt": "2026-07-21T12:00:00.000Z",
  "reason": null
}
dispute.resolved

A dispute on one of your events reached a terminal outcome, decided deterministically from the record, or resolved in the user's favour after the response window lapsed. Advisory only.

{
  "disputeId": "dsp_5f3a…",
  "eventId": "evt_9c21…",
  "user": {
    "externalId": "seller_88"
  },
  "outcome": "FOR_USER",
  "status": "RESOLVED_FOR_USER",
  "lapsed": false,
  "resolvedAt": "2026-07-21T12:00:00.000Z"
}
monitor.triggered

One of your score monitors fired: the user's score crossed a threshold you registered (edge-triggered: down through belowScore, up through aboveScore) or the score band changed. condition tells you which; threshold is null for band_change. Monitors are notification config only; a monitor never reads into or changes a score.

{
  "monitorId": "mon_7a2c…",
  "userId": "seller_88",
  "previousScore": 68,
  "newScore": 61,
  "previousBand": "Good",
  "newBand": "Fair",
  "condition": "below_score",
  "threshold": 65,
  "formulaVersion": "5.3"
}
policy.threshold_crossed

One of your threshold policies fired: a subject crossed the line you registered (edge-triggered: a score/component/verified-event-count crossing in a direction, or entering/leaving a band). Credda supplies EVIDENCE that the line was crossed: the deterministic score context and a machine-readable decision_input block, for your OWN policy engine. It is never a decision, a rating, or an instruction to act; the decision and the loss belong to you. Notification config only; a policy never reads into or changes a score.

{
  "policyId": "pol_3d1e…",
  "policyName": "deposit-waiver-good-band",
  "subject": {
    "externalId": "seller_88"
  },
  "metric": "band",
  "condition": {
    "metric": "band",
    "direction": "enter",
    "threshold": null,
    "component": null,
    "band": "Good"
  },
  "observed": {
    "previous": "Fair",
    "current": "Good"
  },
  "score": 72,
  "previousScore": 64,
  "band": "Good",
  "previousBand": "Fair",
  "reason": {
    "scoreBefore": 64,
    "scoreAfter": 72,
    "scoreDelta": 8,
    "direction": "up",
    "topDriver": {
      "factor": "OTR",
      "delta": 0.12
    }
  },
  "decision_input": {
    "subject_external_id": "seller_88",
    "metric": "band",
    "direction": "enter",
    "threshold": null,
    "component": null,
    "band": "Good",
    "observed_previous": "Fair",
    "observed_current": "Good",
    "score": 72,
    "previous_score": 64,
    "score_band": "Good",
    "previous_band": "Fair",
    "components": {
      "reliability": 78,
      "timeliness": 71,
      "trustworthiness": 96,
      "verification": 60,
      "consistency": 82,
      "momentum": 55
    },
    "verified_events": 14,
    "confidence": 0.9,
    "formula_version": "5.3",
    "computed_at": "2026-07-23T12:00:00.000Z",
    "crossed_at": "2026-07-23T12:00:00.000Z"
  },
  "note": "Credda supplies deterministic evidence that a threshold was crossed. It is not an assessment, a rating, or an instruction to act. The decision, and the loss, belong to you.",
  "formulaVersion": "5.3",
  "computedAt": "2026-07-23T12:00:00.000Z"
}
usage.quota_warning

Your platform's month-to-date API usage crossed the warning share of its monthly quota (default 80%), a heads-up before requests start returning 429 QUOTA_EXCEEDED. Sent at most once per quota period. Only ever fires when a monthly quota cap is configured for your tier.

{
  "used": 80000,
  "cap": 100000,
  "remaining": 20000,
  "ratio": 0.8,
  "threshold": 80000,
  "periodEnd": "2026-08-01T00:00:00.000Z"
}
import.completed

An async historical import job (POST /api/v1/imports) reached its terminal state: COMPLETED with per-row counts, or FAILED. Lets you stop busy-polling GET /api/v1/imports/{id} for status. Advisory only; an import records events through the normal ingestion path; this notification changes no score.

{
  "importJobId": "imp_4b8f…",
  "status": "COMPLETED",
  "createdCount": 942,
  "skippedCount": 12,
  "failedCount": 3,
  "error": null,
  "completedAt": "2026-07-24T12:00:00.000Z"
}
screening.completed

An async bulk screening job (POST /api/v1/screenings) reached its terminal state: COMPLETED with the found count, or FAILED. Lets you stop busy-polling GET /api/v1/screenings/{id} for status; fetch the full results from GET /api/v1/screenings/{id}/results. Advisory only; screening reads scores, it never writes one.

{
  "screeningJobId": "scr_9a1c…",
  "status": "COMPLETED",
  "total": 500,
  "foundCount": 473,
  "error": null,
  "completedAt": "2026-07-24T12:00:00.000Z"
}
confirmation.awaiting_response

A confirmation request you created is still PENDING and has gone unanswered past the reminder threshold (CONFIRMATION_REMINDER_AFTER_HOURS, default 48h). Sent at most once per request. Credda delivers nothing to anyone; this tells YOU to re-send the link over the channel you already own, using the one-time token you received at create time (only its hash is stored here, so the payload carries a URL template, never a working link, and never the token). Notification only: a pending request has recorded no event, and none is recorded if it lapses.

{
  "confirmationId": "cnf_6b41a2c9d0e3",
  "stage": "awaiting_response",
  "subject": {
    "externalId": "tech_88"
  },
  "eventType": "CONTRACT_FULFILLED",
  "stakeLevel": "MEDIUM",
  "description": "Confirm the emergency boiler repair on 14 March was completed",
  "counterpartyRef": "client-4471",
  "counterpartyName": "Northgate Dental",
  "campaignId": null,
  "status": "PENDING",
  "createdAt": "2026-07-20T09:00:00.000Z",
  "expiresAt": "2026-07-27T09:00:00.000Z",
  "ageHours": 48,
  "hoursUntilExpiry": 120,
  "confirmUrlTemplate": "https://api.credda.io/confirm/cnf_6b41a2c9d0e3?token={confirmationToken}",
  "previewUrlTemplate": "https://api.credda.io/api/v1/confirmations/cnf_6b41a2c9d0e3/preview?token={confirmationToken}",
  "note": "Credda delivers nothing to anyone; you hold the channel, and the one-time token you received when this request was created. Only its hash is stored here, so this notification carries a URL template rather than a working link: substitute that token for {confirmationToken}. A request that is still pending has recorded no event, and a request that lapses records none either; an absent confirmation is not evidence of an outcome, in any direction."
}
confirmation.expiring_soon

A PENDING confirmation request is approaching its expiry (within CONFIRMATION_EXPIRING_WITHIN_HOURS, default 24h), the last window in which the counterparty can still act. Sent at most once per request, independently of confirmation.awaiting_response, so you can subscribe to only this one. Same payload, same rules: no token, no counterparty contact detail, no score, and nothing is written either way.

{
  "confirmationId": "cnf_6b41a2c9d0e3",
  "stage": "expiring_soon",
  "subject": {
    "externalId": "tech_88"
  },
  "eventType": "CONTRACT_FULFILLED",
  "stakeLevel": "MEDIUM",
  "description": "Confirm the emergency boiler repair on 14 March was completed",
  "counterpartyRef": "client-4471",
  "counterpartyName": "Northgate Dental",
  "campaignId": null,
  "status": "PENDING",
  "createdAt": "2026-07-20T09:00:00.000Z",
  "expiresAt": "2026-07-27T09:00:00.000Z",
  "ageHours": 156,
  "hoursUntilExpiry": 12,
  "confirmUrlTemplate": "https://api.credda.io/confirm/cnf_6b41a2c9d0e3?token={confirmationToken}",
  "previewUrlTemplate": "https://api.credda.io/api/v1/confirmations/cnf_6b41a2c9d0e3/preview?token={confirmationToken}",
  "note": "Credda delivers nothing to anyone; you hold the channel, and the one-time token you received when this request was created. Only its hash is stored here, so this notification carries a URL template rather than a working link: substitute that token for {confirmationToken}. A request that is still pending has recorded no event, and a request that lapses records none either; an absent confirmation is not evidence of an outcome, in any direction."
}

Verify every delivery

Each delivery is HMAC-SHA256 signed over ${timestamp}.${rawBody}. Verify it with the SDK before trusting it. The check is constant-time and replay-tolerant. Use the raw body, not re-serialized JSON.

import { constructWebhookEvent } from '@credda/js';

// In your webhook handler, pass the RAW request body (not parsed JSON):
const event = await constructWebhookEvent({
  rawBody,
  signatureHeader: req.headers['x-credda-signature'],
  timestampHeader: req.headers['x-credda-timestamp'],
  secret: process.env.CREDDA_WEBHOOK_SECRET,   // shown once, at creation
});
// Throws if the signature is invalid or the timestamp is stale.
switch (event.type) {
  case 'score.updated': /* … */ break;
  case 'dispute.resolved': /* … */ break;
}

Wire-compatible across the TypeScript, Python and Go SDKs, so you can sign with one and verify with another.

Subscribe & operate

Create an endpoint, send a signed test delivery, then watch the cursor-paginated delivery log (one row per attempt). Failed deliveries retry with exponential backoff, and any stored delivery can be replayed.

# Create a webhook (the signing secret is returned ONCE, so store it)
$ curl -X POST https://api.credda.io/api/v1/webhooks \
    -H "Authorization: Bearer $CREDDA_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"url":"https://you.example.com/hooks/credda",
         "events":["score.updated","dispute.resolved"]}'

# Send a signed test delivery, then read the delivery log
$ curl -X POST https://api.credda.io/api/v1/webhooks/WEBHOOK_ID/test \
    -H "Authorization: Bearer $CREDDA_API_KEY"

Full CRUD, delivery log and replay are in the API reference; the machine-readable event catalog is at /api/v1/webhooks/events.

Next