API reference

Webhooks

Webhooks push platform events to your server the moment they happen — an application is submitted, a decision completes, a PISP payment settles. Register an HTTPS endpoint, verify the signature on every request, and respond 2xx quickly. Delivery is at-least-once: design every handler to be idempotent.

Webhooks are the correct way to follow the lifecycle of an application, the outcome of decisioning, and the state of payments and servicing accounts. Polling a list endpoint in a loop is slower, burns through rate limits, and will miss intermediate transitions. Prefer events.

Register an endpoint

POST/partner/v1/webhook_endpoints

Registers a destination URL and the set of events it should receive. The response includes a secret (prefixed whsec_) shown once — store it; you will need it to verify signatures. An endpoint may subscribe to specific event types or to ["*"] for all.

Body parameters

FieldTypeDescription
urlstringreqHTTPS endpoint that receives POST deliveries. Plain HTTP is rejected.
enabled_eventsarrayreqEvent types to subscribe to, or ["*"] for everything.
descriptionstringoptHuman label shown in the dashboard, e.g. "Funding ledger sync".
api_versionstringoptPin event payloads to a dated schema, e.g. 2026-06-01. Defaults to your account version.
metadataobjectoptUp to 20 key/value pairs, echoed on the endpoint object.
bash
curl -s https://api.credicorp.co.uk/partner/v1/webhook_endpoints \
  -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: whk-create-01" \
  -d '{
    "url": "https://yourapp.com/webhooks/credicorp",
    "enabled_events": [
      "application.submitted",
      "decision.completed",
      "payment.settled"
    ],
    "description": "Funding ledger sync"
  }'
php
use Credicorp\Client;

$cc = new Client($_ENV['CREDICORP_TOKEN']);

$endpoint = $cc->webhookEndpoints->create([
    'url'            => 'https://yourapp.com/webhooks/credicorp',
    'enabled_events' => ['application.submitted', 'decision.completed', 'payment.settled'],
    'description'    => 'Funding ledger sync',
]);

// Store $endpoint->secret securely — shown only now.
node
import Credicorp from '@credicorp/sdk';
const cc = new Credicorp(process.env.CREDICORP_TOKEN);

const endpoint = await cc.webhookEndpoints.create({
  url: 'https://yourapp.com/webhooks/credicorp',
  enabled_events: ['application.submitted', 'decision.completed', 'payment.settled'],
  description: 'Funding ledger sync',
});

// endpoint.secret is returned once — persist it now.

Response 201 Created

json
{
  "id": "whe_5Jh2mQ8c",
  "object": "webhook_endpoint",
  "url": "https://yourapp.com/webhooks/credicorp",
  "enabled_events": ["application.submitted", "decision.completed", "payment.settled"],
  "status": "enabled",
  "api_version": "2026-06-01",
  "secret": "whsec_QmF0Y2hTaWduaW5nS2V5RXhhbXBsZQ",
  "created_at": "2026-06-29T10:00:00Z"
}

The event payload

Every delivery is a single JSON event object sent as the raw request body. The resource you care about is nested under data.object; type tells you what happened. The same envelope shape is used for every event in the catalogue.

json
{
  "id": "evt_9Fc1aZ7p",
  "object": "event",
  "type": "decision.completed",
  "api_version": "2026-06-01",
  "created_at": "2026-06-29T10:04:12Z",
  "livemode": true,
  "data": {
    "object": {
      "id": "dec_3Pq7Lm2v",
      "object": "decision",
      "application_id": "app_8Kd2c9Qm",
      "outcome": "approved",
      "approved_amount_pence": 2500000,
      "apr_bps": 1490,
      "reference": "ORDER-4471"
    }
  }
}
id
Unique event ID. Use it to deduplicate — you may see the same id more than once.
type
Dot-namespaced event type from the catalogue below.
livemode
false for events generated by sandbox/test keys, true in production.
data.object
The full resource at the time the event fired. Re-fetch the live resource by ID if you need its latest state.

Event-type catalogue

Subscribe only to the events you act on. New event types are added over time and your endpoint must tolerate types it does not recognise (respond 2xx and ignore).

Event typedata.objectFires when
application.createdapplicationAn application is created via the API or hosted journey.
application.submittedapplicationThe applicant completes the journey; the deal is queued for decisioning.
application.expiredapplicationAn open application times out without completion.
application.withdrawnapplicationThe application is cancelled before funding.
decision.completeddecisionDecisioning finishes — outcome is approved, declined or referred.
offer.acceptedofferThe borrower accepts a credit offer and signs the agreement.
identity.verifiedverificationKYB and director identity checks pass for an applicant.
identity.review_requiredverificationAn identity or AML check is referred for manual review.
account.activatedaccountA loan is disbursed and a servicing account opens.
account.closedaccountA facility is settled in full and closed.
payment.createdpaymentA PISP collection or disbursement is initiated.
payment.settledpaymentFunds clear — a repayment is received or a disbursement lands.
payment.failedpaymentA payment is rejected, returned, or the mandate is cancelled.
mandate.revokedmandateThe borrower revokes a payment mandate at their bank.

