Skip to content

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_balance uses 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_balance has a value, it must cover the full SMS cost.
  • If allocated_balance is NULL, the user spends from the shared pool.
  • The shared pool is available_balance - total_allocated.
  • For postpaid clients, credit_limit adds 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:

{
  "amount": 2500.00
}

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}:

{
  "allocatedBalance": 5000.00
}

To move the user back to the shared pool:

{
  "clearAllocatedBalance": true
}

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_request with several chunks or recipients is one submission.
  • Several sms_request rows mean the client submitted more than once.
  • A COMPLETED idempotency 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_log row means the refund was credited.
  • PENDING means the aggregator will retry. Check client-service health, service discovery, and the ADMIN_API_KEY configuration.
  • FAILED means all ten attempts failed. Check attempts and last_error.
  • An aggregator row can remain PENDING or FAILED after an unclear HTTP result even when client-service applied the refund. Use its idempotency_key to check client_service.refund_log before 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.