# Guide to Khipu Webhooks: Features and Best Practices

[Khipu](https://khipu.com) 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_api_version: "3.0"` |
| 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](https://github.com/hookdeck/webhook-samples/blob/main/providers/khipu/latest/reconciliation.json) has this reconciliation body, taken from Khipu's documentation with test values.

```json
{
  "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:

1. When you create a collection (payment request) through the instant payments API, include:
  
  * `notify_url`: your HTTPS endpoint, for example `https://example.com/webhooks/khipu`
  * `notify_api_version`: `"3.0"`
2. Store the `payment_id` from the response against your order.
3. 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](/docs/cli):

```bash
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](https://docs.khipu.com/en/payment-solutions/instant-payments/payment-webhook) has a worked Python example.

```javascript
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.

```javascript
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](/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 `200` promptly 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_url` from 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_api_version: "3.0"` falls back to notifications API 1.3, which posts a `notification_token` and isn't signed with `x-khipu-signature`.

**Workarounds:**

* Set `notify_api_version` explicitly 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_date` before 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](/webhooks/guides/why-implement-asynchronous-processing-webhooks) describes the pattern, and [webhook idempotency](/webhooks/guides/implement-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](https://hookdeck.com) gives Khipu notifications a stable URL, verification and a stored history you can replay.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Khipu webhooks reliably in minutes.