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
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
| Field | Type | Description | |
|---|---|---|---|
url | string | req | HTTPS endpoint that receives POST deliveries. Plain HTTP is rejected. |
enabled_events | array | req | Event types to subscribe to, or ["*"] for everything. |
description | string | opt | Human label shown in the dashboard, e.g. "Funding ledger sync". |
api_version | string | opt | Pin event payloads to a dated schema, e.g. 2026-06-01. Defaults to your account version. |
metadata | object | opt | Up to 20 key/value pairs, echoed on the endpoint object. |
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" }'
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.
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
{
"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.
{
"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
idmore than once. - type
- Dot-namespaced event type from the catalogue below.
- livemode
falsefor events generated by sandbox/test keys,truein 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 type | data.object | Fires when |
|---|---|---|
application.created | application | An application is created via the API or hosted journey. |
application.submitted | application | The applicant completes the journey; the deal is queued for decisioning. |
application.expired | application | An open application times out without completion. |
application.withdrawn | application | The application is cancelled before funding. |
decision.completed | decision | Decisioning finishes — outcome is approved, declined or referred. |
offer.accepted | offer | The borrower accepts a credit offer and signs the agreement. |
identity.verified | verification | KYB and director identity checks pass for an applicant. |
identity.review_required | verification | An identity or AML check is referred for manual review. |
account.activated | account | A loan is disbursed and a servicing account opens. |
account.closed | account | A facility is settled in full and closed. |
payment.created | payment | A PISP collection or disbursement is initiated. |
payment.settled | payment | Funds clear — a repayment is received or a disbursement lands. |
payment.failed | payment | A payment is rejected, returned, or the mandate is cancelled. |
mandate.revoked | mandate | The 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.
Credicorp-Signature: t=1782295452,v1=4f9b1e8c2a7d...d31 Content-Type: application/json
- Read the raw request body — do not re-serialise the parsed JSON, or the bytes will not match.
- Split the header into
tand thev1value(s). - Compute
HMAC-SHA256(secret, t + "." + raw_body)and compare with a constant-time check. - Reject the delivery if no
v1matches, or iftis more than 5 minutes from now (replay protection).
$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);
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(); });
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.
| Attempt | Delay after previous | Approx. elapsed |
|---|---|---|
| 1 | immediate | 0s |
| 2 | ~1 min | 1 min |
| 3 | ~5 min | 6 min |
| 4 | ~30 min | ~36 min |
| 5 | ~2 hr | ~2.5 hr |
| 6–9 | ~6 hr each | up 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
Returns a cursor-paginated history of events (retained 30 days). Filter by type and created_after. See Pagination.
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.
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
2xxwithin 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.
