Documentation

Everything needed to wire up contacts, send SMS under your own sender ID with automatic provider failover, connect your Hubtel Merchant Account for USSD payments, generate payment links and invoices, trigger SMS sends from your own backend, and auto-add contacts from another system.

00 Our services

Tunnelo ships four services on the same USSD and SMS infrastructure.

ServicePurpose
SMS GatewayBulk SMS sending under your own sender ID, with delivery tracking and automatic failover across up to 5 providers
USSD Menu BuilderBuild interactive USSD menus and deploy them to your shortcode
USSD Payment CollectionCollect money on a USSD menu via Hubtel's native AddToCart checkout
Contact Database SyncLive read-only sync of contacts from your own Postgres, MySQL, MongoDB, or Supabase database
Payment LinksGenerate an instant invoice link for card/mobile money/wallet payments, request a cross-border collection with an instant, auto-confirmed payment link, or share a USDT wallet address

01 Contacts & database connections

Add recipients manually, import a CSV, or keep a live sync from your own database.

MANUAL / CSV / EXCEL
Go to Contacts → Add contact to enter a single number, or Contacts → Import to upload a CSV or Excel file. A phone column is required — named phone, mobile, number, or msisdn — and is auto-detected. A name column (name, contact, person, first name, full name) is optional. No other custom fields are stored, and there's no merge-tag support in messages today.
GOOGLE SHEETS
In your sheet: Share → set general access to "Anyone with the link" (Viewer). Then just paste the sheet's normal URL — the one copied straight from the address bar — into Contacts → Import → import from Google Sheets and click Connect & sync. Tunnelo automatically converts it to the CSV export link it needs, so there's no manual link-building step (this replaces the older "Publish to web" flow, whose CSV option Google has been inconsistent about surfacing). Same column rules as CSV/Excel above. Once connected, Tunnelo automatically re-fetches and re-imports every 6 hours — new rows become contacts, existing ones get their name refreshed, a contact is never removed if it later disappears from the sheet. Click Sync now any time for an immediate re-check, or Disconnect to stop the automatic sync.
CONNECT YOUR DATABASE
Go to Contacts → Connect database. Supported: PostgreSQL, MySQL, MongoDB, Supabase. Enter a read-only connection (Mongo: connection string, Supabase: project URL + key) and pick a table or collection — Tunnelo only ever runs read queries against it, never writes. The table must have a phone/mobile column (auto-detected by name, no manual field mapping), and is capped at 10,000 rows — export a smaller or filtered subset first if yours is bigger.
// PostgreSQL connection
postgres://readonly_user:password@db.yourhost.com:5432/yourdb?sslmode=require

// MongoDB connection URI
mongodb+srv://readonly_user:password@cluster0.mongodb.net/yourdb

// Supabase (project API instead of direct Postgres)
project_url: "https://xyzcompany.supabase.co"
api_key: "YOUR_SUPABASE_KEY"
Note — use a dedicated read-only DB user, not your app's main credentials. Tunnelo never needs write access to your database.
Database on a private network? A private address (e.g. 192.168.x.x) is unreachable from Tunnelo's servers — there's no network path in. Port-forward your DB to a public address instead, and restrict that forwarding rule to accept connections only from Tunnelo's IP: 68.183.116.88. This keeps your database access-controlled to one source rather than open to the whole internet.
SYNC BEHAVIOR
Once connected, an automatic re-sync runs every 6 hours, or trigger Sync now manually. Matching is by phone number: a new number becomes a contact, an existing one gets its name refreshed. Sync never deletes or deactivates a contact, even if its row later disappears from your source table — and synced contacts land with no group assigned, so add them to a group afterward (see §03) or target them directly with a webhook (see §07).

02 Sender ID & sending SMS

Register the name recipients see instead of a phone number, then send.

