STAKEHOLDER API · RELEASE 2026.9.0
Connect with confidence.
Everything you need to request access, sign requests and work with member-approved SACCO services.
Your first integration
Use the Telspay API to connect an approved stakeholder system to the SACCO services its members authorise. This release supports member information, savings statements, group membership, fixed deposits and requests that members review.
- Create an applicant account, verify your email and enable an authenticator.
- Apply for sandbox access. Select your target SACCO, permissions and integration use case.
- After approval, create a key. Save the secret securely when it is displayed.
- Call sandbox products and the synthetic member fixture. Test failures and retries.
- Apply separately for live access with exact public server IPs. Two different administrators must approve it.
- Ask each member to grant permission in Connected Apps and give you their opaque member ID.
Approval does not transfer responsibility for lending or payment verification to the integrator. Keep credentials and signing code on your server.
Environments and test data
| Environment | Base path | Data and approval |
|---|---|---|
| SANDBOX | /api/v1/sandbox | Synthetic fixtures, one administrator review; never posts to live financial tables. |
| LIVE | /api/v1/live | Same-SACCO data with current consent; two different administrators, IP allowlist and live activation. |
https://YOUR-CONFIGURED-DOMAIN/api/v1/sandbox
Member: mem_00000000000000000000000000000001
Loan product: 1 | Maximum UGX 1,000,000 | 1–12 months
Fixed product: 1 | UGX 100,000–1,000,000 | 30–365 days
Fixture savings: UGX 750,000.00
Fixture shares: UGX 100,000.00Sandbox POST requests return SIMULATED. They do not simulate loan underwriting, settlement, bank callbacks or a real member review. The fixed sandbox rate (5%) is a test fixture and is never published as a live SACCO product. Use separate keys and configuration per environment.
Sign every request
Send the key ID and a lowercase hexadecimal HMAC-SHA256 signature. The secret supplied at key creation is the HMAC key as the displayed 64-character text; do not hex-decode it. Keys expire after 90 days. Creating a replacement immediately revokes the previous key.
| Header | Rule |
|---|---|
| X-API-Key | tptest_… or tplive_… key ID. |
| X-API-Timestamp | Unix seconds; keep the server clock within ±300 seconds. |
| X-API-Nonce | Fresh random 16–96 characters [A–Z, a–z, 0–9, _, -] for every HTTP attempt. |
| X-API-Signature | 64 lowercase hexadecimal characters. |
| Idempotency-Key | Required for POST; 16–96 characters with the same allowed character set. |
| Content-Type | application/json for POST. GET has an empty body. |
Join exactly these six fields with the LF byte (
). Sign the raw URL path and query in their transmitted order; do not include scheme, host or fragment. Hash the exact bytes of the request body.
UPPERCASE_METHOD
/raw/path?query=as-transmitted
UNIX_TIMESTAMP
NONCE
LOWERCASE_SHA256_OF_RAW_BODY
IDEMPOTENCY_KEY_OR_EMPTYFor GET, the body is an empty string and the last field is empty, so the canonical string ends in LF. For POST, JSON spacing and escaping must match the transmitted body. HTTP headers alone are insufficient without the signature.
$raw = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
$canonical = implode("\n", [$method, $requestTarget, $timestamp, $nonce,
hash('sha256', $raw), $idempotencyKey]);
$signature = hash_hmac('sha256', $canonical, $secret);HTTPS is mandatory in production. Browser CORS access, OAuth tokens and public API secrets are not supported. Put your integration behind your own authenticated server.
Scopes, ownership and member consent
| Permission | Allows |
|---|---|
| products:read | SACCO loan and fixed-product catalogues; no individual member consent needed. |
| members:read | Minimal membership identity. |
| balances:read | Savings, shares, available balance and fixed principal totals. |
| statements:read | Savings statement with a transaction cursor. |
| groups:read | The member’s active group memberships. |
| fixed:read | The member’s fixed deposits. |
| loans:request | Create and read a loan intent. |
| fixed:request | Create and read a fixed-deposit intent. |
| deposits:notify | Create and read a payment notice. |
The effective permissions are the intersection of the approved client scopes and current member consent. A member grants an approved live client access for 1–90 days in Connected Apps, confirms with their transaction PIN and can revoke access there. Their integration identity appears on the same page.
A member ID is an opaque mem_… reference, not a customer database ID, email, phone number or login credential. Another SACCO’s account is never accessible through this API client. Unknown and unauthorised member IDs both return MEMBER_NOT_FOUND.
Endpoint reference
| Method | Path | Scope | Behaviour |
|---|---|---|---|
| GET | /products | products:read | Returns products assigned to the client’s SACCO. Interpret configured_rate and interest_method using SACCO policy, not as an assumed APR. |
| GET | /fixed-products | products:read | Rates are in basis points: 700 means 7.00% a year. Tax applies only to gross interest. The accepted booking terms remain fixed. |
| GET | /members/{member_id} | members:read | Requires unexpired member consent, an active member and the same SACCO as the client. |
| GET | /members/{member_id}/balances | balances:read | Savings is the signed legacy ledger total. Available excludes old active fixed holds and unposted positive entries; new fixed placements and group reservations are already debited. Final spending checks also enforce minimum balance and pending withdrawals. |
| GET | /members/{member_id}/statement | statements:read | Returns the signed savings ledger. Includes legacy records as recorded; it is not a bank settlement confirmation. Shares are not a savings transaction. |
| GET | /members/{member_id}/groups | groups:read | Returns up to 100 active memberships. Does not expose other group members’ personal accounts. |
| GET | /members/{member_id}/fixed-deposits | fixed:read | Returns up to 100 latest placements belonging to this individual member. |
| POST | /loan-intents | loans:request | The member continues in the existing PIN, KYC, affordability, guarantor and SACCO-review process. This endpoint never disburses or approves a loan. |
| POST | /fixed-deposit-intents | fixed:request | The member must accept a fresh quote and enter their PIN before funds are reserved from their own savings. |
| POST | /deposit-notices | deposits:notify | A notice is untrusted payment information. Member acknowledgement and administrator reconciliation do not automatically credit funds; post only through the existing verified payment workflow. |
| GET | /requests/{request_id} | scope for the request kind | Only this client can read its request. Live reads still require the member’s current consent for that request kind. The loan reference means submitted, not approved or disbursed. |
Append each path to the environment base URL. Money must be a decimal string in UGX. Amounts are computed in integer minor units, rounded half up at the defined interest/tax boundaries. Timestamps without a timezone suffix use UTC. Product IDs are numeric; members and requests use opaque references.
Working request examples
The PHP download includes TelspayClient.php and a CLI quickstart. Set TELSPAY_API_BASE, TELSPAY_API_KEY and TELSPAY_API_SECRET in private server configuration.
require 'TelspayClient.php';
$api = new TelspayClient(getenv('TELSPAY_API_BASE'),
getenv('TELSPAY_API_KEY'), getenv('TELSPAY_API_SECRET'));
$products = $api->request('GET', 'products');
$member = 'mem_00000000000000000000000000000001';
$balance = $api->request('GET', 'members/'.$member.'/balances');
$page = $api->request('GET', 'members/'.$member.'/statement', null,
['limit' => 25, 'after' => 0]);
// Persist with the job BEFORE the first send. Reuse only for this same action.
$jobKey = 'loan_'.bin2hex(random_bytes(16));
$intent = $api->request('POST', 'loan-intents', [
'member_id' => $member, 'product_id' => 1,
'amount' => '100000.00', 'months' => 6
], [], $jobKey);
$status = $api->request('GET', 'requests/'.$intent['data']['request_id']);POST /api/v1/live/fixed-deposit-intents
{
"member_id": "mem_REPLACE_WITH_CONSENTED_MEMBER_ID",
"product_id": 1,
"amount": "500000.00",
"matures_on": "REPLACE_WITH_FUTURE_YYYY-MM-DD"
}
POST /api/v1/live/deposit-notices
{
"member_id": "mem_REPLACE_WITH_CONSENTED_MEMBER_ID",
"amount": "100000.00",
"reference": "BANK-REFERENCE-001",
"channel": "Bank transfer"
}Replace placeholders and choose a currently published product before sending. Loan maximums and terms are validated against the SACCO catalogue; fixed maturity dates must fall within the product’s day limits.
What happens after a request
| Request | Member action | Outcome visible to the client |
|---|---|---|
| Loan intent | Review, complete existing loan application, affordability and guarantors, enter PIN. | SUBMITTED_TO_SACCO with loan:id. Lending review continues in the SACCO system. |
| Fixed-deposit intent | Review a fresh quote, accept terms, confirm PIN. | COMPLETED with fd_… after reservation from the member’s own savings. |
| Deposit notice | Acknowledge the claimed payment with PIN. | MEMBER_ACKNOWLEDGED, then REFERRED_FOR_RECONCILIATION after admin referral. Neither status proves settlement. |
| Any live intent | Member declines or seven-day review period ends. | REJECTED or EXPIRED. |
| Any sandbox intent | No real member action. | SIMULATED. |
Poll GET /requests/{request_id} at a modest interval. Webhooks are not implemented in this release. Consent must remain valid for the request kind, including status reads. A result reference is a traceable handoff, not permission to access another API object.
Pagination, retries and limits
Statement pages return ascending transaction IDs and pagination.next_after. Pass that value as after to continue; a null cursor means the current end. The default limit is 25 and maximum 100. Group and fixed-deposit lists are capped at 100 in this version.
POST requests require a durable idempotency key. Store it with the business operation before sending. Repeat the same payload and key after a timeout, with a new timestamp, nonce and signature. Reusing the key for different data returns IDEMPOTENCY_CONFLICT. Do not automatically retry with a new key.
The default client limit is 60 requests per minute, with a 120-per-minute IP authentication limit. Bodies are capped at 64 KiB. Use bounded exponential backoff with jitter for 429 and temporary 5xx responses, and retain the original idempotency key. Stop on permission or validation errors until corrected.
Clock drift, nonce reuse, a paused client, expired key, changed allowlist or revoked consent can invalidate a previously successful call. Rotating a key revokes the old key immediately; coordinate deployment of the replacement.
Errors and support
{
"error": {
"code": "MEMBER_NOT_FOUND",
"message": "Member not found or not authorised."
},
"request_id": "trace_..."
}| HTTP | Common codes | Action |
|---|---|---|
| 400 / 422 | INVALID_JSON, IDEMPOTENCY_REQUIRED, INVALID_INPUT, INVALID_AMOUNT, LOAN_LIMIT, DEPOSIT_LIMIT | Correct the payload or configured product. |
| 401 | INVALID_SIGNATURE | Check key status, environment, exact signed bytes and clock. |
| 403 | IP_DENIED, SCOPE_DENIED, ACCOUNT_UNAVAILABLE | Review allowlist, scopes and account status. |
| 404 | MEMBER_NOT_FOUND, REQUEST_NOT_FOUND, ENDPOINT_NOT_FOUND | Check ownership, current consent and exact path. |
| 409 | REPLAY_DETECTED, IDEMPOTENCY_CONFLICT | Use a fresh nonce; do not reuse an operation key with changed data. |
| 413 / 415 | BODY_TOO_LARGE, JSON_REQUIRED | Use a JSON object below 64 KiB. |
| 426 | HTTPS_REQUIRED | Use the production HTTPS endpoint. |
| 429 | RATE_LIMITED | Back off with jitter; avoid repeated authentication attempts. |
| 503 | LIVE_DISABLED, SETUP_REQUIRED | Contact the SACCO platform operator. |
| 500 | INTERNAL_ERROR | Retain the reference; retry cautiously with the same idempotency key. |
Send support the top-level request reference, endpoint, UTC timestamp and HTTP status. Do not send API secrets, signatures, member PINs, raw statements or full personal records.
Integration security responsibilities
- Keep secrets in a server secret manager or private configuration. Never commit them, embed them in mobile apps or send them in URLs.
- Use distinct sandbox/live keys, minimum scopes, exact server allowlists and HTTPS certificate verification.
- Protect your own staff accounts with MFA. Minimise stored member data and honour consent and purpose restrictions.
- Log request references and error codes, rather than credentials or personal payloads.
- Test negative cases: other-member access, expired consent, wrong environment, altered body, repeated nonce and duplicate job delivery.
- Use only the existing verified bank/payment process to recognise incoming funds. An API notice is never payment evidence.
Controls follow the principles in the OWASP REST Security Cheat Sheet and OWASP API Security Top 10. They are not a security certification. Operators must complete deployment review, backups, monitoring and runtime support checks before live use.