Skip to content

Commit a053ea2

Browse files
Albert A. Ninyehclaude
andcommitted
feat(ussd): support app test/live modes, UUID ids, and app-scoped simulate
Align the USSD module with API v1: - Apps now have test/live modes. create_app returns id (UUID), mode, test_secret (ussk_test_), live_secret (ussk_live_), is_live, active instead of a single secret. - Add set_mode(id, mode) and rotate_secret(id, mode). - App and extension ids are UUID strings across all methods. - simulate now targets an app: simulate(app_id:, session_id:, msisdn:, input: "", new_session: false, service_code: nil). service_code is sent only when given; not-owned apps return 422 unknown_app. - Extension rentals draw from the dedicated USSD balance; underfunded rentals return 402 insufficient_ussd_balance. - Add Hellio::ExtensionRequiredError (402) and map responses by error slug (extension_required, insufficient_ussd_balance) before status. - Update README, CHANGELOG, and specs; bump version to 1.2.0. Co-Authored-By: Claude Opus 4.8 <[email protected]>
1 parent fabd11b commit a053ea2

7 files changed

Lines changed: 265 additions & 66 deletions

File tree

CHANGELOG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,31 @@
33
All notable changes to `hellio-messaging` are documented here.
44
This project follows [Semantic Versioning](https://semver.org).
55

6+
## [1.2.0] - 2026-07-07
7+
8+
### Added
9+
- USSD apps now have test/live modes:
10+
- `create_app` responses return `id` (UUID string), `name`, `callback_url`,
11+
`mode` ("test" for new apps), `test_secret` ("ussk_test_..."), `live_secret`
12+
("ussk_live_..."), `is_live`, and `active` instead of a single `secret`.
13+
- `set_mode(id, mode)` switches an app between "test" and "live".
14+
- `rotate_secret(id, mode)` rotates the secret for one mode.
15+
- `Hellio::ExtensionRequiredError` (402), raised when switching an app to "live"
16+
before a USSD extension is purchased (slug "extension_required").
17+
- Error mapping now resolves a few responses by their `error` slug before the
18+
HTTP status, covering "extension_required" and "insufficient_ussd_balance".
19+
20+
### Changed
21+
- USSD app and extension ids are UUID strings (were integers).
22+
- `simulate` now targets an app: `simulate(app_id:, session_id:, msisdn:,
23+
input: "", new_session: false, service_code: nil)`. `service_code` is sent
24+
only when given and defaults server-side to the shared short code. Simulation
25+
is always sandboxed (no charge, no extension). A not-owned app returns 422
26+
("unknown_app").
27+
- Extension rentals draw from the dedicated USSD balance; an underfunded rental
28+
now returns 402 with slug "insufficient_ussd_balance" (still
29+
`Hellio::InsufficientBalanceError`).
30+
631
## [1.1.0] - 2026-07-07
732

833
### Added

README.md

Lines changed: 43 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -104,47 +104,70 @@ before the request is sent.
104104

105105
## USSD
106106

107-
USSD endpoints live under `client.ussd`. Rent an extension (the short code
108-
subscribers dial), point it at an app whose `callback_url` Hellio calls on each
109-
session step, and simulate the flow before going live.
107+
USSD endpoints live under `client.ussd`. Build an app whose `callback_url`
108+
Hellio calls on each session step, simulate the flow while still in test mode,
109+
rent an extension (the short code subscribers dial), then switch the app to
110+
live.
111+
112+
Apps have two modes, `"test"` and `"live"`. A new app starts in `"test"` and
113+
carries both a `test_secret` (prefix `ussk_test_`) and a `live_secret` (prefix
114+
`ussk_live_`); `mode`/`is_live` say which one is active. App and extension ids
115+
are UUID strings.
110116

111117
```ruby
112118
# Pricing and availability
113119
client.ussd.pricing # short code, session prices, extension prices
114120
client.ussd.availability("100") # {"data" => {"code" => "100", "valid" => true, "available" => true, "monthly_price" => "50.00"}}
115121

116-
# Apps - Hellio POSTs each session step to callback_url
122+
# Apps - Hellio POSTs each session step to callback_url. New apps start in test mode.
117123
app = client.ussd.create_app(name: "Airtime", callback_url: "https://your-app.com/ussd")
124+
app_id = app["data"]["id"] # UUID string
125+
app["data"]["mode"] # "test"
126+
app["data"]["test_secret"] # "ussk_test_..."
118127
client.ussd.apps
119-
client.ussd.update_app(app["data"]["id"], active: false)
120-
client.ussd.delete_app(app["data"]["id"])
128+
client.ussd.update_app(app_id, active: false)
129+
130+
# Rotate a secret for one mode (test or live)
131+
client.ussd.rotate_secret(app_id, "test")
121132

122-
# Extensions - the code subscribers dial (raises on conflict / low balance)
133+
# Simulate a session - always sandboxed (no charge, no extension).
134+
# new_session: true on the first step, then reuse the same session_id.
135+
# service_code is optional and defaults to the shared short code.
136+
client.ussd.simulate(app_id: app_id, session_id: "sess-1",
137+
msisdn: "233241234567", new_session: true)
138+
client.ussd.simulate(app_id: app_id, session_id: "sess-1",
139+
msisdn: "233241234567", input: "1")
140+
141+
# Extensions - the code subscribers dial, paid from the dedicated USSD balance
123142
client.ussd.extensions
124-
client.ussd.rent_extension("100", app_id: app["data"]["id"])
125-
client.ussd.release_extension(42)
143+
ext = client.ussd.rent_extension("100", app_id: app_id)
144+
145+
# Go live once an extension is purchased
146+
client.ussd.set_mode(app_id, "live")
126147

127148
# Sessions
128149
client.ussd.sessions(status: "ended")
129-
client.ussd.session(1024)
150+
client.ussd.session("6f1c...") # UUID string
130151

131-
# Simulate a session - new_session: true on the first step, then reuse session_id
132-
first = client.ussd.simulate(msisdn: "233241234567", service_code: "*920*100#", new_session: true)
133-
client.ussd.simulate(msisdn: "233241234567", service_code: "*920*100#",
134-
session_id: first["data"]["session_id"], input: "1")
152+
client.ussd.release_extension(ext["data"]["id"])
153+
client.ussd.delete_app(app_id)
135154
```
136155

137-
Renting an extension that is no longer available raises
138-
`Hellio::ConflictError` (409); an insufficient balance raises
139-
`Hellio::InsufficientBalanceError` (402).
156+
Renting an extension that is no longer available raises `Hellio::ConflictError`
157+
(409); an underfunded USSD balance raises `Hellio::InsufficientBalanceError`
158+
(402, slug `insufficient_ussd_balance`). The USSD balance is dedicated, separate
159+
from SMS credit and the main wallet. Switching an app to live before an
160+
extension is purchased raises `Hellio::ExtensionRequiredError` (402, slug
161+
`extension_required`).
140162

141163
### Handling the inbound callback
142164

143165
When a subscriber uses your extension, Hellio POSTs a JSON body
144166
(`sessionId`, `msisdn`, `serviceCode`, `input`, `sequence`, `mode`) to your
145167
app's `callback_url`, signed with an `X-Hellio-Signature` header set to
146-
`HMAC-SHA256(rawBody, app.secret)`. Verify it, then reply with `message` and
147-
`action` (`"continue"` or `"end"`).
168+
`HMAC-SHA256(rawBody, secret)` where `secret` is the app's secret for the mode
169+
that raised the call (`test_secret` in test mode, `live_secret` in live mode).
170+
Verify it, then reply with `message` and `action` (`"continue"` or `"end"`).
148171

149172
```ruby
150173
require "openssl"
@@ -171,6 +194,7 @@ exposes field-level details through `#errors`.
171194
|---|---|
172195
| `Hellio::InvalidApiTokenError` | 401 |
173196
| `Hellio::InsufficientBalanceError` | 402 |
197+
| `Hellio::ExtensionRequiredError` | 402 (slug `extension_required`) |
174198
| `Hellio::ConflictError` | 409 |
175199
| `Hellio::ValidationError` (`#errors`) | 422 |
176200
| `Hellio::RateLimitError` | 429 |

lib/hellio/client.rb

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ class Client
1616
DEFAULT_BASE_URL = "https://api.helliomessaging.com/v1"
1717
DEFAULT_TIMEOUT = 30
1818

19+
# Some responses share an HTTP status but mean different things, so a few are
20+
# mapped to a typed error by their response "error" slug before the status.
21+
SLUG_ERRORS = {
22+
"extension_required" => ExtensionRequiredError,
23+
"insufficient_ussd_balance" => InsufficientBalanceError
24+
}.freeze
25+
1926
attr_reader :base_url, :timeout, :default_sender
2027

2128
# token - API token (falls back to HELLIO_API_TOKEN).
@@ -176,7 +183,8 @@ def delete_webhook(id)
176183
#
177184
# client.ussd.pricing
178185
# client.ussd.rent_extension("100")
179-
# client.ussd.simulate(msisdn: "233241234567", service_code: "*920*100#", new_session: true)
186+
# client.ussd.simulate(app_id: app_id, session_id: "sess-1",
187+
# msisdn: "233241234567", new_session: true)
180188
#
181189
def ussd
182190
@ussd ||= Ussd.new(self)
@@ -250,7 +258,10 @@ def error_for(status, data)
250258
"Hellio API request failed."
251259
end
252260

261+
slug = data["error"] if data.is_a?(Hash) && data["error"].is_a?(String)
262+
253263
error_class =
264+
SLUG_ERRORS[slug] ||
254265
case status
255266
when 401 then InvalidApiTokenError
256267
when 402 then InsufficientBalanceError

lib/hellio/errors.rb

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,15 @@ def errors
2424
# 401: the API token is missing, malformed, or revoked.
2525
class InvalidApiTokenError < Error; end
2626

27-
# 402: the account balance is too low to complete the request.
27+
# 402: the account balance is too low to complete the request. For USSD
28+
# extension rentals this is the dedicated USSD balance ("insufficient_ussd_balance"),
29+
# which is separate from SMS credit and the main wallet.
2830
class InsufficientBalanceError < Error; end
2931

32+
# 402: switching a USSD app to "live" needs a purchased USSD extension first
33+
# ("extension_required"). Buy an extension, then retry the mode switch.
34+
class ExtensionRequiredError < Error; end
35+
3036
# 409: the request conflicts with the current state of a resource, e.g. renting
3137
# a USSD extension that is no longer available ("extension_unavailable").
3238
class ConflictError < Error; end

lib/hellio/ussd.rb

Lines changed: 49 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,21 @@ module Hellio
66
# session), rented extensions (the short codes subscribers dial), sessions, and
77
# a simulator for exercising an app's callback without a live dial.
88
#
9+
# Apps carry two modes, "test" and "live". A new app starts in "test" and
10+
# exposes both a `test_secret` (prefix "ussk_test_") and a `live_secret`
11+
# (prefix "ussk_live_"); `mode`/`is_live` say which one is active. Switching an
12+
# app to "live" requires a purchased USSD extension. Extension rentals draw
13+
# from a dedicated USSD balance, separate from SMS credit and the main wallet.
14+
#
915
# Requests are routed through the owning client, so responses are decoded to a
1016
# Hash with string keys (payloads under "data") and non-2xx responses raise the
1117
# same typed Hellio::Error subclasses. A rented-out extension returns 409
12-
# (Hellio::ConflictError) and an underfunded account returns 402
13-
# (Hellio::InsufficientBalanceError).
18+
# (Hellio::ConflictError), an underfunded USSD balance returns 402
19+
# (Hellio::InsufficientBalanceError, slug "insufficient_ussd_balance"), and
20+
# going live before an extension is purchased returns 402
21+
# (Hellio::ExtensionRequiredError, slug "extension_required").
22+
#
23+
# App and extension ids are UUID strings.
1424
class Ussd
1525
def initialize(client)
1626
@client = client
@@ -37,14 +47,18 @@ def apps
3747
end
3848

3949
# Create a USSD app. `callback_url` is where Hellio POSTs each session step.
50+
# The returned "data" includes the app `id` (UUID string), `name`,
51+
# `callback_url`, `mode` ("test" by default), `test_secret` ("ussk_test_..."),
52+
# `live_secret` ("ussk_live_..."), `is_live`, and `active`.
4053
def create_app(name:, callback_url:)
4154
post("ussd/apps", compact(
4255
"name" => name,
4356
"callback_url" => callback_url
4457
))
4558
end
4659

47-
# Update a USSD app. Pass only the fields you want to change.
60+
# Update a USSD app (`id` is a UUID string). Pass only the fields you want to
61+
# change.
4862
def update_app(id, name: nil, callback_url: nil, active: nil)
4963
put("ussd/apps/#{id}", compact(
5064
"name" => name,
@@ -53,7 +67,21 @@ def update_app(id, name: nil, callback_url: nil, active: nil)
5367
))
5468
end
5569

56-
# Delete a USSD app by id.
70+
# Switch a USSD app's mode (`id` is a UUID string; `mode` is "test" or
71+
# "live"). Returns the app. Switching to "live" before a USSD extension is
72+
# purchased raises Hellio::ExtensionRequiredError (402).
73+
def set_mode(id, mode)
74+
post("ussd/apps/#{id}/mode", "mode" => mode)
75+
end
76+
77+
# Rotate a USSD app's secret for one mode (`id` is a UUID string; `mode` is
78+
# "test" or "live"). Returns the app with the freshly rotated secret. The old
79+
# secret for that mode stops working immediately.
80+
def rotate_secret(id, mode)
81+
post("ussd/apps/#{id}/rotate-secret", "mode" => mode)
82+
end
83+
84+
# Delete a USSD app by id (UUID string).
5785
def delete_app(id)
5886
delete("ussd/apps/#{id}")
5987
end
@@ -65,17 +93,19 @@ def extensions
6593
get("ussd/extensions")
6694
end
6795

68-
# Rent an extension by code, optionally attaching it to an app. Raises
96+
# Rent an extension by code, optionally attaching it to an app (`app_id` is a
97+
# UUID string). Rentals draw from the dedicated USSD balance. Raises
6998
# Hellio::ConflictError (409) if the code is no longer available and
70-
# Hellio::InsufficientBalanceError (402) if the balance is too low.
99+
# Hellio::InsufficientBalanceError (402, slug "insufficient_ussd_balance") if
100+
# the USSD balance is too low.
71101
def rent_extension(code, app_id: nil)
72102
post("ussd/extensions", compact(
73103
"code" => code,
74104
"app_id" => app_id
75105
))
76106
end
77107

78-
# Release a rented extension by id.
108+
# Release a rented extension by id (UUID string).
79109
def release_extension(id)
80110
delete("ussd/extensions/#{id}")
81111
end
@@ -87,24 +117,29 @@ def sessions(status: nil)
87117
get("ussd/sessions", compact("status" => status))
88118
end
89119

90-
# Fetch a single session by id.
120+
# Fetch a single session by id (UUID string).
91121
def session(id)
92122
get("ussd/sessions/#{id}")
93123
end
94124

95125
# -------------------------------------------------------------- Simulator
96126

97-
# Simulate a USSD step against an app's callback. Pass `new_session: true`
98-
# for the first step (dialing the code) and the returned `session_id` on
99-
# follow-up steps. Returns the app's reply plus the `action` ("continue" or
127+
# Simulate a USSD step against an app's callback. `app_id` (UUID string) is
128+
# the app to drive. Pass `new_session: true` for the first step (dialing the
129+
# code) and reuse the same `session_id` on follow-up steps. `service_code` is
130+
# optional and defaults server-side to the shared short code; pass it only to
131+
# override. Simulation is always sandboxed (no charge, no extension needed).
132+
# A not-owned `app_id` raises Hellio::ValidationError (422, slug
133+
# "unknown_app"). Returns the app's reply plus the `action` ("continue" or
100134
# "end").
101-
def simulate(msisdn:, service_code:, input: nil, session_id: nil, new_session: nil)
135+
def simulate(app_id:, session_id:, msisdn:, input: "", new_session: false, service_code: nil)
102136
post("ussd/simulate", compact(
137+
"app_id" => app_id,
103138
"session_id" => session_id,
104139
"msisdn" => msisdn,
105-
"service_code" => service_code,
106140
"input" => input,
107-
"new_session" => new_session
141+
"new_session" => new_session,
142+
"service_code" => service_code
108143
))
109144
end
110145

lib/hellio/version.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# frozen_string_literal: true
22

33
module Hellio
4-
VERSION = "1.1.0"
4+
VERSION = "1.2.0"
55
end

0 commit comments

Comments
 (0)