Guide to Khipu Webhooks: Features and Best Practices
Khipu lets Chilean merchants collect payments by bank transfer: the customer pays from their own bank account, and Khipu reconciles the transfer against the payment request. Since a transfer can be confirmed some time after the customer leaves your checkout, Khipu's payment webhook is how an order gets marked paid and goods released.
This guide covers how Khipu's notification is attached to each collection, the reconciliation payload, verifying x-khipu-signature with its millisecond timestamp, and the parts of delivery Khipu leaves undocumented.
What are Khipu webhooks?
Khipu has no dashboard list of webhooks. Instead, each collection (payment request) you create through the instant payments API names its own notify_url and notify_api_version. With version "3.0", Khipu sends a POST to that URL when the payment is reconciled, carrying the payment's full data in a JSON body.
That differs from Khipu's older notifications API 1.3, which posted only a notification_token for you to exchange for the payment through the API. Version 1.3 isn't signed the same way and isn't covered here.
Only notifications API version 3.0 is supported. Version 1.3 notifications aren't signed this way and fail verification.
Each 3.0 delivery has an x-khipu-signature header holding a millisecond timestamp and a base64 HMAC-SHA256 over the timestamp and body.
Khipu webhook features
| Feature | Details |
|---|---|
| Configuration | Per collection: notify_url and notify_ |
| Signature | base64 HMAC-SHA256 |
| Signature header | x-khipu-signature (t=<ms>,s=<base64>) |
| Signed content | <t>.<raw body> |
| Key | The collecting account's merchant secret |
| Body | JSON payment data |
| Event routing | None; one live notification kind |
| Dedupe key | payment_id |
| Retries and timeout | Not documented |
Events and payloads
| Notification | Triggered When | Common Use Cases |
|---|---|---|
| Payment reconciliation | Khipu reconciles a payment for a collection with notify_url | Mark the order paid, issue the receipt, release goods |
Khipu's page lists three more kinds as upcoming: "Collection successfully generated", "Payment successfully made" and "Payment ended in error". The body has no type field, and the docs don't yet say how those will be told apart from reconciliation when they ship.
Event Payload Structure
The body is the payment: payment_id, the amount and currency, the payer's name, email, national ID and bank details, a receipt_url for the PDF receipt, the payment_method and the conciliation_date. out_of_date_conciliation flags a reconciliation that happened late.
Khipu's own signing example sends the same payment with amount and discount as strings ("1000.0000") and with escaped slashes, while the JSON example shows numbers. Don't assume either type, and always hash the exact bytes received.
Published payload example
The webhook samples library has this reconciliation body, taken from Khipu's documentation with test values.
{
"payment_id": "zfxnocsow6mz",
"receiver_id": 990939,
"subject": "TEST_COBRO",
"amount": 1000.0,
"discount": 0.0,
"currency": "CLP",
"receipt_url": "https://s3.amazonaws.com/staging.notifications.khipu.com/CPKH-1804240956-zfxnocsow6mz.pdf",
"bank": "DemoBank",
"bank_id": "Bawdf",
"payer_name": "Developer User #990.939",
"payer_email": "test@khipu.com",
"personal_identifier": "44.444.444-4",
"bank_account_number": "000000000000444444444",
"out_of_date_conciliation": false,
"transaction_id": "15f836bd-e8a7-4d12-b2f1-56403012b555",
"authorizer_operation_code": "AUT-20240418-0001",
"responsible_user_email": "test@khipu.com",
"payment_method": "simplified_transfer",
"conciliation_date": "2024-04-18T13:56:54.859Z"
}
Setting up Khipu webhooks
Prerequisites
- A Khipu collecting account with API access (the same account that creates the collections)
- A publicly reachable HTTPS endpoint
Register Your Endpoint
The endpoint is set in code, on every collection:
- When you create a collection (payment request) through the instant payments API, include:
notify_url: your HTTPS endpoint, for examplehttps://example.com/webhooks/khipunotify_api_version:"3.0"
- Store the
payment_idfrom the response against your order. - When the payment is reconciled, Khipu POSTs the payment to
notify_url.
A collection created without notify_api_version "3.0" gets the 1.3 notification instead. Changing the URL means changing the code that creates collections; existing collections keep the URL they were created with.
Get Your Signing Secret
The key is the merchant secret of the collecting account, the one whose credentials create the collection. Set it as WEBHOOK_SECRET and use it as UTF-8 bytes, without trimming or decoding. If you collect through more than one Khipu account, each has its own secret.
Test a delivery
Start a local receiver and forward requests with the Hookdeck CLI:
npx hookdeck-cli listen 3000 khipu --path /webhooks/khipu
Create a test collection with the CLI's URL as notify_url, pay it with Khipu's demo bank, and check that the body you receive matches what your parser expects.
Securing Khipu webhooks
Split x-khipu-signature on commas to get t and s. Build the message as the t text, a dot and the raw body, compute HMAC-SHA256 with the merchant secret, base64-encode it and compare it with s in constant time. t is Unix time in milliseconds; use it exactly as received. Khipu's payment webhook page has a worked Python example.
const crypto = require('node:crypto');
function decodeBase64(value) {
if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
const bytes = Buffer.from(value, 'base64');
return bytes.length > 0 && bytes.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')
? bytes : null;
}
function verifySignature(rawBody, headers, secret) {
if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
const signature = headers['x-khipu-signature'];
if (typeof signature !== 'string') return false;
const key = Buffer.from(secret, 'utf8');
const parts = signature.split(',').map((part) => part.trim());
const times = parts.filter((part) => part.startsWith('t=')).map((part) => part.slice(2));
if (times.length !== 1 || !/^[0-9]+$/.test(times[0])) return false;
const message = Buffer.concat([Buffer.from(`${times[0]}.`, 'utf8'), rawBody]);
const candidates = parts.filter((part) => part.startsWith('s=')).map((part) => part.slice(2));
const expected = crypto.createHmac('sha256', key).update(message).digest();
return candidates.some((candidate) => {
const received = decodeBase64(candidate);
if (!received) return false;
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}
The handler below records the payment once per payment_id and marks the matching order as paid.
const express = require('express');
function createKhipuApp({ alreadyRecorded, markOrderPaid }) {
const app = express();
app.post('/webhooks/khipu', express.raw({ type: '*/*' }), async (req, res) => {
const secret = process.env.WEBHOOK_SECRET;
if (!secret) return res.sendStatus(500);
if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);
let payment;
try {
payment = JSON.parse(req.body.toString('utf8'));
} catch {
return res.sendStatus(400);
}
if (!payment || typeof payment !== 'object' || Array.isArray(payment)) {
return res.sendStatus(400);
}
if (typeof payment.payment_id !== 'string') return res.sendStatus(400);
try {
if (!(await alreadyRecorded(payment.payment_id))) {
await markOrderPaid(payment.payment_id, {
amount: String(payment.amount),
currency: payment.currency,
receiptUrl: payment.receipt_url,
reconciledAt: payment.conciliation_date,
late: payment.out_of_date_conciliation === true,
});
}
} catch {
return res.sendStatus(503);
}
return res.sendStatus(200);
});
return app;
}
The verifier doesn't enforce an age limit on t. A receiver that calls Khipu directly can add one, but Khipu doesn't document how long retries (if any) take, so leave a generous margin. payment_id is what keeps a repeated notification from marking an order twice.
Hookdeck's Khipu source type parses t and s, signs {t}.{body} with your merchant secret, and compares the base64 result. Khipu receives 200 whatever the outcome; a failed check is stored as failed verification and not delivered onwards. Hookdeck doesn't compare t with the current time, and the original header is kept on retries and replays.
Make Khipu webhooks production-ready. Hookdeck Event Gateway verifies each Khipu notification, stores it, and retries delivery to your service, filling the gap left by Khipu's undocumented retry behaviour.
Khipu webhook limitations and pain points
Undocumented retries
The Problem: You can't tell whether a notification missed during downtime will come again.
Why It Happens: Khipu documents one POST per event and says nothing about retries, timeouts or which response it expects.
Workarounds:
- Return
200promptly after storing the payment. - Poll the Khipu API for collections still pending after a reasonable period.
How Hookdeck Can Help: Hookdeck stores each notification on arrival and handles retries to your service, so a deploy or outage on your side doesn't depend on Khipu trying again.
The URL lives in every collection
The Problem: Moving the endpoint, or adding Hookdeck in front, doesn't affect payments already created.
Why It Happens: notify_url is fixed per collection at creation time.
Workarounds:
- Keep the old endpoint running until outstanding collections have expired or been paid.
- Put a stable URL, such as a Hookdeck source URL, in
notify_urlfrom the start.
How Hookdeck Can Help: A Hookdeck source URL doesn't change when your backend moves, so collections never need a new notify_url.
Version 1.3 collections
The Problem: Some notifications fail verification while others pass.
Why It Happens: A collection created without notify_ falls back to notifications API 1.3, which posts a notification_token and isn't signed with x-khipu-signature.
Workarounds:
- Set
notify_api_versionexplicitly on every collection. - Check older integrations that might still create 1.3 collections.
How Hookdeck Can Help: Hookdeck's request log shows the failing requests' bodies, so 1.3 notifications are easy to spot by their notification_token.
No event type for future notifications
The Problem: When the announced notification kinds arrive, a receiver that assumes "every request is a reconciliation" may mark failed payments as paid.
Why It Happens: The body has no type field, and Khipu hasn't documented how the upcoming kinds will differ.
Workarounds:
- Check
conciliation_datebefore treating a payment as settled. - Log and hold any body that doesn't match the reconciliation shape.
How Hookdeck Can Help: Hookdeck filters on body fields such as conciliation_date can hold back anything that doesn't look like a reconciliation.
Best practices
Treat amounts as text
Read amount and discount as decimal strings, since Khipu's documentation shows both numbers and "1000.0000"-style strings. Never compute money in floating point.
Keep the timestamp as a string
Use t exactly as it appears in the header when building the signed message. Parsing it to a number and printing it back can change the text.
Protect payer data
Bodies include names, emails, national ID numbers and bank account numbers. Keep them out of logs and limit how long raw requests are retained.
Acknowledge quickly
Record the payment, return 200, and run fulfilment in the background. The asynchronous processing guide describes the pattern, and webhook idempotency covers storing payment_id with the order update.
Conclusion
Khipu's webhook is simple in scope, one reconciliation notification per collection, with a solid signature over a millisecond timestamp and the body. What it lacks is documentation on retries and on the notification kinds still to come, so a Khipu receiver should deduplicate on payment_id, reconcile pending payments through the API, and check the body before treating it as settled.
Hookdeck Event Gateway gives Khipu notifications a stable URL, verification and a stored history you can replay.
Get started with Hookdeck for free and handle Khipu webhooks reliably in minutes.