First steps
Quickstart
This guide covers the path from "credentials in hand" to "first order received and confirmed" without any intervention from the BipBip team. The four steps follow the order shown.
Sandbox in progress — direct integration against production
Prerequisites
Before getting started, the BipBip team provides the following configuration items. All are required before writing the first line of code:
- 1HMAC Secret — shared key for verifying webhook authenticity (one per account)
- 2API Key (
X-Bipbip-Api-Key) — authentication header for calling the REST API - 3remoteId — store identifier defined by the merchant (e.g.
POS_TGU_001). One per registered store. - 4Registered base URL — the base URL of the POS server where BipBip will send webhooks (must be publicly accessible)
The 4 steps
- 1
Implementing the webhook endpoint
BipBip sends
POST {baseUrl}/v1/order/{remoteId}each time a new order arrives for the store. The POS endpoint must:- Capture the raw body before parsing the JSON
- Verify the HMAC signature (see HMAC Verification)
- Return HTTP 200 with a JSON body that includes
remoteOrderId - Respond within 30 seconds (BipBip treats any slow response as a failure)
remoteOrderId is required
Returning HTTP 200 without a validremoteOrderIdin the body is treated as a failed delivery and BipBip will retry. SeeBipBip keeps retrying. - 2
Verifying the HMAC signature
Every request from BipBip includes the header
X-Bipbip-Signature-256with an HMAC-SHA256 signature. Verifying the signature guarantees that the request originates from BipBip and was not modified in transit. The HMAC Verification section contains code samples. - 3
Responding with the POS remoteOrderId
Once the signature is verified and the order is created in the POS, the HTTP 200 response includes:
{ "remoteOrderId": "POS-2026-04-11-00142" }This value is the POS internal identifier for this order. BipBip stores it and includes it in all subsequent cancellation webhooks to enable correlation.
- 4
Accepting the order via the REST API
After receiving the webhook and responding with 200, formal order acceptance happens via
POST /api/v1/Orders/{orderKey}/accept. That call executes an atomic cascade that transitions the order frompending → preparing(passing internally throughaccepted) in a single operation. The response returnsstatus: "preparing"directly — do NOT callPUT /statuswithpreparingafterwards; the next merchant action is to mark the orderreadywhen it is.This step is optional if the store has auto-accept enabled — in that case BipBip runs the same cascade automatically upon receiving the 200, and the order arrives directly at
preparingwithout the merchant invoking/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]);REST API rate limits
The API is limited to 100 requests per minute (fixed window) and1,000 requests per hour (sliding window), partitioned by API Key. When the limit is exceeded BipBip returns HTTP 429 — the solution is exponential backoff with jitter in the client.
Sandbox — coming soon
Test environment: coming soon
Security
HMAC Verification
Every webhook BipBip sends includes an HMAC-SHA256 signature in the headerX-Bipbip-Signature-256. Verifying this signature is mandatory — without it, any malicious actor can send fake orders to the endpoint.
The algorithm
BipBip signs each request using the following formula:
message = "{timestamp}.{rawBody}"
keyBytes = UTF-8 bytes of the HMAC secret
signature = "sha256=" + LOWERCASE(HEX(HMAC-SHA256(keyBytes, UTF8(message))))The relevant headers in each request are:
X-Bipbip-Timestamp— Unix timestamp in seconds (integer as string)X-Bipbip-Signature-256— the signature insha256=<hex>formatX-Bipbip-Delivery-Id— unique UUID per delivery batch (use for deduplication)X-Bipbip-Event-Type— onlyorder.createdon the creation webhook. Subsequent updates (cancelled,driver_assigned,driver_released,delivered) go toPUT /v1/order/{remoteId}/{remoteOrderId}/eventsand are discriminated bybody.event(that endpoint does not declare this header). See the endpoint spec —driver_assignedcan arrive more than once per order.
Recommended clock skew: maximum 300 seconds
X-Bipbip-Timestampand the local server clock should not exceed 300 seconds (5 minutes). This protects against replay attacks where a valid request is captured and resent hours later.Common pitfalls (footguns)
These three mistakes account for 90% of cases where the signature does not verify. Review them before looking for another problem.
Footgun 1: signing the re-serialized JSON instead of the raw body
The most frequent mistake: parsing the body with JSON.parse()first and then signing the re-serialized object. Any difference in whitespace, key ordering, or numeric precision produces a different signature than BipBip's.
Solution: capture the raw bytes of the body before calling any JSON parsing function. Verify the signature. Only then parse.
Footgun 2: string comparison without timing-safe equality
Comparing the computed signature with the received one using ===,== orstrcmp()introduces a timing oracle vulnerability: an attacker can measure response time to deduce characters of the valid signature one by one.
Solution: always use a constant-time comparison function:crypto.timingSafeEqual() in Node.js,hmac.compare_digest() in Python,CryptographicOperations.FixedTimeEquals() in C#,hash_equals() in PHP.
Footgun 3: generating the timestamp locally instead of reading the header
The signature includes the timestamp that BipBip wrote in X-Bipbip-Timestamp. Using Date.now(),time() orDateTime.UtcNowto build the message produces a timestamp that differs from BipBip's, and the signature never verifies.
Solution: always read the timestamp from the X-Bipbip-Timestamp header. Generating it locally produces a mismatch.
Code samples
Selection by language. All samples use only the standard library — no external dependencies required.
// 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]);Support
Troubleshooting
The four most frequently asked questions during integration. If the answer is not here, the contact is [email protected].
Signature does not verify
Symptom: the POS code computes the HMAC but the calculated signature never matches X-Bipbip-Signature-256.
Most likely cause: the signature is being applied to the re-serialized JSON instead of the raw body as it arrived over the wire.
How to fix:
- Verify that the raw bytes of the body are captured before any call to
JSON.parse(),json_decode()or equivalent. - Confirm that the signed message is exactly
"{timestamp}.{rawBody}"— the timestamp comes from the header, not the local clock. - Confirm the use of constant-time comparison (see Common pitfalls).
- If the problem persists, log the exact message being signed and compare it byte by byte.
Webhook arrives twice
Symptom: the POS creates the same order twice, or receives two webhooks for the same event.
Cause: BipBip delivers webhooks with at-least-once guarantee. If the POS endpoint takes too long to respond or a network error occurs, BipBip retries the delivery. This is expected behavior, not a bug.
How to fix: implement deduplication using the X-Bipbip-Delivery-Id header. This header is a unique UUID per delivery batch — when the ID has already been processed, the response is HTTP 200 immediately without reprocessing.
// 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 keeps retrying after the 200
Symptom: the endpoint returns HTTP 200 but BipBip keeps sending the same webhook.
Cause: returning HTTP 200 without a valid remoteOrderId is treated as a failed delivery. BipBip needs that value to compose the URL for future cancellation webhooks.
How to fix: ensure the response body is valid JSON with a non-null, non-empty remoteOrderId:
// Correcto — BipBip marca el delivery como exitoso
{ "remoteOrderId": "POS-INTERNAL-12345" }
// Incorrecto — BipBip trata esto como delivery fallido y reintenta
{}
{ "remoteOrderId": null }
{ "remoteOrderId": "" }Retries exhausted
cancelled event is sent to the endpoint.No webhooks arriving
Symptom: BipBip confirms it dispatched the webhook but the POS server received nothing.
Checklist:
- Publicly accessible URL: the registered base URL must be reachable from the internet. The test runs with
curl -X POST https://server.com/v1/order/testfrom an external network. localhost or private VPN URLs do not work without tunneling (e.g. ngrok). - Firewall and allowlist: when the POS server has inbound firewall rules, HTTPS traffic from BipBip's IP ranges must be allowed (the exact range is provided by the team).
- Header inspection: when the request arrives but is not processed, log all incoming headers. Verify that
X-Bipbip-Signature-256andX-Bipbip-Timestampare present. - BackOffice status: the BipBip team verifies whether the delivery is
Pending,DeliveredorFailed.
Reference
Glossary
Six terms that appear throughout the integration contract. They serve as reference when theWebhook Spec or theREST API mention an unfamiliar term.
- orderKey
- BipBip's public opaque identifier for an order. Format:
ord_prefix followed by 16 base62 characters (e.g.ord_4xK9mZqPwRtN2aLb). - Usage: the only order identifier BipBip exposes externally. Used as a URL segment in REST endpoints (
/api/v1/Orders/{orderKey}/accept). It must not be used internally — internal correlation uses theremoteOrderIdfrom the POS. - remoteOrderId
- The POS internal order identifier. Returned in the body of the ACK to the creation webhook (HTTP 200) and persisted by BipBip. Required field.
- Usage: BipBip includes it in the URL of all subsequent update webhooks (
/v1/order/{remoteId}/{remoteOrderId}/events) to enable correlation without looking up byorderKey. - remoteId
- Store identifier defined by the merchant (e.g.
POS_TGU_001,plaza-pedregal-42). Configured once during onboarding, one per registered store. - Usage: appears as
{remoteId}in the path of incoming webhooks. Allows distinguishing which store each order comes from when operating multiple stores with the same base endpoint. - Idempotency-Key
- Header sent by the POS when calling mutation endpoints on the REST API (
/accept,/reject,/status). Value: any string unique per logical attempt (recommended: UUID v4). - Usage: when the same Idempotency-Key is sent with the same body within 24 hours, BipBip returns the cached response without re-executing the mutation — enables safe retries without duplicate side effects. The same key with a different body returns HTTP 422 with a
typeending in/idempotency-conflict. - X-Bipbip-Delivery-Id
- Header BipBip sends in each webhook dispatch. Value: unique UUID per delivery batch for a given event.
- Usage: deduplication key on the merchant side. When the same
X-Bipbip-Delivery-Idarrives twice (retry), the event has already been processed — the response is 200 without re-executing business logic. It differs fromorderKey: multiple delivery IDs can exist for the same order if retries occurred. - Order states (from the POS)
- The seven states an order can have in the BipBip system, with the merchant's decision or action at each transition:
State Meaning Merchant action pending Order received, awaiting POS response Respond with remoteOrderId, then call/acceptor/rejectaccepted Internal state of the /acceptcascade. Not observable between calls — the order moves atomically frompendingtopreparingNo additional action. The acceptedAttimestamp is recorded and available viaGET /Orders/{key}rejected Merchant rejected the order (terminal) No further actions preparing Order being prepared Call PUT /statuswithreadyready Ready for driver pickup Call PUT /statuswithhanded_overwhen the driver picks updriver_assigned Driver assigned — courier en route to the merchant Incoming PUT /statuswithstatus: driver_assignedand adriverobject (fullName,phone?). Informational — respond 200handed_over Handed over to the driver (terminal from the merchant's perspective) No further actions — next state: deliveredcancelled Cancelled (terminal). The merchant does NOT trigger this — post-acceptance cancellations are handled via support Incoming PUT /statuswithstatus: cancelled+ optionalreason— the POS updates the statedelivered Delivered to the customer (terminal) Incoming PUT /statuswithstatus: delivered— the POS marks the order as delivered and responds 200- Unified casing in v1.0: both the webhook and the REST API use
snake_caselowercase (pending,accepted,handed_over,driver_assigned…). The webhook carries it inbody.eventof theOrderUpdateEventenvelope — whereeventis what happened, not the state of the resource. The REST API receives it in the body ofPUT /api/v1/Orders/{orderKey}/status(acceptspreparing | ready | handed_over;preparingstays in the enum for forward-compat but returns 409 because the/acceptcascade already left the order there).