Verify the signature

Every delivery carries a Credicorp-Signature header. It contains a Unix timestamp (t) and one or more HMAC-SHA256 signatures (v1) over the string {timestamp}.{raw_body}, keyed with your endpoint secret. Verify it before trusting the payload.

header
Credicorp-Signature: t=1782295452,v1=4f9b1e8c2a7d...d31
Content-Type: application/json
  1. Read the raw request body — do not re-serialise the parsed JSON, or the bytes will not match.
  2. Split the header into t and the v1 value(s).
  3. Compute HMAC-SHA256(secret, t + "." + raw_body) and compare with a constant-time check.
  4. Reject the delivery if no v1 matches, or if t is more than 5 minutes from now (replay protection).
php
$payload = file_get_contents('php://input');
$header  = $_SERVER['HTTP_CREDICORP_SIGNATURE'] ?? '';

try {
    $event = Credicorp\Webhook::constructEvent(
        $payload, $header, $_ENV['CREDICORP_WEBHOOK_SECRET']
    );
} catch (Credicorp\Exception\SignatureVerificationException $e) {
    http_response_code(400); exit;
}

if ($event->type === 'decision.completed') {
    $decision = $event->data->object;
    // ... fulfil, idempotently keyed on $event->id
}
http_response_code(200);
node
import express from 'express';
import Credicorp from '@credicorp/sdk';
const cc = new Credicorp(process.env.CREDICORP_TOKEN);
const app = express();

app.post('/webhooks/credicorp',
  express.raw({ type: 'application/json' }), async (req, res) => {
    let event;
    try {
      event = cc.webhooks.constructEvent(
        req.body, req.headers['credicorp-signature'],
        process.env.CREDICORP_WEBHOOK_SECRET
      );
    } catch (err) {
      return res.status(400).end();
    }
    if (event.type === 'payment.settled') {
      // reconcile, idempotently on event.id
    }
    res.status(200).end();
});
python
import credicorp, os
from flask import Flask, request

app = Flask(__name__)

@app.route('/webhooks/credicorp', methods=['POST'])
def hook():
    try:
        event = credicorp.Webhook.construct_event(
            request.data, request.headers.get('Credicorp-Signature'),
            os.environ['CREDICORP_WEBHOOK_SECRET'],
        )
    except credicorp.error.SignatureVerificationError:
        return '', 400

    if event.type == 'decision.completed':
        handle(event.data.object)  # keyed on event.id
    return '', 200

Verify every request. Anyone who learns your endpoint URL can POST to it. An unsigned or mis-signed request must be rejected with 400 — never fund, disburse, or reconcile from an unverified payload.

Retries & back-off

Delivery is at-least-once. We treat any 2xx response within 10 seconds as success. Any other status, a timeout, or a connection error is a failure and is retried with exponential back-off and jitter. Do the slow work (ledger writes, emails) after you have responded 2xx — acknowledge first, process async.

AttemptDelay after previousApprox. elapsed
1immediate0s
2~1 min1 min
3~5 min6 min
4~30 min~36 min
5~2 hr~2.5 hr
6–9~6 hr eachup to ~72 hr

After roughly 72 hours of continuous failures the endpoint is automatically disabled and a notification email is sent to your team. Re-enable it from the dashboard or by POSTing to /partner/v1/webhook_endpoints/{id}/enable. Use the replay endpoint to backfill events missed during an outage.

Because retries can arrive out of order and events may be redelivered, always deduplicate on event.id and make state transitions order-independent (check the resource's current status rather than assuming the event reflects the latest state).

List & replay events

GET/partner/v1/events

Returns a cursor-paginated history of events (retained 30 days). Filter by type and created_after. See Pagination.

POST/partner/v1/events/{id}/replay

Re-delivers a past event to one or all matching endpoints — useful after fixing a handler bug or recovering from downtime. Replayed deliveries are signed identically and carry the original event.id.

bash
curl -s https://api.credicorp.co.uk/partner/v1/events/evt_9Fc1aZ7p/replay \
  -H "Authorization: Bearer $TOKEN" \
  -d '{ "endpoint_id": "whe_5Jh2mQ8c" }'

Test locally without a public URL: run credicorp listen --forward-to localhost:3000/webhooks/credicorp from the CLI. It streams live sandbox events to your machine and prints a temporary signing secret.

Handler checklist

  • Respond fast. Return 2xx within 10s; queue the real work.
  • Verify always. Reject anything that fails signature or timestamp checks with 400.
  • Deduplicate. Persist processed event.ids; skip repeats.
  • Tolerate the unknown. Ignore event types you don't handle — don't error.
  • Re-fetch when it matters. For money movement, read the live resource by ID before acting.