STEP 1 — ADD YOUR SMS PROVIDER(S)
Go to Settings → SMS providers. SMS sends through your own business account on each provider you add — Hubtel, Arkesel, Nalo, mNotify, or WhichSMS, up to one of each (5 max). For every provider, enter the Sender ID already approved on that account (up to 11 characters, e.g. YOURBRAND) along with its credentials — Hubtel needs a Client ID and Client Secret; Arkesel, Nalo, and mNotify need only an API Key; WhichSMS needs an API Key, plus an optional Public Key that turns on delivery-status tracking (sending works without it). There's no separate approval step on Tunnelo's side — credit and sender ID stay tied to your own provider account, never shared with other customers.
AUTOMATIC FAILOVER
With more than one provider configured, rank them using the controls next to each — the top one is tried first on every send. If that attempt fails (provider outage, rejected message, bad credentials), Tunnelo automatically retries the same message through the next provider in your list, live, within the same send — no manual switching, no delay waiting on a health check. This applies to every send path: immediate sends, scheduled broadcasts, webhook-triggered sends, and USSD payment-confirmation SMS. The delivery log records which provider actually delivered each message.
BULK SENDING
A broadcast to more than 50 recipients with the same message text sends as real bulk batches, not one request per recipient — every provider (Hubtel, Arkesel, Nalo, mNotify, WhichSMS) supports this. It matters most for WhichSMS: their API rate-limits at 60 requests per minute per key, so a large broadcast sent one-at-a-time would crawl; batching hundreds or thousands of recipients into each request turns the same rate limit into effectively unlimited throughput.
DELIVERY-STATUS TRACKING BY PROVIDER
Hubtel, Arkesel, mNotify, and WhichSMS all get their delivery status polled automatically and reflected in Delivery Logs — no action needed on your end. Nalo is the one exception: their API only supports delivery reports via a push callback, not a status you can poll for, so a Nalo send stays showing "Sent" in Delivery Logs rather than progressing to "Delivered"/"Failed" — the message itself still sends and delivers normally, this only affects whether Tunnelo can show you the outcome afterward.
STEP 2 — COMPOSE & SEND
From Send SMS, choose recipients — a single number, or (under Broadcast/Schedule) one group or all contacts — write your message, and choose Send now or Schedule for a future date/time.

03 Groups & table selection

Organize contacts into reusable lists for targeted sends.

CREATE A GROUP
Go to Contacts → Groups → New group, name it, then add members by pasting numbers, uploading a CSV/Excel file, or pulling from a table in your connected database.
FROM A DATABASE TABLE
With a database connected (see §01), pick a table from the dropdown — every row with a detected phone/mobile column is pulled into the group. There's no column-mapping step or filter; if you only want a subset, prepare that subset as its own table or view first.
USING GROUPS IN A SEND
In Send SMS, switch to Broadcast or Schedule, then pick one group (or All Contacts) as the recipient.

04 Connect your payment provider (USSD)

USSD payment nodes collect money through Hubtel's own native checkout — there's no provider to pick, and no separate charge API call.

HOW IT WORKS
A payment, vote, or ticket item with a charge triggers Hubtel's own AddToCart prompt directly on the caller's phone as part of the USSD session — Tunnelo never makes a separate outbound call to start the charge. Money settles straight to your own Hubtel account; Tunnelo never holds customer payment funds.
HUBTEL MERCHANT ACCOUNT — REQUIRED FOR VERIFICATION
Go to Settings → Hubtel Merchant Account and enter your API ID, API Key, and Collection Account Number (Developer Portal → Programmable Keys → Payment Keys, and Account ID). AddToCart's own prompt isn't trusted as proof of payment on its own — Tunnelo uses these credentials to independently re-verify every payment via Hubtel's Transaction Status Check API before crediting it.
Note — without a Merchant Account configured, USSD payments can never be verified and are always marked failed, even if the caller actually paid. Hubtel also requires your Tunnelo server's IP to be whitelisted for that verification API — contact Tunnelo support for the IP to give your Hubtel Retail Systems Engineer if a payment gets stuck at "in progress".

05 Set up a USSD menu

Build the menu tree visually, then deploy it to your shortcode. Five node types, and — this trips people up — payment collection is built directly into two of them; it's not a step you bolt on separately.

THE FIVE NODE TYPES
Node typeWhat it does
messageShows text, then ends the session. Terminal — a caller can't go anywhere from it.
submenuNests further numbered options under it. Purely structural — no data collected.
paymentCollects a standalone charge with no vote or ticket attached — a fixed amount, or one the caller types in.
voteLists candidates/options to pick from. Optionally charges a fee per vote — set on the vote item itself, not a separate payment node.
ticketLists price tiers to pick from. Always charges — the price lives on each tier, not a separate payment node.
PAYMENT IS PART OF THE ITEM, NOT A SEPARATE STEP
There's no "add a payment node after your vote/ticket" step, and no menu item for the confirm/cancel prompt either — both are generated automatically. Configure the amount right on the vote or ticket item itself:
ItemWhere the amount livesWhat the caller sees
voteFee per vote field on the item (blank = free vote)Pick a candidate → Vote for X costs GHS5.00. 1. Confirm 0. Cancel → charged on 1
ticketPrice field on each tierPick a tier → Regular — GHS20.00. 1. Confirm 0. Cancel → charged on 1
Pressing anything other than 1 — including 0 — cancels: no vote recorded, no ticket issued, no charge attempted.
STANDALONE PAYMENT NODES
A payment item is for a charge that isn't tied to a vote or ticket. Three amount modes:
Amount typeFlow
FixedShows your Prompt text with the amount filled in, then 1. Confirm / 0. Cancel is added automatically — same as vote/ticket.
Caller enters itShows your Prompt, caller types a number (GHS1–10,000), then shows your Confirmation message as-is — the confirm/cancel prompt is not auto-added here. Write "Reply 1 to confirm" into the confirmation message yourself, or callers won't know what to press.
From your backendTunnelo asks your system what to charge, then shows your Prompt with the amount filled in and 1. Confirm / 0. Cancel added automatically — same auto-confirm as Fixed. See below for the webhook contract.
FROM YOUR BACKEND — DYNAMIC AMOUNT LOOKUP
Set an Amount lookup URL — a public HTTPS endpoint on your own system. On the confirm screen, and again right before the charge is placed, Tunnelo sends it:
POST https://yoursystem.com/api/ussd-amount
Content-Type: application/json

