# 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}`