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
- Credenciales y modos
- Crear un Payment Link (API)
- Consultar estado de un link
- Callback URL (retorno del usuario)
- Webhook — configuración
- Webhook — verificación de firma
- Webhook — procesamiento del evento
- Estrategia de referencias múltiples
- Idempotencia
- Esquema de base de datos
- Flujo completo de extremo a extremo
- 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
- Ingresa al panel de Bold → Configuración → Integraciones → API.
- Copia la API Key y la Secret Key.
- 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
{
"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: 25000equivale 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 (formatoLNK_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;
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
{
"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
secretKeyes""(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.
HTTP 400 al crear el link
- Verificar que
total_amountsea entero (no float). referenceno puede superar 60 caracteres ni contener caracteres especiales.- La
callback_urldebe 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
- Panel Bold: 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}