Files
soft_usite/BOLD_INTEGRACION.md
T

638 lines
20 KiB
Markdown

# Integración Bold — Guía completa
Guía de referencia para integrar Bold (pasarela de pagos colombiana) en cualquier sistema backend.
Basada en experiencia real de producción con PHP; los conceptos aplican a cualquier lenguaje.
---
## Índice
1. [Credenciales y modos](#1-credenciales-y-modos)
2. [Crear un Payment Link (API)](#2-crear-un-payment-link-api)
3. [Consultar estado de un link](#3-consultar-estado-de-un-link)
4. [Callback URL (retorno del usuario)](#4-callback-url-retorno-del-usuario)
5. [Webhook — configuración](#5-webhook--configuración)
6. [Webhook — verificación de firma](#6-webhook--verificación-de-firma)
7. [Webhook — procesamiento del evento](#7-webhook--procesamiento-del-evento)
8. [Estrategia de referencias múltiples](#8-estrategia-de-referencias-múltiples)
9. [Idempotencia](#9-idempotencia)
10. [Esquema de base de datos](#10-esquema-de-base-de-datos)
11. [Flujo completo de extremo a extremo](#11-flujo-completo-de-extremo-a-extremo)
12. [Errores frecuentes y soluciones](#12-errores-frecuentes-y-soluciones)
---
## 1. Credenciales y modos
Bold maneja dos entornos que se diferencian **únicamente por la API key**; el endpoint es el mismo.
| Parámetro | Descripción |
|--------------------|-----------------------------------------------------------|
| `bold_api_key` | Clave de la API (diferente para test y producción) |
| `bold_secret_key` | Clave secreta para verificar la firma del webhook |
| `bold_mode` | `"test"` / `"production"` (para lógica propia del sistema)|
> **Importante:** En modo **test** la clave secreta del webhook es una cadena vacía `""`.
> En producción se usa la clave real del panel de Bold.
### Obtener las credenciales
1. Ingresa al panel de Bold → **Configuración → Integraciones → API**.
2. Copia la **API Key** y la **Secret Key**.
3. Para el webhook, registra tu URL en **Configuración → Webhooks**.
---
## 2. Crear un Payment Link (API)
### Endpoint
```
POST https://integrations.api.bold.co/online/link/v1
```
### Headers
```
Authorization: x-api-key {bold_api_key}
Content-Type: application/json
Accept: application/json
```
### Payload JSON
```json
{
"amount_type": "CLOSE",
"amount": {
"currency": "COP",
"total_amount": 25000
},
"description": "Descripción del producto o servicio",
"callback_url": "https://tudominio.com/pago_exitoso.php",
"payer_email": "cliente@ejemplo.com",
"reference": "TU-REF-12345-1746123456"
}
```
> **Moneda COP:** Bold no usa centavos. `total_amount: 25000` equivale a $25.000 pesos.
#### Campos clave
| Campo | Tipo | Descripción |
|----------------|---------|-----------------------------------------------------------------------------|
| `amount_type` | string | `"CLOSE"` = monto fijo. `"OPEN"` = el usuario puede ingresar el monto. |
| `total_amount` | int | Valor en pesos COP (sin centavos). |
| `callback_url` | string | URL a la que Bold redirige al usuario tras el pago. |
| `payer_email` | string | Pre-rellena el email en la pasarela. |
| `reference` | string | **Tu referencia personalizada.** Llega al webhook. Max 60 chars, alfanumérico + guiones. |
### Respuesta exitosa (`HTTP 200`)
```json
{
"payload": {
"payment_link": "LNK_abc123xyz",
"url": "https://checkout.bold.co/payment/LNK_abc123xyz"
}
}
```
- `payment_link` → ID interno de Bold (formato `LNK_xxx`). Guardarlo en tu base de datos.
- `url` → URL a la que debes redirigir al usuario.
### Ejemplo en PHP
```php
$payload = [
'amount_type' => 'CLOSE',
'amount' => ['currency' => 'COP', 'total_amount' => 25000],
'description' => 'Inscripción al evento',
'callback_url' => 'https://tudominio.com/pago_exitoso.php',
'payer_email' => $email,
'reference' => 'ERA-' . $usuarioId . '-' . time(),
];
$ch = curl_init('https://integrations.api.bold.co/online/link/v1');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => [
'Authorization: x-api-key ' . $apiKey,
'Content-Type: application/json',
],
CURLOPT_TIMEOUT => 15,
CURLOPT_SSL_VERIFYPEER => true,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
// Manejar error
}
$data = json_decode($response, true);
$linkId = $data['payload']['payment_link'];
$paymentUrl = $data['payload']['url'];
header('Location: ' . $paymentUrl);
exit;
```
---
## 3. Consultar estado de un link
Útil si el webhook no llegó o como verificación extra en el callback.
### Endpoint
```
GET https://integrations.api.bold.co/online/link/v1/{payment_link}
```
### Headers
```
Authorization: x-api-key {bold_api_key}
Accept: application/json
```
### Respuesta relevante
```json
{
"payload": {
"payment_link": "LNK_abc123xyz",
"status": "ACTIVE",
"transactions": [
{
"status": "APPROVED",
"payment_method": "CARD",
"amount": 25000
}
]
}
}
```
---
## 4. Callback URL (retorno del usuario)
Cuando el usuario termina (o abandona) el pago, Bold lo redirige a tu `callback_url` con parámetros GET.
> **Advertencia:** Bold puede usar distintos nombres de parámetro según la versión del checkout.
> **Nunca confíes en un único parámetro.** Revisa todos los posibles:
```
bold-order-id, order_id, payment_link, payment_link_id, reference, id
```
### Estrategia robusta en PHP
```php
// Recolectar todos los candidatos posibles
$candidateRefs = [];
foreach (['bold-order-id', 'order_id', 'payment_link', 'reference', 'id'] as $key) {
$val = trim((string)($_GET[$key] ?? ''));
if ($val !== '') {
$candidateRefs[] = $val;
}
}
// Agregar lo guardado en sesión antes de la redirección
if (!empty($_SESSION['bold_order_id'])) {
$candidateRefs[] = $_SESSION['bold_order_id'];
}
$candidateRefs = array_values(array_unique(array_filter($candidateRefs)));
// Buscar usuario por cualquiera de las referencias
$usuario = null;
foreach ($candidateRefs as $ref) {
$usuarioId = buscarUsuarioIdPorReferencia($pdo, $ref);
if ($usuarioId) {
// Cargar el usuario y salir del loop
break;
}
}
// Respaldo: usar ID de sesión si no se encontró por referencia
if (!$usuario && !empty($_SESSION['usuario_id'])) {
// Cargar por $_SESSION['usuario_id']
}
```
> **Nota crítica:** La callback se dispara **antes** del webhook. No es seguro marcar un pago
> como aprobado solo por el callback; el origen de verdad es el **webhook**.
> En el callback solo confirma que el usuario regresó y muestra una pantalla de "procesando".
---
## 5. Webhook — configuración
### Registrar la URL en Bold
Panel de Bold → **Configuración → Webhooks** → agregar URL:
```
https://tudominio.com/webhook.php
```
### Eventos disponibles
| Evento | Descripción |
|------------------|------------------------------------|
| `SALE_APPROVED` | Pago aprobado exitosamente |
| `SALE_REJECTED` | Pago rechazado por la entidad |
| `SALE_REVERSED` | Reverso / devolución |
| `CHARGEBACK` | Contracargo iniciado |
Para la mayoría de casos solo necesitas `SALE_APPROVED`.
### Requisitos del receptor
- Debe responder **HTTP 200** en **menos de 2 segundos**.
- Si la respuesta demora más, Bold reintentará el envío.
- La URL debe ser HTTPS en producción.
---
## 6. Webhook — verificación de firma
Bold firma cada notificación con HMAC-SHA256. El header es `x-bold-signature`.
### Algoritmo de verificación
```
1. Leer el body RAW (antes de hacer cualquier json_decode)
2. Codificar en Base64: encoded = base64(rawBody)
3. Calcular HMAC: computed = HMAC-SHA256(encoded, secretKey) → hexadecimal
4. Comparar con el header x-bold-signature (timing-safe)
```
> **Modo test:** la `secretKey` es `""` (cadena vacía).
> **Modo producción:** usa la clave del panel de Bold.
### Implementación PHP
```php
$rawBody = (string) file_get_contents('php://input');
$signatureHeader = $_SERVER['HTTP_X_BOLD_SIGNATURE'] ?? '';
// Responder 200 ANTES de todo procesamiento
http_response_code(200);
header('Content-Type: application/json');
header('Connection: close');
$body = '{"ok":true}';
header('Content-Length: ' . strlen($body));
echo $body;
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request();
} else {
flush();
}
// --- A partir de aquí el cliente ya recibió 200 ---
if ($signatureHeader !== '') {
$keyForHmac = ($boldMode === 'production') ? $secretKey : '';
$encoded = base64_encode($rawBody);
$computed = hash_hmac('sha256', $encoded, $keyForHmac);
if (!hash_equals($computed, $signatureHeader)) {
// Firma inválida: loguear y salir
exit;
}
}
```
---
## 7. Webhook — procesamiento del evento
### Estructura del payload JSON
```json
{
"id": "notif_uuid_unico",
"type": "SALE_APPROVED",
"subject": "TXN_xyz789",
"data": {
"payment_id": "TXN_xyz789",
"amount": {
"total": 25000,
"currency": "COP"
},
"payer_email": "cliente@ejemplo.com",
"metadata": {
"reference": "ERA-42-1746123456"
}
}
}
```
### Campos a extraer
| Campo | Ruta en JSON | Descripción |
|----------------------------|---------------------------------------------|------------------------------------------|
| `notification_id` | `event.id` | ID único de la notificación (idempotencia) |
| `tipo` | `event.type` | Tipo de evento |
| `payment_id` | `event.data.payment_id` o `event.subject` | ID de la transacción Bold |
| `referencia` | `event.data.metadata.reference` | Tu referencia personalizada |
| `payer_email` | `event.data.payer_email` | Email del pagador |
| `amount` | `event.data.amount.total` | Monto en pesos COP |
### Flujo de procesamiento
```php
$event = json_decode($rawBody, true);
$notificationId = $event['id'] ?? '';
$tipo = $event['type'] ?? '';
$data = $event['data'] ?? [];
$paymentId = $data['payment_id'] ?? ($event['subject'] ?? '');
$referencia = $data['metadata']['reference'] ?? '';
$email = $data['payer_email'] ?? '';
// Solo procesar ventas aprobadas
if ($tipo !== 'SALE_APPROVED') {
exit;
}
// Verificar idempotencia (ver sección 9)
// Resolver usuario (ver sección 8)
// Marcar pago en la base de datos
// Enviar correo de confirmación
```
---
## 8. Estrategia de referencias múltiples
**Problema real:** Bold puede enviar en el webhook un `payment_id` diferente al `LNK_xxx`
original del payment link. Si solo guardas `bold_order_id`, la búsqueda falla.
**Solución:** tabla de historial de referencias. Cada vez que aparece una referencia nueva
asociada a un usuario, se registra.
### Tabla `usuarios_referencias_pago`
```sql
CREATE TABLE IF NOT EXISTS `usuarios_referencias_pago` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`usuario_id` INT NOT NULL,
`referencia` VARCHAR(120) NOT NULL,
`tipo` VARCHAR(40) NOT NULL DEFAULT 'desconocida',
`origen` VARCHAR(40) NOT NULL DEFAULT 'sistema',
`creado_en` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY `uq_referencia` (`referencia`),
INDEX `idx_usuario` (`usuario_id`),
CONSTRAINT `fk_ref_usuario`
FOREIGN KEY (`usuario_id`) REFERENCES `usuarios`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
### Cuándo registrar referencias
| Momento | Referencias a guardar | `tipo` |
|----------------------|--------------------------------------------------------|------------------|
| Al crear el link | `LNK_xxx` (payment_link) | `payment_link` |
| Al crear el link | `ERA-{id}-{ts}` (tu referencia) | `referencia_era` |
| En el callback URL | Todos los params GET de Bold | `callback` |
| En el webhook | `payment_id` del evento | `webhook_payment_id` |
| En el webhook | `metadata.reference` del evento | `webhook_referencia` |
### Función de búsqueda por cualquier referencia
```php
function buscarUsuarioIdPorReferencia(PDO $pdo, string $referencia): ?int {
$stmt = $pdo->prepare('
SELECT usuario_id
FROM usuarios_referencias_pago
WHERE referencia = ?
LIMIT 1
');
$stmt->execute([trim($referencia)]);
$row = $stmt->fetchColumn();
return $row ? (int)$row : null;
}
```
### Cascada de búsqueda en el webhook
```php
$usuarioId = null;
// 1. Por referencia personalizada ERA-{id}-{ts}
if ($referencia !== '') {
$usuarioId = buscarUsuarioIdPorReferencia($pdo, $referencia);
}
// 2. Por payment_id de la transacción
if (!$usuarioId && $paymentId !== '') {
$usuarioId = buscarUsuarioIdPorReferencia($pdo, $paymentId);
}
// 3. Por email (último recurso, solo si hay un único pendiente)
if (!$usuarioId && $payerEmail !== '') {
$stmt = $pdo->prepare('
SELECT id FROM usuarios
WHERE email = ? AND estado_pago = "pendiente"
ORDER BY fecha_registro DESC LIMIT 1
');
$stmt->execute([$payerEmail]);
$row = $stmt->fetchColumn();
$usuarioId = $row ? (int)$row : null;
}
```
---
## 9. Idempotencia
Bold puede reenviar la misma notificación varias veces (reintentos ante timeouts).
Debes garantizar que procesar la misma notificación dos veces no cause efectos duplicados
(doble correo, doble registro de pago, etc.).
### Tabla `webhook_log`
```sql
CREATE TABLE IF NOT EXISTS `webhook_log` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`notification_id` VARCHAR(64) NOT NULL,
`tipo` VARCHAR(30) NOT NULL,
`payment_id` VARCHAR(64) DEFAULT NULL,
`referencia` VARCHAR(120) DEFAULT NULL,
`usuario_id` INT DEFAULT NULL,
`procesado` TINYINT(1) NOT NULL DEFAULT 0,
`recibido_en` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY `uq_notification` (`notification_id`),
INDEX `idx_usuario` (`usuario_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
### Patrón de INSERT IGNORE
```php
// Intentar insertar. Si ya existe (UNIQUE conflict) → rowCount() = 0 → duplicado
$ins = $pdo->prepare('
INSERT IGNORE INTO webhook_log (notification_id, tipo, payment_id, referencia)
VALUES (?, ?, ?, ?)
');
$ins->execute([$notificationId, $tipo, $paymentId, $referencia]);
if ($ins->rowCount() === 0) {
// Notificación ya procesada anteriormente
exit;
}
// Continúa procesamiento...
// Al finalizar, marcar como procesado
$pdo->prepare('UPDATE webhook_log SET procesado=1, usuario_id=? WHERE notification_id=?')
->execute([$usuarioId, $notificationId]);
```
---
## 10. Esquema de base de datos
Tablas mínimas necesarias:
```sql
-- Tabla principal de usuarios/registros
CREATE TABLE `usuarios` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`email` VARCHAR(255) NOT NULL,
`token` VARCHAR(64) NOT NULL UNIQUE,
`bold_order_id` VARCHAR(80) DEFAULT NULL, -- LNK_xxx principal
`estado_pago` ENUM('pendiente','pagado') NOT NULL DEFAULT 'pendiente',
`fecha_registro` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Historial de todas las referencias de Bold asociadas al usuario
CREATE TABLE `usuarios_referencias_pago` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`usuario_id` INT NOT NULL,
`referencia` VARCHAR(120) NOT NULL,
`tipo` VARCHAR(40) NOT NULL DEFAULT 'desconocida',
`origen` VARCHAR(40) NOT NULL DEFAULT 'sistema',
`creado_en` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY `uq_referencia` (`referencia`),
INDEX `idx_usuario` (`usuario_id`),
CONSTRAINT `fk_ref_usuario`
FOREIGN KEY (`usuario_id`) REFERENCES `usuarios`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Log de webhooks para idempotencia
CREATE TABLE `webhook_log` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`notification_id` VARCHAR(64) NOT NULL,
`tipo` VARCHAR(30) NOT NULL,
`payment_id` VARCHAR(64) DEFAULT NULL,
`referencia` VARCHAR(120) DEFAULT NULL,
`usuario_id` INT DEFAULT NULL,
`procesado` TINYINT(1) NOT NULL DEFAULT 0,
`recibido_en` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY `uq_notification` (`notification_id`),
INDEX `idx_usuario` (`usuario_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
---
## 11. Flujo completo de extremo a extremo
```
Usuario llena formulario
[Tu backend] genera referencia personalizada:
ERA-{usuarioId}-{timestamp}
POST → API Bold /online/link/v1
▼ respuesta LNK_xxx + URL
[Guardar en DB]:
- usuarios.bold_order_id = LNK_xxx
- referencias_pago: LNK_xxx (tipo: payment_link)
- referencias_pago: ERA-... (tipo: referencia_era)
- sesión: bold_order_id, usuario_id
Redirigir usuario a URL Bold (checkout)
Usuario paga
┌────┴────────────────────────────────┐
│ │
▼ ▼
callback_url (GET) webhook (POST) ← fuente de verdad
Bold redirige al usuario Bold notifica el evento
con params: bold-order-id, etc.
│ │
▼ ▼
Mostrar pantalla "procesando" 1. Responder 200 INMEDIATAMENTE
No marcar como pagado aún 2. Verificar firma HMAC
3. Idempotencia (INSERT IGNORE)
4. Resolver usuario por referencias
5. UPDATE estado_pago = 'pagado'
6. Enviar correo de confirmación
```
---
## 12. Errores frecuentes y soluciones
### El webhook no llega
- Verificar que la URL esté registrada en el panel de Bold.
- La URL debe ser **pública** (no localhost). Usar [ngrok](https://ngrok.com) para pruebas locales:
```bash
ngrok http 80
# Registrar la URL https://xxxx.ngrok.io/webhook.php en Bold
```
- Confirmar que el servidor responde HTTP 200 en < 2 s.
- Revisar el log de webhooks en el panel de Bold para ver reintentos.
### Firma inválida en modo test
El `secretKey` en modo test es `""` (cadena vacía). Asegúrate de usar eso, no la clave real.
```php
$keyForHmac = ($boldMode === 'production') ? $secretKey : '';
```
### No se encuentra el usuario en el webhook
El `payment_id` que llega en el webhook puede ser **diferente** al `LNK_xxx` creado con la API.
Solución: usar la tabla de historial de referencias y la referencia personalizada `ERA-{id}-{ts}`.
### Bold reintenta y se procesan pagos dobles
Implementar idempotencia con `webhook_log` usando `INSERT IGNORE` sobre `notification_id`.
### HTTP 400 al crear el link
- Verificar que `total_amount` sea entero (no float).
- `reference` no puede superar 60 caracteres ni contener caracteres especiales.
- La `callback_url` debe ser una URL accesible públicamente.
### Error de timeout en curl
Bold tiene un timeout de respuesta. Si el servidor tarda más de 15 s, la llamada falla.
Configurar `CURLOPT_TIMEOUT` en 15 y asegurar que la conexión a internet es estable.
---
## Referencias oficiales
- Documentación Bold: [https://developers.bold.co](https://developers.bold.co)
- Panel Bold: [https://dashboard.bold.co](https://dashboard.bold.co)
- API de links: `POST https://integrations.api.bold.co/online/link/v1`
- Consultar link: `GET https://integrations.api.bold.co/online/link/v1/{id}`