Troubleshooting: Client Billing and Balance¶
Client-service keeps the client balance, user allocations, deductions, and refunds.
A user can spend from one of two balance pools:
- a user with
allocated_balanceuses that private allocation; - a user without an allocation uses the shared client balance.
Start with the client ID, user ID or username, and message ID.
Submission returns 402 Payment Required¶
The response contains the error balance_deduct_failed when client-service rejects the deduction.
Common causes:
- The user is not
ACTIVE. - The user's private allocation is lower than the SMS cost.
- The shared balance is lower than the SMS cost.
- A postpaid client has reached its credit limit.
Check the balance and user allocation¶
SELECT cb.available_balance,
cb.credit_limit,
cu.id AS user_id,
cu.status AS user_status,
cu.allocated_balance,
COALESCE((
SELECT SUM(u.allocated_balance)
FROM client_service.client_users u
WHERE u.client_id = cb.client_id
AND u.allocated_balance IS NOT NULL
), 0) AS total_allocated
FROM client_service.client_balances cb
JOIN client_service.client_users cu ON cu.client_id = cb.client_id
WHERE cb.client_id = :client_id
AND cu.username = :username;
Check the result:
- The user status must be
ACTIVE. - If
allocated_balancehas a value, it must cover the full SMS cost. - If
allocated_balanceisNULL, the user spends from the shared pool. - The shared pool is
available_balance - total_allocated. - For postpaid clients,
credit_limitadds extra spending room to the shared pool.
Add funds or change the user allocation¶
To add funds, call PUT /admin/clients/{clientId}/balance with the X-Admin-API-Key header:
This API adds the amount to the current balance. It has no idempotency key, so check the returned or current balance before retrying a timed-out request.
To set a user's private allocation, call PUT /admin/clients/{clientId}/users/{userId}:
To move the user back to the shared pool:
Both user-update requests require the X-Admin-API-Key header. There is currently no API to change a client's credit limit. Do not update balance or credit-limit values directly during troubleshooting; use the approved operational change process.
Client reports duplicate charges¶
The same message text does not prove a duplicate charge. Separate submissions can contain the same text, and one submission can create several chunks and recipients.
Check the router request¶
SELECT sr.id AS sms_request_id,
sr.message_id,
sr.idempotency_key,
sr.total_cost,
sr.status,
sr.submitted_at,
ir.status AS idempotency_status,
ir.expires_at
FROM sms_router.sms_request sr
LEFT JOIN sms_router.idempotency_record ir
ON ir.message_id = sr.message_id
AND ir.system_user_name = sr.system_user_name
WHERE sr.system_user_name = :username
AND (sr.message_id = :message_id
OR sr.idempotency_key = :idempotency_key)
ORDER BY sr.submitted_at DESC;
Check the result:
- One
sms_requestwith several chunks or recipients is one submission. - Several
sms_requestrows mean the client submitted more than once. - A
COMPLETEDidempotency record stores the response for 24 hours. A retry with the same key and body returns that response without another charge. - A missing key or a different key creates a new submission.
Clients should send a unique Idempotency-Key header with every POST submission and reuse the same key only when retrying the same request body.
Direct SMS deductions do not create a client_service.deduct_log row. For a direct-SMS dispute, use the router request, idempotency record, service logs, and balance observations. Do not create a ledger row manually.
Failed delivery has no refund¶
Direct SMS and campaign SMS use different refund owners:
- aggregator-integration-service handles direct SMS refunds;
- sms-router-service handles campaign refunds;
- client-service applies the credit and writes the final refund record.
Understand the refund idempotency key¶
Every internal refund request has an idempotency_key. This value identifies one specific refund. Client-service stores it in client_service.refund_log. If the same request is retried, client-service returns the existing result instead of crediting the balance again.
This is not a Redis key and is not the Idempotency-Key sent by a client when submitting an SMS.
The refunding service creates the value based on what failed:
| Refund case | Example key | Meaning |
|---|---|---|
| A complete direct-SMS chunk fails during sending | chunk:812 |
Refund the cost of chunk ID 812. |
| One direct-SMS recipient fails after a delivery report | recipient:4567 |
Refund the cost of SMS recipient ID 4567. |
| A campaign recipient fails | recipient:550e8400-e29b-41d4-a716-446655440000:2 |
Refund that campaign recipient for resend attempt 2. |
The resend attempt is part of a campaign refund key because a failed recipient can be sent and charged again. Each resend can have one refund, while retries of the same refund remain safe.
Use the exact key created by aggregator-integration-service or sms-router-service to find proof of credit:
SELECT id,
client_id,
user_id,
amount,
idempotency_key,
available_balance,
created_date
FROM client_service.refund_log
WHERE client_id = :client_id
AND idempotency_key = :refund_key;
Check a direct SMS refund¶
SELECT ar.idempotency_key,
ar.chunk_id,
ar.recipient_id,
ar.amount,
ar.reason,
ar.status AS aggregator_refund_status,
ar.attempts,
ar.last_error,
ar.refunded_at,
cr.id AS client_refund_log_id,
cr.amount AS credited_amount,
cr.available_balance AS balance_after_refund,
cr.created_date AS credited_at
FROM sms_aggregator.balance_refund_log ar
LEFT JOIN client_service.refund_log cr
ON cr.client_id = ar.client_id
AND cr.idempotency_key = ar.idempotency_key
WHERE ar.chunk_id = :chunk_id
OR ar.recipient_id = :recipient_id;
Check the result:
- A matching
client_service.refund_logrow means the refund was credited. PENDINGmeans the aggregator will retry. Check client-service health, service discovery, and theADMIN_API_KEYconfiguration.FAILEDmeans all ten attempts failed. Checkattemptsandlast_error.- An aggregator row can remain
PENDINGorFAILEDafter an unclear HTTP result even when client-service applied the refund. Use itsidempotency_keyto checkclient_service.refund_logbefore retrying or crediting manually.
Check a campaign refund¶
Check the campaign recipient:
SELECT id,
campaign_id,
status,
resend_attempt,
'recipient:' || id || ':' || resend_attempt AS refund_key,
refunded_at,
last_refund_attempt_at,
last_error_code,
last_error_message
FROM sms_router.campaign_recipient
WHERE id = :campaign_recipient_id;
For a failed campaign recipient, sms-router-service creates the refund key from the campaign recipient ID and resend_attempt. Use the refund_key returned by this query to check client_service.refund_log.
NOT_AVAILABLE is not refundable. Error code 9099 is treated as charged and is also not refundable under the current policy.
Do not use the current balance alone to decide whether a refund happened. Later charges and refunds can change it. Use client_service.refund_log as proof, and do not insert refund records manually.