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"
> }'
1{
2 "delivery_id": "del_test_k7m2n9",
3 "status": "delivered",
4 "event_type": "call.completed",
5 "response_code": 200,
6 "response_time_ms": 142
7}

The test endpoint delivers a realistic Nigerian mock payload with valid +234 E.164 numbers, conversation history, and a valid HMAC-SHA256 signature. You can use any event_type value: call.initiated, call.transcription, call.completed, or message.received.

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.

1{
2 "event_id": "evt_test_a1b2c3d4",
3 "event_type": "call.initiated",
4 "channel": "voice",
5 "agent_id": "agt_k7m2n9p4q1",
6 "number": "+2348000001234",
7 "from": "+2348031234567",
8 "timestamp": "2026-04-09T14:30:00Z",
9 "payload": {
10 "call_id": "call_x9y8z7w6",
11 "direction": "inbound",
12 "from": "+2348031234567",
13 "to": "+2348000001234",
14 "status": "ringing",
15 "mno": "mtn"
16 }
17}

call.transcription

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

1{
2 "event_id": "evt_test_e5f6g7h8",
3 "event_type": "call.transcription",
4 "channel": "voice",
5 "agent_id": "agt_k7m2n9p4q1",
6 "number": "+2348000001234",
7 "from": "+2348031234567",
8 "timestamp": "2026-04-09T14:30:12Z",
9 "payload": {
10 "call_id": "call_x9y8z7w6",
11 "transcript_segment": {
12 "speaker": "caller",
13 "text": "Hello, I would like to check my account balance please.",
14 "start_ms": 2100,
15 "end_ms": 5400,
16 "confidence": 0.96
17 },
18 "conversation_history": [
19 {
20 "speaker": "system",
21 "text": "This call is from First National Microfinance Bank.",
22 "start_ms": 0,
23 "end_ms": 2000
24 },
25 {
26 "speaker": "caller",
27 "text": "Hello, I would like to check my account balance please.",
28 "start_ms": 2100,
29 "end_ms": 5400
30 }
31 ]
32 }
33}

call.completed

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

1{
2 "event_id": "evt_test_i9j0k1l2",
3 "event_type": "call.completed",
4 "channel": "voice",
5 "agent_id": "agt_k7m2n9p4q1",
6 "number": "+2348000001234",
7 "from": "+2348031234567",
8 "timestamp": "2026-04-09T14:32:45Z",
9 "payload": {
10 "call_id": "call_x9y8z7w6",
11 "direction": "inbound",
12 "from": "+2348031234567",
13 "to": "+2348000001234",
14 "status": "completed",
15 "duration_seconds": 165,
16 "mno_used": "mtn",
17 "cost_kobo": 2475,
18 "recording_url": null,
19 "transcript": [
20 {
21 "speaker": "system",
22 "text": "This call is from First National Microfinance Bank.",
23 "start_ms": 0,
24 "end_ms": 2000
25 },
26 {
27 "speaker": "caller",
28 "text": "Hello, I would like to check my account balance please.",
29 "start_ms": 2100,
30 "end_ms": 5400
31 },
32 {
33 "speaker": "agent",
34 "text": "Good afternoon! I would be happy to help you check your account balance. Could you please provide your account number?",
35 "start_ms": 5500,
36 "end_ms": 9200
37 }
38 ]
39 }
40}

message.received

Fired when an inbound SMS arrives.

1{
2 "event_id": "evt_test_m3n4o5p6",
3 "event_type": "message.received",
4 "channel": "sms",
5 "agent_id": "agt_k7m2n9p4q1",
6 "number": "+2348000001234",
7 "from": "+2348051234567",
8 "timestamp": "2026-04-09T15:10:00Z",
9 "payload": {
10 "message_id": "msg_q7r8s9t0",
11 "direction": "inbound",
12 "from": "+2348051234567",
13 "to": "+2348000001234",
14 "channel": "sms",
15 "body": "What are your business hours?",
16 "mno": "glo"
17 }
18}

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

