Primeros pasos
Quickstart
Esta guía cubre el camino desde "credenciales obtenidas" hasta "primera orden recibida y confirmada" sin intervención del equipo BipBip. Los cuatro pasos siguen el orden indicado.
Sandbox en proceso — integración directa en producción
Precondiciones
Antes de empezar, asegúrate de tener estos elementos de configuración. El equipo BipBip te los entrega durante el onboarding — todos son requeridos antes de escribir la primera línea de código:
- 1HMAC Secret — clave compartida para verificar la autenticidad del webhook (una por cuenta)
- 2API Key (
X-Bipbip-Api-Key) — header de autenticación para llamar la REST API - 3remoteId — identificador de la tienda, definido por el comercio (ej:
POS_TGU_001). Uno por tienda registrada. - 4Base URL registrada — la URL base del servidor del POS donde BipBip enviará los webhooks (debe ser públicamente accesible)
Los 4 pasos
- 1
Implementar el endpoint del webhook
BipBip envía
POST {baseUrl}/v1/order/{remoteId}cada vez que entra una orden nueva a tu tienda. Tu endpoint debe:- Capturar el cuerpo crudo (raw body) antes de parsear el JSON
- Verificar la firma HMAC (ver Verificación HMAC)
- Devolver HTTP 200 con un JSON body que incluya
remoteOrderId - Responder en menos de 15 segundos (BipBip toma cualquier respuesta lenta como fallo y reintenta)
Importante: remoteOrderId es obligatorio en la respuesta
Sin unremoteOrderIdválido en el body, BipBip trata el delivery como fallido y reintenta. El 200 solo no alcanza. VerBipBip sigue reintentando. - 2
Verificar la firma HMAC
Toda solicitud de BipBip incluye el header
X-Bipbip-Signature-256con una firma HMAC-SHA256. Verificar la firma garantiza que el request proviene de BipBip y no fue modificado en tránsito. La sección Verificación HMAC contiene los code samples. - 3
Responder con el remoteOrderId del POS
Una vez verificada la firma y creada la orden en el POS, la respuesta HTTP 200 incluye:
{ "remoteOrderId": "POS-2026-04-11-00142" }Este valor es el identificador interno del POS para esta orden. BipBip lo guarda y lo incluye en todos los webhooks de cancelación subsiguientes para permitir la correlación.
- 4
Aceptar la orden llamando a la REST API
Después de recibir el webhook y responder 200, la aceptación formal de la orden se hace llamando a
POST /api/v1/Orders/{orderKey}/accept. Esa llamada ejecuta de forma automática la secuencia de estados de la orden (pending → accepted → preparing) en una sola operación. El response traestatus: "preparing"directo — no es necesario (ni válido) llamar despuésPUT /statusconpreparing; la siguiente acción es marcarreadycuando el pedido esté listo.Este paso es opcional si tu tienda tiene auto-accept habilitado — en ese caso BipBip ejecuta esa misma secuencia automáticamente al recibir el 200, y la orden llega directo a
preparingsin que tengas que invocar/accept.// Accept an order via the BipBip REST API — Node.js (Quickstart Step 4) // Call POST /api/v1/Orders/{orderKey}/accept after receiving and verifying the webhook. // Requires Node.js 18+ (native fetch). No npm packages required. // // In v1.0 this call executes an atomic cascade: pending → accepted → preparing. // The response returns status: "preparing" directly. Do NOT call PUT /status with // "preparing" afterwards — the next merchant action is to mark the order ready. const API_BASE_URL = 'https://merchant-api.bipbip.hn'; // Staging: https://merchant-api.bipbip.dev const API_KEY = process.env.BIPBIP_API_KEY; // X-Bipbip-Api-Key header value /** * Accepts a BipBip order. The body is empty in v1.0 — the remoteOrderId was already * linked when the POS responded 200 to the order.created webhook. * * @param {string} orderKey - The opaque BipBip order key (format: "ord_" + 16 base62 chars) * @param {string} idempotencyKey - A unique UUID v4 for this request (reuse to safely retry) * @returns {Promise<object>} - { message, data: { orderKey, status: "preparing", acceptedAt, preparingAt, remoteOrderId } } */ async function acceptOrder(orderKey, idempotencyKey) { const url = `${API_BASE_URL}/api/v1/Orders/${encodeURIComponent(orderKey)}/accept`; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Bipbip-Api-Key': API_KEY, 'X-Bipbip-Schema-Version': '1.0', 'Idempotency-Key': idempotencyKey, // Required on all mutations — enables safe retry }, body: '{}', // Empty body in v1.0 }); if (!response.ok) { const error = await response.json().catch(() => ({})); throw new Error(`Accept failed: ${response.status} — ${JSON.stringify(error)}`); } return response.json(); } // ── Usage example ───────────────────────────────────────────────────────────── // Inside the webhook handler (after HMAC verification): // // const { randomUUID } = require('crypto'); // // async function handleOrderWebhook(req, res) { // const order = JSON.parse(req.body.toString('utf8')); // // // Step 1: Create the order in the POS system and obtain the internal ID // const remoteOrderId = await pos.createOrder(order); // // // Step 2: Respond 200 with remoteOrderId — this links the POS ID to the order // res.status(200).json({ remoteOrderId }); // // // Step 3: Accept the order on BipBip (use a stable UUID per attempt). // // Skip this step if the store has auto-accept enabled — BipBip runs the cascade itself. // const idempotencyKey = randomUUID(); // await acceptOrder(order.orderKey, idempotencyKey); // } module.exports = { acceptOrder };""" Accept an order via the BipBip REST API — Python 3.8+ (Quickstart Step 4) Call POST /api/v1/Orders/{orderKey}/accept after receiving and verifying the webhook. Uses only Python standard library (urllib.request, json, uuid). No pip packages required. In v1.0 this call executes an atomic cascade: pending -> accepted -> preparing. The response returns status: "preparing" directly. Do NOT call PUT /status with "preparing" afterwards — the next merchant action is to mark the order ready. """ import json import os import urllib.request import urllib.error from uuid import uuid4 API_BASE_URL = "https://merchant-api.bipbip.hn" # Staging: https://merchant-api.bipbip.dev API_KEY = os.environ.get("BIPBIP_API_KEY", "") # X-Bipbip-Api-Key header value def accept_order(order_key: str, idempotency_key: str) -> dict: """ Accepts a BipBip order. Body is empty in v1.0 — the remoteOrderId was already linked when the POS responded 200 to the order.created webhook. Args: order_key: The opaque BipBip order key (format: "ord_" + 16 base62 chars). idempotency_key: A unique UUID v4 for this request (reuse to safely retry). Returns: dict: { "message": ..., "data": { "orderKey": ..., "status": "preparing", "acceptedAt": ..., "preparingAt": ..., "remoteOrderId": ... } } Raises: urllib.error.HTTPError: If the API returns a non-2xx status code. """ url = f"{API_BASE_URL}/api/v1/Orders/{order_key}/accept" # Empty body in v1.0 payload = b"{}" req = urllib.request.Request( url, data=payload, method="POST", headers={ "Content-Type": "application/json", "X-Bipbip-Api-Key": API_KEY, "X-Bipbip-Schema-Version": "1.0", "Idempotency-Key": idempotency_key, # Required on all mutations }, ) with urllib.request.urlopen(req) as response: return json.loads(response.read().decode("utf-8")) # ── Usage example ────────────────────────────────────────────────────────────── # Inside the webhook handler (after HMAC verification): # # def handle_order_webhook(raw_body: bytes) -> dict: # order = json.loads(raw_body) # # # Step 1: Create the order in the POS system and obtain the internal ID # remote_order_id = pos.create_order(order) # # # Step 2: Accept the order on BipBip (use a stable UUID per attempt). # # Skip this if the store has auto-accept enabled — BipBip runs the cascade itself. # idempotency_key = str(uuid4()) # accept_order(order["orderKey"], idempotency_key) # # # Step 3: Respond 200 with remoteOrderId so BipBip can link the POS ID to the order # return {"remoteOrderId": remote_order_id}// Accept an order via the BipBip REST API — C# / .NET 6+ (Quickstart Step 4) // Call POST /api/v1/Orders/{orderKey}/accept after receiving and verifying the webhook. // Uses only System.Net.Http.HttpClient (built into .NET). No NuGet packages required. // // In v1.0 this call executes an atomic cascade: pending -> accepted -> preparing. // The response returns status: "preparing" directly. Do NOT call PUT /status with // "preparing" afterwards — the next merchant action is to mark the order ready. using System; using System.Net.Http; using System.Text; using System.Text.Json; using System.Threading.Tasks; /// <summary> /// Client for the BipBip Merchant REST API. /// </summary> public class BipBipOrderClient { private readonly HttpClient _http; private readonly string _apiKey; /// <param name="apiKey">X-Bipbip-Api-Key header value (provided by BipBip).</param> /// <param name="baseAddress">BipBip API base URL. Default: https://merchant-api.bipbip.hn (staging: https://merchant-api.bipbip.dev).</param> public BipBipOrderClient(string apiKey, string baseAddress = "https://merchant-api.bipbip.hn") { _apiKey = apiKey; _http = new HttpClient { BaseAddress = new Uri(baseAddress) }; } /// <summary> /// Accepts a BipBip order. Body is empty in v1.0 — the remoteOrderId was already /// linked when the POS responded 200 to the order.created webhook. /// </summary> /// <param name="orderKey">The opaque BipBip order key (format: "ord_" + 16 base62 chars).</param> /// <param name="idempotencyKey">A unique GUID for this request (reuse to safely retry).</param> public async Task<JsonElement> AcceptOrderAsync( string orderKey, string idempotencyKey) { // Empty body in v1.0 — note the path uses capital O ("/Orders/", case-sensitive) using var content = new StringContent("{}", Encoding.UTF8, "application/json"); using var request = new HttpRequestMessage( HttpMethod.Post, $"/api/v1/Orders/{Uri.EscapeDataString(orderKey)}/accept") { Content = content }; request.Headers.Add("X-Bipbip-Api-Key", _apiKey); request.Headers.Add("X-Bipbip-Schema-Version", "1.0"); request.Headers.Add("Idempotency-Key", idempotencyKey); // Required on all mutations var response = await _http.SendAsync(request); var json = await response.Content.ReadAsStringAsync(); response.EnsureSuccessStatusCode(); // Throws on 4xx/5xx return JsonDocument.Parse(json).RootElement; } } // ── Usage example ───────────────────────────────────────────────────────────── // Inside the webhook handler (after HMAC verification): // // var client = new BipBipOrderClient( // apiKey: Environment.GetEnvironmentVariable("BIPBIP_API_KEY")!); // // // Step 1: Create the order in the POS system and obtain the internal ID // string remoteOrderId = await pos.CreateOrderAsync(order); // // // Step 2: Accept the order on BipBip (use a stable GUID per attempt). // // Skip this if the store has auto-accept enabled — BipBip runs the cascade itself. // string idempotencyKey = Guid.NewGuid().ToString(); // await client.AcceptOrderAsync(order.OrderKey, idempotencyKey); // // // Step 3: Respond 200 with remoteOrderId so BipBip can link the POS ID to the order // return Ok(new { remoteOrderId });<?php /** * Accept an order via the BipBip REST API — PHP 8.0+ (Quickstart Step 4) * Call POST /api/v1/Orders/{orderKey}/accept after receiving and verifying the webhook. * Uses only PHP built-in cURL functions. No Composer packages required. * * In v1.0 this call executes an atomic cascade: pending -> accepted -> preparing. * The response returns status: "preparing" directly. Do NOT call PUT /status with * "preparing" afterwards — the next merchant action is to mark the order ready. */ define('BIPBIP_API_BASE', 'https://merchant-api.bipbip.hn'); // Staging: https://merchant-api.bipbip.dev $apiKey = getenv('BIPBIP_API_KEY') ?: ''; // X-Bipbip-Api-Key header value /** * Accepts a BipBip order. Body is empty in v1.0 — the remoteOrderId was already * linked when the POS responded 200 to the order.created webhook. * * @param string $orderKey The opaque BipBip order key (format: "ord_" + 16 base62 chars). * @param string $apiKey X-Bipbip-Api-Key header value. * @param string $idempotencyKey A unique UUID v4 for this request (reuse to safely retry). * @return array Decoded JSON response: ['message' => ..., 'data' => ['orderKey' => ..., 'status' => 'preparing', ...]] * @throws RuntimeException If the API returns a non-2xx status code. */ function acceptBipBipOrder( string $orderKey, string $apiKey, string $idempotencyKey ): array { // Note: path uses capital O ("/Orders/", case-sensitive) $url = BIPBIP_API_BASE . '/api/v1/Orders/' . rawurlencode($orderKey) . '/accept'; $payload = '{}'; // Empty body in v1.0 $curl = curl_init($url); curl_setopt_array($curl, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', "X-Bipbip-Api-Key: {$apiKey}", 'X-Bipbip-Schema-Version: 1.0', "Idempotency-Key: {$idempotencyKey}", // Required on all mutations — enables safe retry ], ]); $body = curl_exec($curl); $status = curl_getinfo($curl, CURLINFO_HTTP_CODE); curl_close($curl); if ($status < 200 || $status >= 300) { throw new RuntimeException("Accept failed: HTTP {$status} — {$body}"); } return json_decode($body, true); } // ── Usage example ────────────────────────────────────────────────────────────── // Inside the webhook handler (after HMAC verification): // // $apiKey = getenv('BIPBIP_API_KEY'); // $rawBody = file_get_contents('php://input'); // $order = json_decode($rawBody, true); // // // Step 1: Create the order in the POS system and obtain the internal ID // $remoteOrderId = pos()->createOrder($order); // // // Step 2: Accept the order on BipBip (use a stable UUID per attempt). // // Skip this if the store has auto-accept enabled — BipBip runs the cascade itself. // $idempotencyKey = bin2hex(random_bytes(16)); // UUID-style unique key // acceptBipBipOrder($order['orderKey'], $apiKey, $idempotencyKey); // // // Step 3: Respond 200 with remoteOrderId so BipBip can link the POS ID to the order // header('Content-Type: application/json'); // echo json_encode(['remoteOrderId' => $remoteOrderId]);Rate limits de la REST API
El límite es 100 requests por minuto (ventana fija) y1,000 por hora (ventana deslizante), por API Key. Al alcanzar el límite, BipBip devuelve HTTP 429 — la solución es exponential backoff con jitter.
Sandbox — próximo
Ambiente de pruebas: próximo
Seguridad
Verificación HMAC
Cada webhook que BipBip envía incluye una firma HMAC-SHA256 en el headerX-Bipbip-Signature-256. Verificar esta firma es obligatorio — sin ella, cualquier actor malicioso puede enviar órdenes falsas al endpoint.
El algoritmo
BipBip firma cada request usando la siguiente fórmula:
message = "{timestamp}.{rawBody}"
keyBytes = UTF-8 bytes del HMAC secret
signature = "sha256=" + LOWERCASE(HEX(HMAC-SHA256(keyBytes, UTF8(message))))Los headers relevantes en cada request son:
X-Bipbip-Timestamp— Unix timestamp en segundos (número entero como string)X-Bipbip-Signature-256— la firma en formatosha256=<hex>X-Bipbip-Delivery-Id— UUID único por envío (usar para deduplicación)X-Bipbip-Event-Type— soloorder.createden el creation webhook. Las novedades posteriores (cancelled,driver_assigned,driver_released,delivered) van porPUT /v1/order/{remoteId}/{remoteOrderId}/eventsy se discriminan porbody.event(ese endpoint no declara este header). Ver el spec del endpoint —driver_assignedpuede llegar más de una vez por orden.X-Bipbip-Schema-Version— versión del schema del payload. Actualmente1.0.
Clock skew: rechazar requests más viejos de 5 minutos
X-Bipbip-Timestampy el reloj local no debe superar 300 segundos (5 minutos). Así se bloquean los ataques de replay — un request válido capturado y reenviado horas después se rechaza sin que llegue a ejecutarse.Errores comunes (footguns)
Estos tres errores son la causa del 90% de los casos donde la firma no verifica. Conviene revisarlos antes de buscar otro problema.
Footgun 1: firmar el JSON re-serializado en vez del raw body
El error más frecuente: parsear el body con JSON.parse()primero y después firmar el objeto re-serializado. Cualquier diferencia de whitespace, orden de keys o precisión numérica produce una firma diferente a la de BipBip.
Solución: captura los bytes crudos del body antes de llamar a cualquier función de JSON parsing. Verifica la firma. El parsing va después.
Footgun 2: comparación de strings sin timing-safe equality
Comparar la firma calculada con la recibida usando ===,== ostrcmp()introduce una vulnerabilidad de timing oracle: un atacante puede medir el tiempo de respuesta para deducir caracteres de la firma válida de a uno.
Solución: usa siempre una función de comparación en tiempo constante:crypto.timingSafeEqual() en Node.js,hmac.compare_digest() en Python,CryptographicOperations.FixedTimeEquals() en C#,hash_equals() en PHP.
Footgun 3: generar el timestamp localmente en vez de leer el header
La firma incluye el timestamp que BipBip escribió en X-Bipbip-Timestamp. El uso de Date.now(),time() oDateTime.UtcNowpara construir el mensaje produce un timestamp diferente al de BipBip y la firma nunca verifica.
Solución: lee siempre el timestamp del header X-Bipbip-Timestamp. Generarlo localmente produce un desajuste.
Code samples
Selección por lenguaje. Todos los samples usan solo la librería estándar — sin dependencias externas.
// HMAC-SHA256 webhook signature verification — Node.js
// Verify that the webhook payload from BipBip is authentic before processing it.
// Requires Node.js 18+ (native fetch not needed here; only built-in crypto module).
const crypto = require('crypto');
/**
* Verifies the HMAC-SHA256 signature of an incoming BipBip webhook.
*
* @param {string} secret - HMAC secret provided by BipBip during onboarding
* @param {string} timestamp - Value of the X-Bipbip-Timestamp header (Unix seconds as string)
* @param {Buffer|string} rawBody - Raw request body bytes BEFORE any JSON.parse() call
* @param {string} signature - Value of the X-Bipbip-Signature-256 header (e.g. "sha256=abc123...")
* @returns {boolean} - true if the signature is valid and the timestamp is within skew limit
*/
function verifyBipBipSignature(secret, timestamp, rawBody, signature) {
// Step 1: Validate timestamp to prevent replay attacks.
// Reject requests where the clock skew exceeds 300 seconds (5 minutes).
const now = Math.floor(Date.now() / 1000);
const ts = parseInt(timestamp, 10);
if (Math.abs(now - ts) > 300) {
return false;
}
// Step 2: Build the signed message exactly as BipBip does:
// message = "{timestamp}.{rawBody}"
// IMPORTANT: rawBody must be the original bytes received over the wire.
// Do NOT re-serialize a parsed JSON object — any whitespace/key-order
// difference will produce a different signature.
const message = `${timestamp}.${rawBody}`;
// Step 3: Compute HMAC-SHA256 with the shared secret.
// Both key and message are treated as UTF-8.
// The digest is lowercased hex (BipBip never uses base64).
const computed = crypto
.createHmac('sha256', secret)
.update(message, 'utf8')
.digest('hex');
// Step 4: Prepend the "sha256=" prefix to match the header value format.
const expected = `sha256=${computed}`;
// Step 5: Use a timing-safe comparison to prevent timing-oracle attacks.
// crypto.timingSafeEqual requires two Buffers of equal length.
const expectedBuf = Buffer.from(expected, 'utf8');
const receivedBuf = Buffer.from(signature, 'utf8');
if (expectedBuf.length !== receivedBuf.length) {
return false;
}
return crypto.timingSafeEqual(expectedBuf, receivedBuf);
}
// ── Express.js integration example ──────────────────────────────────────────
// Inside an Express app, use express.raw() (not express.json()) so that the
// raw body bytes are available for signature verification.
//
// app.use('/v1/order/:remoteId', express.raw({ type: '*/*' }), (req, res) => {
// const secret = process.env.BIPBIP_HMAC_SECRET;
// const timestamp = req.headers['x-bipbip-timestamp'];
// const signature = req.headers['x-bipbip-signature-256'];
// const rawBody = req.body; // Buffer when using express.raw()
//
// if (!verifyBipBipSignature(secret, timestamp, rawBody, signature)) {
// return res.status(401).json({ error: 'Invalid signature' });
// }
//
// const order = JSON.parse(rawBody.toString('utf8'));
// const remoteOrderId = generateInternalOrderId(order);
// res.status(200).json({ remoteOrderId });
// });
module.exports = { verifyBipBipSignature };"""
HMAC-SHA256 webhook signature verification — Python 3.8+
Verify that the webhook payload from BipBip is authentic before processing it.
Uses only Python standard library (hmac, hashlib, time). No pip packages required.
"""
import hashlib
import hmac
import time
def verify_bipbip_signature(
secret: str,
timestamp: str,
raw_body: bytes,
signature: str,
max_skew_seconds: int = 300,
) -> bool:
"""
Verifies the HMAC-SHA256 signature of an incoming BipBip webhook.
Args:
secret: HMAC secret provided by BipBip during onboarding.
timestamp: Value of the X-Bipbip-Timestamp header (Unix seconds as string).
raw_body: Raw request body bytes BEFORE any json.loads() call.
signature: Value of the X-Bipbip-Signature-256 header (e.g. "sha256=abc123...").
max_skew_seconds: Maximum allowed clock skew in seconds (default: 300).
Returns:
True if the signature is valid and the timestamp is within the skew limit.
"""
# Step 1: Validate timestamp to prevent replay attacks.
# Reject requests where the clock skew exceeds max_skew_seconds.
now = int(time.time())
try:
ts = int(timestamp)
except (ValueError, TypeError):
return False
if abs(now - ts) > max_skew_seconds:
return False
# Step 2: Build the signed message exactly as BipBip does:
# message = "{timestamp}.{rawBody}"
# IMPORTANT: raw_body must be the original bytes received over the wire.
# Do NOT re-serialize a parsed JSON object — any whitespace/key-order
# difference will produce a different signature.
message = f"{timestamp}.{raw_body.decode('utf-8')}".encode('utf-8')
# Step 3: Compute HMAC-SHA256 with the shared secret.
# Both key and message are treated as UTF-8 encoded bytes.
computed_hex = hmac.new(
secret.encode('utf-8'),
message,
hashlib.sha256,
).hexdigest() # hexdigest() returns lowercase hex — matches BipBip's format
# Step 4: Prepend the "sha256=" prefix to match the header value format.
expected = f"sha256={computed_hex}"
# Step 5: Use a timing-safe comparison to prevent timing-oracle attacks.
# hmac.compare_digest() is constant-time and accepts str or bytes.
return hmac.compare_digest(expected, signature)
# ── Flask integration example ────────────────────────────────────────────────
# Inside a Flask app, the raw body is accessed via request.get_data() (not request.json)
# so that the bytes are available for signature verification before deserialization.
#
# @app.route('/v1/order/<remote_id>', methods=['POST'])
# def receive_order(remote_id):
# secret = os.environ['BIPBIP_HMAC_SECRET']
# timestamp = request.headers.get('X-Bipbip-Timestamp', '')
# signature = request.headers.get('X-Bipbip-Signature-256', '')
# raw_body = request.get_data() # bytes — no JSON parsing yet
#
# if not verify_bipbip_signature(secret, timestamp, raw_body, signature):
# return jsonify({'error': 'Invalid signature'}), 401
#
# order = request.json # safe to parse after verification
# remote_order_id = pos_internal_id_generator(order)
# return jsonify({'remoteOrderId': remote_order_id}), 200// HMAC-SHA256 webhook signature verification — C# / .NET 6+
// Verify that the webhook payload from BipBip is authentic before processing it.
// Uses only System.Security.Cryptography (built into .NET). No NuGet packages required.
using System;
using System.Security.Cryptography;
using System.Text;
/// <summary>
/// Utilities for verifying BipBip webhook signatures.
/// </summary>
public static class BipBipWebhookVerifier
{
/// <summary>
/// Verifies the HMAC-SHA256 signature of an incoming BipBip webhook.
/// </summary>
/// <param name="secret">HMAC secret provided by BipBip during onboarding.</param>
/// <param name="timestamp">Value of the X-Bipbip-Timestamp header (Unix seconds as string).</param>
/// <param name="rawBody">Raw request body string BEFORE any deserialization.</param>
/// <param name="signature">Value of the X-Bipbip-Signature-256 header (e.g. "sha256=abc123...").</param>
/// <param name="maxSkewSeconds">Maximum allowed clock skew in seconds (default: 300).</param>
/// <returns>True if the signature is valid and the timestamp is within the skew limit.</returns>
public static bool VerifySignature(
string secret,
string timestamp,
string rawBody,
string signature,
int maxSkewSeconds = 300)
{
// Step 1: Validate timestamp to prevent replay attacks.
// Reject requests where the clock skew exceeds maxSkewSeconds.
if (!long.TryParse(timestamp, out long ts))
return false;
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (Math.Abs(now - ts) > maxSkewSeconds)
return false;
// Step 2: Build the signed message exactly as BipBip does:
// message = "{timestamp}.{rawBody}"
// IMPORTANT: rawBody must be the original string received over the wire.
// Do NOT re-serialize a deserialized object — any whitespace/key-order
// difference will produce a different signature.
string message = $"{timestamp}.{rawBody}";
// Step 3: Compute HMAC-SHA256 with the shared secret.
// Both key and message are UTF-8 encoded bytes.
byte[] keyBytes = Encoding.UTF8.GetBytes(secret);
byte[] messageBytes = Encoding.UTF8.GetBytes(message);
using var hmac = new HMACSHA256(keyBytes);
byte[] hashBytes = hmac.ComputeHash(messageBytes);
// Step 4: Convert hash to lowercase hex and prepend the "sha256=" prefix.
string computedHex = BitConverter.ToString(hashBytes)
.Replace("-", string.Empty)
.ToLowerInvariant();
string expected = $"sha256={computedHex}";
// Step 5: Use a timing-safe comparison to prevent timing-oracle attacks.
// CryptographicOperations.FixedTimeEquals() compares byte arrays in constant time.
byte[] expectedBytes = Encoding.UTF8.GetBytes(expected);
byte[] receivedBytes = Encoding.UTF8.GetBytes(signature);
return CryptographicOperations.FixedTimeEquals(expectedBytes, receivedBytes);
}
}
// ── ASP.NET Core integration example ────────────────────────────────────────
// Read the raw body string from the request stream BEFORE binding to a model.
// Use [FromBody] with a string or read Request.Body manually.
//
// [HttpPost("/v1/order/{remoteId}")]
// public async Task<IActionResult> ReceiveOrder(
// string remoteId,
// [FromHeader(Name = "X-Bipbip-Timestamp")] string timestamp,
// [FromHeader(Name = "X-Bipbip-Signature-256")] string signature)
// {
// string secret = _config["BipBip:HmacSecret"]!;
// string rawBody = await new StreamReader(Request.Body).ReadToEndAsync();
//
// if (!BipBipWebhookVerifier.VerifySignature(secret, timestamp, rawBody, signature))
// return Unauthorized();
//
// var order = JsonSerializer.Deserialize<OrderPayload>(rawBody);
// string remoteOrderId = _orderService.CreateOrder(order!);
// return Ok(new { remoteOrderId });
// }
//
// NOTE: Enabling raw body reading in ASP.NET Core may require calling
// Request.EnableBuffering() in a middleware before the controller executes.<?php
/**
* HMAC-SHA256 webhook signature verification — PHP 8.0+
* Verify that the webhook payload from BipBip is authentic before processing it.
* Uses only PHP built-in functions (hash_hmac, hash_equals). No Composer packages required.
*/
/**
* Verifies the HMAC-SHA256 signature of an incoming BipBip webhook.
*
* @param string $secret HMAC secret provided by BipBip during onboarding.
* @param string $timestamp Value of the X-Bipbip-Timestamp header (Unix seconds as string).
* @param string $rawBody Raw request body string BEFORE any json_decode() call.
* @param string $signature Value of the X-Bipbip-Signature-256 header (e.g. "sha256=abc123...").
* @param int $maxSkewSeconds Maximum allowed clock skew in seconds (default: 300).
* @return bool True if the signature is valid and the timestamp is within the skew limit.
*/
function verifyBipBipSignature(
string $secret,
string $timestamp,
string $rawBody,
string $signature,
int $maxSkewSeconds = 300
): bool {
// Step 1: Validate timestamp to prevent replay attacks.
// Reject requests where the clock skew exceeds $maxSkewSeconds.
$now = time();
$ts = (int) $timestamp;
if (abs($now - $ts) > $maxSkewSeconds) {
return false;
}
// Step 2: Build the signed message exactly as BipBip does:
// message = "{timestamp}.{rawBody}"
// IMPORTANT: $rawBody must be the original string received over the wire.
// Do NOT re-serialize a json_decode result — any whitespace/key-order
// difference will produce a different signature.
$message = "{$timestamp}.{$rawBody}";
// Step 3: Compute HMAC-SHA256 with the shared secret.
// hash_hmac() returns a lowercase hex string by default — matches BipBip's format.
$computedHex = hash_hmac('sha256', $message, $secret);
// Step 4: Prepend the "sha256=" prefix to match the header value format.
$expected = "sha256={$computedHex}";
// Step 5: Use a timing-safe comparison to prevent timing-oracle attacks.
// hash_equals() is constant-time and is the recommended PHP function for this purpose.
return hash_equals($expected, $signature);
}
// ── PHP / Laravel / Slim integration example ─────────────────────────────────
// Read the raw input BEFORE calling json_decode so that the original bytes
// are available for signature verification.
//
// // Plain PHP (no framework):
// $secret = getenv('BIPBIP_HMAC_SECRET');
// $timestamp = $_SERVER['HTTP_X_BIPBIP_TIMESTAMP'] ?? '';
// $signature = $_SERVER['HTTP_X_BIPBIP_SIGNATURE_256'] ?? '';
// $rawBody = file_get_contents('php://input'); // raw bytes — no JSON parsing yet
//
// if (!verifyBipBipSignature($secret, $timestamp, $rawBody, $signature)) {
// http_response_code(401);
// echo json_encode(['error' => 'Invalid signature']);
// exit;
// }
//
// $order = json_decode($rawBody, true); // safe to parse after verification
// $remoteOrderId = generateYourInternalOrderId($order);
// echo json_encode(['remoteOrderId' => $remoteOrderId]);Soporte
Troubleshooting
Las cuatro preguntas más frecuentes durante la integración. Si la respuesta no aparece acá, el contacto es [email protected].
Mi firma no verifica
Síntoma: el código del POS computa el HMAC pero la firma calculada nunca coincide con X-Bipbip-Signature-256.
Causa más probable: la firma se aplica sobre el JSON re-serializado en vez del raw body tal como llegó por el wire.
Cómo arreglarlo:
- Verifica que capturas los bytes crudos del body antes de cualquier llamada a
JSON.parse(),json_decode()o equivalente. - Confirma que el mensaje firmado sea exactamente
"{timestamp}.{rawBody}"— el timestamp viene del header, no de tu reloj local. - Confirma que usas comparación en tiempo constante (ver Errores comunes).
- Si persiste, loguea el mensaje exacto que firmas y compáralo byte a byte.
Recibo el webhook dos veces
Síntoma: el POS crea la misma orden dos veces o recibe dos webhooks para el mismo evento.
Causa: BipBip entrega webhooks con garantía at-least-once. Si el endpoint del POS tarda demasiado en responder o hay un error de red, BipBip reintenta el envío. Es comportamiento esperado, no un bug.
Cómo arreglarlo: implementa deduplicación con el header X-Bipbip-Delivery-Id. Este header es un UUID único por lote de intentos — si ya procesaste ese ID, responde HTTP 200 inmediato sin reprocesar.
// Example: deduplicación con X-Bipbip-Delivery-Id
const processed = new Set();
app.post('/v1/order/:remoteId', async (req, res) => {
const deliveryId = req.headers['x-bipbip-delivery-id'];
if (processed.has(deliveryId)) {
return res.status(200).json({ remoteOrderId: yourStore.getByDeliveryId(deliveryId) });
}
// ... verificar HMAC, procesar orden ...
processed.add(deliveryId);
res.status(200).json({ remoteOrderId });
});BipBip sigue reintentando después del 200
Síntoma: el endpoint devuelve HTTP 200 pero BipBip sigue enviando el mismo webhook.
Causa: devolver HTTP 200 sin un remoteOrderId válido se trata como delivery fallido. BipBip necesita ese valor para componer la URL de los webhooks de cancelación futuros.
Cómo arreglarlo: asegúrate de que tu response body sea un JSON válido con remoteOrderId no nulo y no vacío:
// Correcto — BipBip marca el delivery como exitoso
{ "remoteOrderId": "POS-INTERNAL-12345" }
// Incorrecto — BipBip trata esto como delivery fallido y reintenta
{}
{ "remoteOrderId": null }
{ "remoteOrderId": "" }Agotados los reintentos del creation
order.created sin éxito, la orden se cancela internamente (notifica al cliente) y nunca llega al POS. No se envía un PUT /status de fallo separado — BipBip no expone ese evento al POS.No recibo ningún webhook
Síntoma: BipBip confirma que despachó el webhook pero el servidor del POS no recibió nada.
Checklist:
- URL públicamente accesible: la base URL registrada debe ser accesible desde internet. La prueba se hace con
curl -X POST https://servidor.com/v1/order/testdesde una red externa. Las URLs localhost o VPN privada no funcionan sin tunelado (ej: ngrok). - Firewall: permitir tráfico HTTPS entrante (TCP/443) sin restricción por IP. BipBip no publica un IP allowlist fijo. Si la política de seguridad exige allowlist estático o ruta privada (ej: AWS PrivateLink), el contacto es [email protected] para coordinar config custom.
- Inspección de headers: si el request llega pero no se procesa, loguear todos los headers de entrada. Verificar que
X-Bipbip-Signature-256yX-Bipbip-Timestampestén presentes. - Estado en BackOffice: solicitar al equipo BipBip la verificación del estado del delivery (
Pending,DeliveredoFailed).
Referencia
Glosario
Seis términos que aparecen en todo el contrato de integración. Sirven como referencia cuando elWebhook Spec o laREST API mencionen un término poco familiar.
- orderKey
- Identificador opaco público de BipBip para una orden. Formato: prefijo
ord_seguido de 16 caracteres base62 (ej:ord_4xK9mZqPwRtN2aLb). - Uso: único identificador de orden que BipBip expone externamente. Úsalo como segmento de URL en los endpoints REST (
/api/v1/Orders/{orderKey}/accept). No expongas este identificador en tu UI interna — para la correlación interna usa elremoteOrderIddel POS. - remoteOrderId
- El identificador de orden del propio POS. Se devuelve en el body del ACK al webhook de creación (HTTP 200) y BipBip lo persiste. Campo obligatorio.
- Uso: BipBip lo incluye en la URL de todos los webhooks de novedades subsiguientes (
/v1/order/{remoteId}/{remoteOrderId}/events) para permitir la correlación sin buscar pororderKey. - remoteId
- Identificador de la tienda definido por el comercio (ej:
POS_TGU_001,plaza-pedregal-42). Se configura una vez durante el onboarding, uno por tienda registrada. - Uso: aparece como
{remoteId}en el path de los webhooks entrantes. Permite distinguir desde qué tienda proviene cada orden cuando se operan múltiples tiendas con el mismo endpoint base. - Idempotency-Key
- Header que el POS envía al llamar los endpoints de mutación de la REST API (
/accept,/reject,/status). Valor: cualquier string único por intento lógico (recomendado: UUID v4). - Uso: cuando se envía la misma Idempotency-Key con el mismo body dentro de las 24 horas, BipBip devuelve la respuesta en caché sin reejecutar la mutación — permite reintentos seguros sin efectos dobles. La misma key con un body diferente devuelve HTTP 422 con un
typeterminado en/idempotency-conflict. - X-Bipbip-Delivery-Id
- Header que BipBip envía en cada webhook dispatch. Valor: UUID único por lote de intentos de entrega de un mismo evento.
- Uso: clave de deduplicación del lado del comercio. Cuando llega el mismo
X-Bipbip-Delivery-Iddos veces (reintento), el evento ya fue procesado — la respuesta es 200 sin reejecutar la lógica de negocio. Es distinto delorderKey: pueden existir múltiples delivery IDs para la misma orden si hubo reintentos. - Estados de orden (desde el POS)
- Los siete estados que puede tener una orden en el sistema BipBip, con la decisión o acción del comercio en cada transición:
Estado Significado Acción del comercio pending Orden recibida, esperando respuesta del POS Responder con remoteOrderId, luego/accepto/rejectaccepted Estado intermedio que se ejecuta automáticamente dentro de /accept. No observable entre llamadas — la orden pasa dependingapreparingen una sola operaciónSin acción adicional. El timestamp acceptedAtqueda registrado y disponible víaGET /Orders/{key}rejected Comercio rechazó la orden (terminal) Sin acciones adicionales preparing Orden en preparación Llamar PUT /statusconreadyready Lista para entrega al driver Llamar PUT /statusconhanded_overcuando el driver retiradriver_assigned Driver asignado — repartidor en camino al comercio Llega PUT /statusconstatus: driver_assignedy objetodriver(fullName,phone?). Informativo — respuesta 200handed_over Entregada al driver (terminal del lado del comercio) Sin acciones adicionales — siguiente estado: deliveredcancelled Cancelada (terminal). El comercio NO la dispara — la cancelación posterior a la aceptación se gestiona vía soporte Llega PUT /statusconstatus: cancelled+reasonopcional — el POS actualiza el estadodelivered Entregada al cliente (terminal) Llega PUT /statusconstatus: delivered— el POS marca la orden como entregada y responde 200- Casing unificado en v1.0: tanto el webhook como la REST API usan
snake_caselowercase (pending,accepted,handed_over,driver_assigned…). El webhook lo lleva enbody.eventdel envelopeOrderUpdateEvent— dondeeventes lo que ocurrió, no el estado del recurso. La REST API lo recibe en el body dePUT /api/v1/Orders/{orderKey}/status(aceptapreparing | ready | handed_over;preparingqueda en el enum por forward-compat pero produce 409 porque/acceptya dejó la orden ahí).