Files
soft_usite/BOLD_INTEGRACION.md
T

20 KiB

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
  2. Crear un Payment Link (API)
  3. Consultar estado de un link
  4. Callback URL (retorno del usuario)
  5. Webhook — configuración
  6. Webhook — verificación de firma
  7. Webhook — procesamiento del evento
  8. Estrategia de referencias múltiples
  9. Idempotencia
  10. Esquema de base de datos
  11. Flujo completo de extremo a extremo
  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.

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

{
  "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)

{
  "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

$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;

Ú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

{
  "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

// 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

$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

{
  "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

$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

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

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

$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

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

// 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:

-- 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 para pruebas locales:
    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.

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

  • 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