{ "phone": "233241234567", "service_id": "...", "item_id": "..." }
Your endpoint has 8 seconds to respond with the amount to charge:
{ "amount": 12.50 }
Because it's called twice, whatever your system returns on the second call (right before the charge) is what actually gets charged — if that differs from what the caller confirmed, the newer value wins. If your endpoint is unreachable, times out, returns a non-2xx status, or the amount isn't a positive number, the session ends with "Unable to retrieve your amount right now. Please try again shortly." and nothing is charged.
Note — phone is whoever is currently dialing, not necessarily who the payment is for — mobile money can be paid from any phone with a balance on it. If your system identifies customers by an account/meter/membership number rather than phone, turn on Ask for a payment number first? A prompt step is inserted before the amount lookup, and whatever the caller types is sent as an extra identifier field alongside phone. Look the customer up by identifier when it's present — phone alone is only reliable when your customers are indexed by their own mobile number (e.g. airtime top-up).
PLACEHOLDERS AND WHAT HAPPENS AFTER PAYMENT
Prompt, confirmation, success, and failure text on a payment item accept these placeholders:
PlaceholderResolves to
{amount}The charge amount, formatted as GHS12.50
{ref}An 8-character order reference
{description}The item's label
{phone}The caller's number
{date} / {time}When the payment completed
On success, the caller's phone also gets an SMS. If you leave the SMS template field blank, your Success message text is reused as the SMS body — same placeholders work in both. ticket items are the one exception: the confirmation SMS is always a fixed template with the event name, tier, and {ref} baked in, so there's nothing to configure there.
SUBMIT FOR REVIEW, THEN GO LIVE
Click Save, then Publish. This submits the menu to Tunnelo for review — it shows as pending until approved. Only one active menu can run on a given shortcode. Once approved, Tunnelo assigns your shortcode and the status changes to active — live for anyone dialing in.

07 Trigger an SMS from your own system

Webhooks on Tunnelo work in reverse from most platforms: your own backend calls us, and we send the SMS — there's no outbound event stream to subscribe to.

STEP 1 — CREATE A WEBHOOK
Go to Webhooks → New webhook. Pick who receives the message — an individual contact, a group, all contacts, Dynamic, or External — and write a message template using {key} placeholders for values you'll send at call time, e.g. {name} or {order_id}. External skips the template entirely — see below.
STEP 2 — CALL YOUR WEBHOOK URL
Tunnelo gives you a unique URL. From your own backend, POST a JSON body to it whenever the trigger happens — an order ships, a payment clears, whatever fits your flow. Tunnelo fills the template from the fields you send and dispatches the SMS.
// Your webhook URL
POST /api/inbound/<token>
Content-Type: application/json

{ "name": "Efua", "order_id": "8f2a1c" }

// Template: "Hi {name}, your order {order_id} has shipped."
// Sends:    "Hi Efua, your order 8f2a1c has shipped."
DYNAMIC RECIPIENT
Every other recipient type is fixed when you create the webhook. Dynamic instead reads the recipient's phone number from the payload itself at call time, so one shared webhook URL can send to a different person on every call — e.g. a MikroTik script firing per-customer when their data usage crosses a threshold. The request body must include a phone field.
POST /api/inbound/<token>
Content-Type: application/json

{ "phone": "+233241234567", "usage": "90%" }

// Template: "Quota alert: {usage} used"
// Sends:    "Quota alert: 90% used" to +233241234567
EXTERNAL RECIPIENT
External goes a step further than Dynamic: there's no stored template at all — both the phone number and the full message text come from your own backend on every call. Use this when your system already knows exactly what to say, e.g. an order-status update composed by your app. The request body must include phone and message fields.
POST /api/inbound/<token>
Content-Type: application/json

