Testing Locally

Test your Voicebip integration locally without real phone calls or SMS charges.

Sandbox Mode

Use a pk_test_ API key to enter sandbox mode. In sandbox mode:

  • All agent and number CRUD operations work identically to production
  • Calls and messages simulate the full lifecycle (webhook events fire normally)
  • Webhook signatures use real HMAC-SHA256 — your verification code works unchanged
  • No real SIP/RTP calls are placed, no real SMPP messages are sent
  • Billing is NGN 0 — no charges accrue in sandbox mode

Switch to production by replacing pk_test_ with pk_live_ in your Authorization header. No other code changes needed.

Test Numbers

Sandbox mode provisions numbers from reserved test pools:

Number RangeTypeDescription
+234800000xxxxMobile virtualSandbox mobile virtual numbers for voice and SMS testing
+234100000xxxxLagos DIDSandbox Lagos geographic DID numbers

These numbers are free to provision in sandbox and behave identically to production numbers, except no real calls or messages are routed.

Test Webhook Endpoint

Use POST /v1/webhooks/test to send a synthetic webhook event to your configured URL. This is the fastest way to verify your webhook handler works correctly.

curl -X POST "https://api.voicebip.com/v1/webhooks/test" \
-H "Authorization: Bearer pk_test_your_key" \
-H "Content-Type: application/json" \
-d '{
"webhook_url": "https://your-ngrok-url.ngrok-free.app/webhook",
"event_type": "call.completed"
}'
{
"success": true,
"response_code": 200,
"response_body": "{\"status\":\"ok\"}"
}

The response reports how your endpoint replied: success (true when your endpoint returned a 2xx), the response_code it sent, and the response_body it returned (truncated).

The test endpoint delivers a realistic Nigerian mock payload with valid +234 E.164 numbers, conversation history, and a valid HMAC-SHA256 signature. event_type defaults to call.completed; other accepted values are call.initiated, call.transcription, call.barge_in, call.idle_silence, call.quality, call.quality_degraded, message.received, message.sent, message.delivered, and message.failed.

Mock Webhook Payloads

Below are the exact payloads Voicebip sends when you use POST /v1/webhooks/test. These match the production payload format — only the phone numbers are from the sandbox pool.

call.initiated

Fired when a call begins ringing.

{
"event_id": "evt_test_a1b2c3d4",
"event_type": "call.initiated",
"channel": "voice",
"agent_id": "agt_k7m2n9p4q1",
"number": "+2348000001234",
"from": "+2348031234567",
"timestamp": "2026-04-09T14:30:00Z",
"payload": {
"call_id": "call_x9y8z7w6",
"direction": "inbound",
"from": "+2348031234567",
"to": "+2348000001234",
"status": "ringing",
"mno": "mtn"
}
}

call.transcription

Fired as real-time transcript segments arrive during the conversation. Includes conversation_history with all turns so far.

{
"event_id": "evt_test_e5f6g7h8",
"event_type": "call.transcription",
"channel": "voice",
"agent_id": "agt_k7m2n9p4q1",
"number": "+2348000001234",
"from": "+2348031234567",
"timestamp": "2026-04-09T14:30:12Z",
"payload": {
"call_id": "call_x9y8z7w6",
"transcript_segment": {
"speaker": "caller",
"text": "Hello, I would like to check my account balance please.",
"start_ms": 2100,
"end_ms": 5400,
"confidence": 0.96
},
"conversation_history": [
{
"speaker": "system",
"text": "This call is from First National Microfinance Bank.",
"start_ms": 0,
"end_ms": 2000
},
{
"speaker": "caller",
"text": "Hello, I would like to check my account balance please.",
"start_ms": 2100,
"end_ms": 5400
}
]
}
}

call.completed

Fired when the call ends. Includes full duration, transcript, MNO used, and cost.

{
"event_id": "evt_test_i9j0k1l2",
"event_type": "call.completed",
"channel": "voice",
"agent_id": "agt_k7m2n9p4q1",
"number": "+2348000001234",
"from": "+2348031234567",
"timestamp": "2026-04-09T14:32:45Z",
"payload": {
"call_id": "call_x9y8z7w6",
"direction": "inbound",
"from": "+2348031234567",
"to": "+2348000001234",
"status": "completed",
"duration_seconds": 165,
"mno_used": "mtn",
"cost_kobo": 2475,
"recording_url": null,
"transcript": [
{
"speaker": "system",
"text": "This call is from First National Microfinance Bank.",
"start_ms": 0,
"end_ms": 2000
},
{
"speaker": "caller",
"text": "Hello, I would like to check my account balance please.",
"start_ms": 2100,
"end_ms": 5400
},
{
"speaker": "agent",
"text": "Good afternoon! I would be happy to help you check your account balance. Could you please provide your account number?",
"start_ms": 5500,
"end_ms": 9200
}
]
}
}

message.received

Fired when an inbound SMS arrives.

{
"event_id": "evt_test_m3n4o5p6",
"event_type": "message.received",
"channel": "sms",
"agent_id": "agt_k7m2n9p4q1",
"number": "+2348000001234",
"from": "+2348051234567",
"timestamp": "2026-04-09T15:10:00Z",
"payload": {
"message_id": "msg_q7r8s9t0",
"direction": "inbound",
"from": "+2348051234567",
"to": "+2348000001234",
"channel": "sms",
"body": "What are your business hours?",
"mno": "glo"
}
}

Webhook Signature Verification

Every webhook delivery includes two headers:

  • X-Voicebip-Signature: sha256={hex_digest} — HMAC-SHA256 of "{timestamp}.{body}"
  • X-Voicebip-Timestamp: {unix_seconds} — Unix timestamp used in the signature