1import hashlib
2import hmac
3import time
4
5def verify_webhook(
6 payload: bytes,
7 signature_header: str,
8 timestamp_header: str,
9 signing_secret: str,
10 max_age_seconds: int = 300,
11) -> bool:
12 """Verify X-Voicebip-Signature against X-Voicebip-Timestamp and body."""
13 if not signature_header.startswith("sha256="):
14 return False
15
16 try:
17 timestamp = int(timestamp_header)
18 except (ValueError, TypeError):
19 return False
20
21 # Reject deliveries older than max_age_seconds (replay protection).
22 if abs(time.time() - timestamp) > max_age_seconds:
23 return False
24
25 expected_sig = signature_header[len("sha256="):]
26 signed_payload = f"{timestamp}.".encode() + payload
27 computed_sig = hmac.new(
28 signing_secret.encode("utf-8"),
29 signed_payload,
30 hashlib.sha256,
31 ).hexdigest()
32
33 return hmac.compare_digest(computed_sig, expected_sig)
34
35
36# Usage in a Flask handler:
37from flask import Flask, request, abort
38
39app = Flask(__name__)
40SIGNING_SECRET = "whsec_your_signing_secret"
41
42@app.route("/webhook", methods=["POST"])
43def handle_webhook():
44 signature = request.headers.get("X-Voicebip-Signature", "")
45 timestamp = request.headers.get("X-Voicebip-Timestamp", "")
46 if not verify_webhook(request.data, signature, timestamp, SIGNING_SECRET):
47 abort(401, "Invalid signature")
48
49 event = request.json
50 print(f"Received {event['event_type']} for agent {event['agent_id']}")
51 return "", 200

Go

1package main
2
3import (
4 "crypto/hmac"
5 "crypto/sha256"
6 "encoding/hex"
7 "fmt"
8 "io"
9 "math"
10 "net/http"
11 "strconv"
12 "strings"
13 "time"
14)
15
16func verifyWebhook(payload []byte, signatureHeader, timestampHeader, signingSecret string) bool {
17 if !strings.HasPrefix(signatureHeader, "sha256=") {
18 return false
19 }
20
21 ts, err := strconv.ParseInt(timestampHeader, 10, 64)
22 if err != nil {
23 return false
24 }
25
26 // Reject deliveries older than 5 minutes (replay protection).
27 skew := time.Since(time.Unix(ts, 0))
28 if math.Abs(float64(skew)) > float64(5*time.Minute) {
29 return false
30 }
31
32 expectedSig := strings.TrimPrefix(signatureHeader, "sha256=")
33
34 mac := hmac.New(sha256.New, []byte(signingSecret))
35 mac.Write([]byte(timestampHeader))
36 mac.Write([]byte{'.'})
37 mac.Write(payload)
38 computedSig := hex.EncodeToString(mac.Sum(nil))
39
40 return hmac.Equal([]byte(computedSig), []byte(expectedSig))
41}
42
43func webhookHandler(w http.ResponseWriter, r *http.Request) {
44 body, err := io.ReadAll(r.Body)
45 if err != nil {
46 http.Error(w, "Bad request", http.StatusBadRequest)
47 return
48 }
49
50 signature := r.Header.Get("X-Voicebip-Signature")
51 timestamp := r.Header.Get("X-Voicebip-Timestamp")
52 if !verifyWebhook(body, signature, timestamp, "whsec_your_signing_secret") {
53 http.Error(w, "Invalid signature", http.StatusUnauthorized)
54 return
55 }
56
57 fmt.Fprintf(w, "OK")
58}
59
60func main() {
61 http.HandleFunc("/webhook", webhookHandler)
62 http.ListenAndServe(":3000", nil)
63}

TypeScript

1import crypto from "crypto";
2import express from "express";
3
4const app = express();
5const SIGNING_SECRET = "whsec_your_signing_secret";
6const MAX_AGE_SECONDS = 300; // 5 minutes
7
8function verifyWebhook(
9 payload: Buffer,
10 signatureHeader: string,
11 timestampHeader: string,
12 signingSecret: string
13): boolean {
14 if (!signatureHeader.startsWith("sha256=")) {
15 return false;
16 }
17
18 const timestamp = parseInt(timestampHeader, 10);
19 if (isNaN(timestamp)) {
20 return false;
21 }
22
23 // Reject deliveries older than 5 minutes (replay protection).
24 const skewSeconds = Math.abs(Date.now() / 1000 - timestamp);
25 if (skewSeconds > MAX_AGE_SECONDS) {
26 return false;
27 }
28
29 const expectedSig = signatureHeader.slice("sha256=".length);
30 const signedPayload = Buffer.concat([
31 Buffer.from(`${timestamp}.`),
32 payload,
33 ]);
34 const computedSig = crypto
35 .createHmac("sha256", signingSecret)
36 .update(signedPayload)
37 .digest("hex");
38
39 return crypto.timingSafeEqual(
40 Buffer.from(computedSig),
41 Buffer.from(expectedSig)
42 );
43}
44
45app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
46 const signature = req.headers["x-voicebip-signature"] as string;
47 const timestamp = req.headers["x-voicebip-timestamp"] as string;
48
49 if (!verifyWebhook(req.body, signature, timestamp, SIGNING_SECRET)) {
50 return res.status(401).send("Invalid signature");
51 }
52
53 const event = JSON.parse(req.body.toString());
54 console.log(`Received ${event.event_type} for agent ${event.agent_id}`);
55 res.sendStatus(200);
56});
57
58app.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.