{ "phone": "+233241234567", "message": "Your order has shipped!" }

// Sends exactly that text to +233241234567 — no template involved.
Note — the token in the URL is the only credential — there's no separate signature to verify. Keep it as secret as an API key; anyone with the URL can trigger a send on your account.

08 API keys — auto-add contacts

The opposite direction from §07: instead of Tunnelo sending an SMS, an external system creates a contact in Tunnelo — e.g. adding every new Shopify customer automatically, with no manual CSV export.

STEP 1 — CREATE A KEY
Go to Contacts → API keys → New key, name it, and copy the value shown — it's only ever displayed once. Tunnelo stores a one-way hash of it, not the key itself, so a lost key can't be recovered, only replaced.
STEP 2 — CALL THE ENDPOINT
Two endpoints, same auth. This key only works on these two — nothing else on your account (sending SMS, viewing data, billing, settings) is reachable with it.
// Single contact
POST /api/customer/contacts
Authorization: Bearer <your key>
{ "name": "Jane Doe", "phone": "+233241234567", "group_name": "shopify" }

// Bulk — up to 10,000 per call
POST /api/customer/contacts/bulk
Authorization: Bearer <your key>
{ "group_name": "shopify", "contacts": [{ "name": "Jane", "phone": "+233241234567" }, ...] }
phone is required on both. name is required on the single endpoint, optional on bulk. group_name is optional on both, and applies to the whole batch in bulk mode.
JAVASCRIPT EXAMPLE (NODE.JS)
Drop this into your signup handler — wherever a customer gets created on your side, call it right after.
// Keep the key server-side only — an env var, never in browser/client JS,
// since anyone who can read it could add contacts to your account.
const TUNNELO_API_KEY = process.env.TUNNELO_API_KEY;
const TUNNELO_URL = 'https://your-domain.com/api/customer/contacts';

async function addContactToTunnelo({ name, phone, groupName }) {
  const res = await fetch(TUNNELO_URL, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${TUNNELO_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ name, phone, group_name: groupName }),
  });

  if (!res.ok) {
    const err = await res.json();
    throw new Error(`Tunnelo contact sync failed: ${err.error ?? res.status}`);
  }

  return res.json(); // { contact: { id, name, phone, network, group_name, created_at } }
}

// e.g. inside your own "customer created" event
await addContactToTunnelo({
  name: newCustomer.fullName,
  phone: newCustomer.phone,
  groupName: 'shopify',
});
SETUP BY PLATFORM
This endpoint needs a custom Authorization header, so it only works in tools that let you set headers — a native "Webhook URL" field that just takes a bare URL (like most e-commerce/form apps' own settings) can't hold this key.
PlatformWhere
ZapierAdd step "Webhooks by Zapier" → POST → URL + Headers (Authorization) + Data (body) all live in that one step
MakeAdd an "HTTP" module → "Make a request" → URL, Method POST, Headers, Body
n8n"HTTP Request" node → URL, Method POST, Header Auth, Body
HubSpotAutomation → Workflows → pick a trigger → action "Send a webhook" (Operations Hub Starter+; on lower tiers use Zapier's HubSpot trigger instead)
Your own backendStraight into the POST call wherever a customer record is created
Revoking — takes effect immediately, the very next request with a revoked key gets rejected. Keys don't expire on their own — unlike a login session, they stay valid until you revoke them.

09 Team members

Give staff their own login to the same account — one shared wallet, contacts, and settings, no data duplicated per person.

INVITE SOMEONE
Go to Team → Invite a team member and enter their email. They get an email with a link that's valid for 7 days. Clicking it signs them in immediately — no separate signup form — then a one-time prompt asks them to set a phone number and password before they can use anything else. Re-inviting the same pending email just resends the link with a fresh expiry, it doesn't create a duplicate invite.
WHAT STAFF CAN ACCESS
Everything tied to the account is shared: contacts, sending, USSD builder, webhooks, SMS providers, and settings all read and write against the same account as the owner — including the same subscription and wallet balance. Billing and team management stay owner-only — staff can't see the Team page, invite anyone, or touch Payments; those routes are rejected even with a valid staff login. If the owner's subscription lapses or downgrades, every staff login loses the same access at the same time, since it's one shared subscription, not a per-person one.
REMOVING ACCESS
From Team, remove a member or revoke a pending invite. Removing a member takes effect immediately — their login session and API access stop working right away, same as revoking an API key.