> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.voicebip.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.voicebip.com/_mcp/server.

# Testing Locally

> Sandbox mode, mock webhook payloads, signature verification, and local development with ngrok.

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 Range     | Type           | Description                                              |
| ---------------- | -------------- | -------------------------------------------------------- |
| `+234800000xxxx` | Mobile virtual | Sandbox mobile virtual numbers for voice and SMS testing |
| `+234100000xxxx` | Lagos DID      | Sandbox 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.

```bash
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"
  }'
```

```json
{
  "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.

```json
{
  "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.

```json
{
  "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.

```json
{
  "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.

```json
{
  "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

```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

```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

```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](https://ngrok.com) creates a public URL that tunnels to your local development server. This lets Voicebip deliver webhooks to your machine during development.

### Install ngrok

```bash
# macOS
brew install ngrok

# Linux (snap)
snap install ngrok

# Or download from https://ngrok.com/download
```

### Start the Tunnel

```bash
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:

```bash
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

```bash
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`.

> **Warning**
>
> 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.