Authentication

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.

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.

http
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 / fieldDescription
Credicorp-SignatureThe signed timestamp and signature(s). The value you verify.
tUnix timestamp (seconds) when the signature was generated. Drives replay defence.
v1Lower-case hex HMAC-SHA256 of the signed payload. One or more, space/comma separated.
Credicorp-DeliveryUnique 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:

text
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.

javascript
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
<?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}';
python
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:

javascript
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.id and 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.
ResponseWhat Credicorp does
2xxDelivery acknowledged. No retry.
4xxTreated as rejected (bad signature, your bug). Retried with back-off.
5xx / timeoutRetried 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.

http
# 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 2xx fast, 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.