Always verify the signature and check the timestamp is within a 5-minute window to prevent replay attacks.

Python

import hashlib
import hmac
import time
def verify_webhook(
payload: bytes,
signature_header: str,
timestamp_header: str,
signing_secret: str,
max_age_seconds: int = 300,
) -> bool:
"""Verify X-Voicebip-Signature against X-Voicebip-Timestamp and body."""
if not signature_header.startswith("sha256="):
return False
try:
timestamp = int(timestamp_header)
except (ValueError, TypeError):
return False
# Reject deliveries older than max_age_seconds (replay protection).
if abs(time.time() - timestamp) > max_age_seconds:
return False
expected_sig = signature_header[len("sha256="):]
signed_payload = f"{timestamp}.".encode() + payload
computed_sig = hmac.new(
signing_secret.encode("utf-8"),
signed_payload,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(computed_sig, expected_sig)
# Usage in a Flask handler:
from flask import Flask, request, abort
app = Flask(__name__)
SIGNING_SECRET = "your_workspace_signing_secret"
@app.route("/webhook", methods=["POST"])
def handle_webhook():
signature = request.headers.get("X-Voicebip-Signature", "")
timestamp = request.headers.get("X-Voicebip-Timestamp", "")
if not verify_webhook(request.data, signature, timestamp, SIGNING_SECRET):
abort(401, "Invalid signature")
event = request.json
print(f"Received {event['event_type']} for agent {event['agent_id']}")
return "", 200

Go

package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"math"
"net/http"
"strconv"
"strings"
"time"
)
func verifyWebhook(payload []byte, signatureHeader, timestampHeader, signingSecret string) bool {
if !strings.HasPrefix(signatureHeader, "sha256=") {
return false
}
ts, err := strconv.ParseInt(timestampHeader, 10, 64)
if err != nil {
return false
}
// Reject deliveries older than 5 minutes (replay protection).
skew := time.Since(time.Unix(ts, 0))
if math.Abs(float64(skew)) > float64(5*time.Minute) {
return false
}
expectedSig := strings.TrimPrefix(signatureHeader, "sha256=")
mac := hmac.New(sha256.New, []byte(signingSecret))
mac.Write([]byte(timestampHeader))
mac.Write([]byte{'.'})
mac.Write(payload)
computedSig := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(computedSig), []byte(expectedSig))
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
signature := r.Header.Get("X-Voicebip-Signature")
timestamp := r.Header.Get("X-Voicebip-Timestamp")
if !verifyWebhook(body, signature, timestamp, "your_workspace_signing_secret") {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
fmt.Fprintf(w, "OK")
}
func main() {
http.HandleFunc("/webhook", webhookHandler)
http.ListenAndServe(":3000", nil)
}

TypeScript

import crypto from "crypto";
import express from "express";
const app = express();
const SIGNING_SECRET = "your_workspace_signing_secret";
const MAX_AGE_SECONDS = 300; // 5 minutes
function verifyWebhook(
payload: Buffer,
signatureHeader: string,
timestampHeader: string,
signingSecret: string
): boolean {
if (!signatureHeader.startsWith("sha256=")) {
return false;
}
const timestamp = parseInt(timestampHeader, 10);
if (isNaN(timestamp)) {
return false;
}
// Reject deliveries older than 5 minutes (replay protection).
const skewSeconds = Math.abs(Date.now() / 1000 - timestamp);
if (skewSeconds > MAX_AGE_SECONDS) {
return false;
}
const expectedSig = signatureHeader.slice("sha256=".length);
const signedPayload = Buffer.concat([
Buffer.from(`${timestamp}.`),
payload,
]);
const computedSig = crypto
.createHmac("sha256", signingSecret)
.update(signedPayload)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(computedSig),
Buffer.from(expectedSig)
);
}
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
const signature = req.headers["x-voicebip-signature"] as string;
const timestamp = req.headers["x-voicebip-timestamp"] as string;
if (!verifyWebhook(req.body, signature, timestamp, SIGNING_SECRET)) {
return res.status(401).send("Invalid signature");
}
const event = JSON.parse(req.body.toString());
console.log(`Received ${event.event_type} for agent ${event.agent_id}`);
res.sendStatus(200);
});
app.listen(3000, () => console.log("Webhook server running on port 3000"));

Local Development with ngrok

ngrok creates a public URL that tunnels to your local development server. This lets Voicebip deliver webhooks to your machine during development.

Install ngrok

# macOS
brew install ngrok
# Linux (snap)
snap install ngrok
# Or download from https://ngrok.com/download

Start the Tunnel

ngrok http 3000

ngrok displays a public URL like https://a1b2c3d4.ngrok-free.app. This URL forwards all traffic to localhost:3000.

Set Your Webhook URL

Use the ngrok URL as your agent’s webhook endpoint:

curl -X PATCH "https://api.voicebip.com/v1/agents/agt_abc123" \
-H "Authorization: Bearer pk_test_your_key" \
-H "Content-Type: application/json" \
-d '{"webhook_url": "https://a1b2c3d4.ngrok-free.app/webhook"}'

Send a Test Event

curl -X POST "https://api.voicebip.com/v1/webhooks/test" \
-H "Authorization: Bearer pk_test_your_key" \
-H "Content-Type: application/json" \
-d '{
"webhook_url": "https://a1b2c3d4.ngrok-free.app/webhook",
"event_type": "call.completed"
}'

You should see the webhook payload arrive in your local server’s console and in the ngrok web inspector at http://localhost:4040.

Free ngrok URLs change every time you restart the tunnel. Update your webhook URL accordingly, or use a paid ngrok plan for a stable subdomain.