Request signing
Credicorp signs every webhook with HMAC-SHA256 so you can prove a delivery genuinely came from us and wasn't altered in transit. Verify the signature on every request before you trust the body — an unverified webhook is just an unauthenticated POST to a public URL.
Never act on an unverified webhook. Your endpoint is public, so anyone can POST to it. A forged payment.settled could trick you into releasing goods, and a forged decision.completed into onboarding an applicant we declined. Always verify the signature, and never trust amounts in the payload over a server-side read of the Payments API.
The Credicorp-Signature header
Every delivery carries a Credicorp-Signature header. It is a comma-separated list of key=value pairs: a timestamp t and one or more signatures v1. Multiple v1 values appear during a secret rotation — match against any of them.
POST /webhooks/credicorp HTTP/1.1 Host: yourapp.com Content-Type: application/json Credicorp-Signature: t=1719660000,v1=4f8b2e1c9a7d… Credicorp-Delivery: whd_3KqaP9 {"id":"evt_8Kd2c9Qm","type":"decision.completed","data":{…}}
| Header / field | Description |
|---|---|
Credicorp-Signature | The signed timestamp and signature(s). The value you verify. |
t | Unix timestamp (seconds) when the signature was generated. Drives replay defence. |
v1 | Lower-case hex HMAC-SHA256 of the signed payload. One or more, space/comma separated. |
Credicorp-Delivery | Unique delivery id. Useful for idempotent processing and support. |
The signed payload
The signature is computed over a string you reconstruct yourself — the timestamp, a literal dot, and the exact raw request body:
signed_payload = t + "." + raw_request_body # expected = hex( HMAC_SHA256( whsec, signed_payload ) )
Sign the raw bytes, not parsed JSON. Capture the body before any JSON middleware touches it. If you re-serialise a parsed object, key order and whitespace change and the HMAC will never match. In Express use express.raw(); in Laravel read $request->getContent(); in Flask read request.get_data().
Verify a delivery
Compute the expected HMAC with your endpoint's whsec secret and compare it to the header in constant time. The official SDKs ship a one-line webhooks.verify() helper; the raw recipe is below in three languages.
import express from "express"; import crypto from "node:crypto"; const app = express(); const SECRET = process.env.CC_WEBHOOK_SECRET; // whsec_… const TOLERANCE = 300; // 5 min // IMPORTANT: capture the RAW body — do not JSON-parse first. app.post("/webhooks/credicorp", express.raw({ type: "*/*" }), (req, res) => { const header = req.headers["credicorp-signature"] || ""; const parts = Object.fromEntries( header.split(",").map(p => p.trim().split("="))); const t = parts.t; const body = req.body; // Buffer // 1. Replay defence — reject stale timestamps. if (Math.abs(Date.now() / 1000 - Number(t)) > TOLERANCE) return res.status(400).send("stale timestamp"); // 2. Recompute the HMAC over `t.body`. const signed = Buffer.concat([Buffer.from(t + "."), body]); const expected = crypto.createHmac("sha256", SECRET) .update(signed).digest("hex"); // 3. Constant-time compare against each v1. const ok = (parts.v1 || "").split(" ").some(sig => { const a = Buffer.from(sig), b = Buffer.from(expected); return a.length === b.length && crypto.timingSafeEqual(a, b); }); if (!ok) return res.status(400).send("bad signature"); const event = JSON.parse(body.toString("utf8")); // … handle event.type, then 2xx fast. res.json({ received: true }); });
<?php $secret = getenv('CC_WEBHOOK_SECRET'); // whsec_… $tolerance = 300; // 5 min // Read the RAW body — never the parsed $_POST. $body = file_get_contents('php://input'); $header = $_SERVER['HTTP_CREDICORP_SIGNATURE'] ?? ''; // Parse the header into t and v1[]. $t = null; $sigs = []; foreach (explode(',', $header) as $pair) { [$k, $v] = array_pad(explode('=', trim($pair), 2), 2, ''); if ($k === 't') $t = $v; if ($k === 'v1') $sigs = explode(' ', $v); } // 1. Replay defence. if ($t === null || abs(time() - (int)$t) > $tolerance) { http_response_code(400); exit('stale timestamp'); } // 2. Recompute HMAC over "t.body". $expected = hash_hmac('sha256', $t . '.' . $body, $secret); // 3. Constant-time compare against each v1. $ok = false; foreach ($sigs as $sig) { if (hash_equals($expected, $sig)) { $ok = true; break; } } if (!$ok) { http_response_code(400); exit('bad signature'); } $event = json_decode($body, true); // … handle $event['type'], then return 2xx fast. http_response_code(200); echo '{"received":true}';
import hmac, hashlib, time, os from flask import Flask, request, abort app = Flask(__name__) SECRET = os.environ["CC_WEBHOOK_SECRET"].encode() # whsec_… TOLERANCE = 300 # 5 min @app.post("/webhooks/credicorp") def webhook(): body = request.get_data() # raw bytes header = request.headers.get("Credicorp-Signature", "") parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p) t = parts.get("t", "") # 1. Replay defence. if not t.isdigit() or abs(int(time.time()) - int(t)) > TOLERANCE: abort(400, "stale timestamp") # 2. Recompute HMAC over b"t.body". signed = t.encode() + b"." + body expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest() # 3. Constant-time compare against each v1. sigs = parts.get("v1", "").split(" ") if not any(hmac.compare_digest(expected, s) for s in sigs): abort(400, "bad signature") event = request.get_json() # … handle event["type"], then return 2xx fast. return {"received": True}, 200
SDK helper
If you use an SDK, the whole recipe collapses to one call that parses the header, checks the timestamp, compares in constant time and throws on failure:
import { Credicorp } from "@credicorp/sdk"; const event = Credicorp.webhooks.verify({ payload: rawBody, // Buffer / string header: req.headers["credicorp-signature"], secret: process.env.CC_WEBHOOK_SECRET, toleranceSeconds: 300, }); // → parsed event, or throws SignatureVerificationError
Replay defence
The signed t timestamp lets you reject replays. Even a valid, correctly-signed request should be refused if it's old — an attacker who captures one delivery must not be able to resend it tomorrow.
- Reject outside tolerance. We recommend a five-minute window (
±300s). Tighten it if your clocks are well synced. - De-duplicate on delivery id. We may retry a delivery (see below), so store
Credicorp-Delivery/event.idand treat repeats as no-ops. Combined with the timestamp check, this closes the replay window completely. - Keep your clock in sync with NTP — a server drifting minutes will reject genuine deliveries.
| Response | What Credicorp does |
|---|---|
| 2xx | Delivery acknowledged. No retry. |
| 4xx | Treated as rejected (bad signature, your bug). Retried with back-off. |
| 5xx / timeout | Retried with exponential back-off for up to 72 hours, then marked failed. |
Verify, ack, then work. Respond 2xx within a few seconds of verifying the signature, then process asynchronously on a queue. Doing slow work (decisioning look-ups, disbursement) inline risks a timeout, which triggers a retry and a duplicate.
Rotating the signing secret
Roll an endpoint's whsec from its settings or with POST /partner/v1/webhooks/{id}/roll. During the overlap window Credicorp signs each delivery with both the old and new secret and emits two v1 values. Because your verifier already matches against every v1, deploy the new secret whenever you like, confirm deliveries still verify, then retire the old one — with no missed events.
# During rotation — two signatures, match either Credicorp-Signature: t=1719660000,v1=4f8b…oldsecret v1=a91c…newsecret
Verification checklist
- Read the raw request body before any JSON parsing.
- Reconstruct
signed_payload = t + "." + body. - Compute
HMAC-SHA256(whsec, signed_payload)as lower-case hex. - Compare with a constant-time function (
timingSafeEqual/hash_equals/compare_digest) — never==. - Reject timestamps outside your tolerance; de-duplicate on delivery id.
- Return
2xxfast, then process on a queue.
See the Webhooks reference for the full event catalogue and payload shapes, and API keys for where the whsec secret comes from.
