Skip to main content

API Integration•Oct 19, 2026

Integrating VerifyET to a Telegram bot.

How we built CBE, Telebirr, Kaafi, and COOPay payment verification for a Python Telegram shop and wallet.

Telegram bot payment flow with VerifyET verification, receiving account checks, and wallet or product delivery

Telegram bot payment verification in Ethiopia connects a customer's bank receipt to a specific order or wallet deposit. In our Python bot, VerifyET checks the transaction, and the bot accepts payment only after the bank, exact ETB amount, receiving account, payment time, and duplicate checks all pass.

We built this flow for NAZE Topup, a Telegram shop with direct product checkout and wallet funding. Customers can pay through CBE, Telebirr, Kaafi eBirr, or COOPay eBirr, then send their payment proof in the same chat.

This article explains how we integrated VerifyET, how the VerifyET API skill guided the implementation, and how we handled incomplete receipts, invalid payments, delayed responses, and repeated submissions.

What the Telegram payment flow does

A receipt can describe a real transaction and still be unsuitable for an order. The amount might differ, the money might have gone to another account, or the receipt might already have paid for something else.

We therefore separated transaction verification from the shop's payment acceptance policy. VerifyET supplies the transaction evidence. The bot compares that evidence with a saved payment request before it releases a product or credits a wallet.

The customer flow follows these steps.

  1. Choose a product, or open My Wallet and select Add Balance.
  2. Choose a configured bank or wallet.
  3. Read the exact amount, receiving account, recipient label, and payment deadline.
  4. Make the transfer through that bank or wallet.
  5. Send a transaction identifier, an official receipt link, a full payment SMS, or a supported screenshot.
  6. Wait while the bot verifies the transaction and applies the payment checks.
  7. Receive a payment result. An accepted product payment enters delivery. An accepted wallet deposit adds the requested balance.

For example, a 250 ETB wallet request requires a verified 250 ETB payment to its configured receiving account. A 200 ETB receipt does not partially fund that request. A 300 ETB receipt does not automatically create an extra balance.

How we structured the integration

The Python implementation keeps transport, parsing, payment decisions, and Telegram presentation in separate modules.

ModuleResponsibility
verify_et.pyCalls VerifyET, sends authentication and idempotency headers, parses HTTP responses, and reads retry timing.
receipt_input.pyExtracts bank-specific identifiers from text, SMS messages, and supported receipt links.
local_payments.pyCreates checkouts, validates evidence, stores attempts, prevents receipt reuse, schedules retries, and commits accepted payments.
payment_ui.pyPresents bank instructions, English and Amharic guidance, status screens, and conditional error messages.
database.pyProvides durable database connections and transaction support.
NAZE_TOPUP_LOCAL_PAYMENT_DEVELOPER.pyConnects Telegram messages and buttons to payment actions, recovery, and product delivery.

The shared payment service records a purpose of either purchase or wallet_deposit. Both purposes use the same verification policy, but each has its own financial result.

This structure also gives us one place to change the API request contract and one place to change the shop's acceptance rules.

How the VerifyET API skill guided development

The VerifyET API skill gives the coding agent an integration workflow and references for authentication, bank payloads, response states, settlement matching, image processing, and error handling.

We used those rules to shape a small server-side adapter, explicit bank requests, saved idempotency keys, durable request IDs, and checks for confirmationHistory.confirmedBefore.

The skill also identifies a critical asynchronous case. POST /api/verify can return an inline result or queue a request. Our Telegram deployment handles queued work through durable polling. The skill also covers webhooks and SSE, but this bot does not add a webhook receiver or use SSE.

The receipt parser follows VerifyET's receipt-input rules for supported URLs, identifier patterns, text normalization, and composite references. Parsing prepares the request. It does not establish that a payment is valid.

For the API contract and server-side examples, see the VerifyET API documentation and REST API examples.

Step 1. Set up API keys on the bot server

We load configuration from environment variables and a .env file beside the bot. Existing process environment variables take precedence over the file.

The VerifyET key needs verification:write to submit transactions and verification:read to retrieve results. The bot sends it in the x-api-key header from its Python backend.

Our configuration template leaves credentials and receiving accounts blank.

BOT_TOKEN=
ADMIN_CHAT_ID=
VERIFY_ET_API_KEY=

VERIFY_ET_CBE_SETTLEMENT_ACCOUNT=
VERIFY_ET_CBE_RECIPIENT_NAME=
VERIFY_ET_TELEBIRR_SETTLEMENT_ACCOUNT=
VERIFY_ET_TELEBIRR_RECIPIENT_NAME=
VERIFY_ET_KAAFI_SETTLEMENT_ACCOUNT=
VERIFY_ET_KAAFI_RECIPIENT_NAME=
VERIFY_ET_COOPAY_SETTLEMENT_ACCOUNT=
VERIFY_ET_COOPAY_RECIPIENT_NAME=

DATABASE_PATH=.local/naze.sqlite3

The Telegram bot token authenticates the bot with Telegram. The VerifyET key authenticates verification calls. Supplier API keys remain separate from both.

We keep actual key values out of source files, Telegram messages, and the release archive. The archive ships a blank .env.example and excludes private configuration, databases, and logs. All examples in this article use blank settings or test values.

Only methods with a valid configured receiving account and a verification key appear in the payment chooser. Startup validation also requires at least one configured payment method.

Step 2. Identify the bank without guessing from a reference alone

The customer selects the payment method first. The bot then parses the receipt against that selected provider and sends an explicit bank value to VerifyET.

The active bot maps its button names to these API identifiers.

Telegram methodVerifyET bankText proof used by the bot
CBEcbeNew Receipt ID or supported official link. Legacy FT references require an eight-digit account suffix.
TelebirrtelebirrtransactionNumber, extracted from an identifier, supported link, or full SMS.
Kaafi eBirrkaafiebirrreferenceNumber, including the token from a /kaafimf/ receipt link.
COOPay eBirrcoopayebirrreferenceNumber, using the case-sensitive token from a /coopay/ receipt link.

For links and SMS messages, the parser checks provider evidence such as recognized receipt hosts, Ebirr tenant paths, issuer text, and identifier patterns. A supported receipt for a different selected bank returns wrong_bank.

The parser normalizes whitespace and invisible characters, preserves case-sensitive identifiers, and rejects conflicting receipt values. It does not infer the bank from whichever provider name happens to appear in unrelated text.

The reusable parser supports ten providers. The shop exposes the four methods listed above. Adding a parser rule alone does not add a payment method to the Telegram checkout.

CBE Receipt IDs and older FT references

New CBE Receipt IDs do not require an account suffix. The parser represents them as receiptNumber, and the payment adapter sends the value through the API's preferred reference field.

Older CBE FT references require both referenceNumber and an eight-digit accountSuffix. The parser can extract these from a supported composite link. If the input lacks required information, the bot asks for the complete SMS or receipt link instead of guessing hidden digits.

accountSuffix is a receipt lookup field. settlementAccount is the shop's expected receiving account. They have different purposes.

Why the COOPay SMS Ref needs special handling

COOPay's numeric SMS Ref alone is not the verification token. The bot needs the official receipt link or its case-sensitive token. A full SMS works when it includes that link.

The parser also keeps Kaafi and COOPay tenant paths distinct. A receipt on the same Ebirr host can belong to a different provider.

Step 3. Save the amount, owner, and receiving account before payment

Each checkout stores its Telegram user ID, purpose, bank, exact amount, settlement account, creation time, expiry, and order details. Purchase records also preserve the product and delivery information.

We use Decimal for monetary comparisons. Wallet requests accept positive ETB amounts with at most two decimal places and reject nonfinite or oversized values.

The proof window lasts twenty minutes from checkout creation. Returning to the screen or changing the method on a reusable checkout does not extend its deadline. Changing the purchase details can require a new checkout.

Once verification is pending, the bot blocks changes to the amount, method, or purchase details. The saved proof must continue to refer to the same payment request.

The Telegram user must own the checkout to submit proof, read its status, or resume it from history.

Step 4. Pass the settlement account and check who received the money

We configure the shop's receiving account separately for each method. The bot displays that account to the customer and saves the same value in the checkout.

Every verification payload includes the saved settlementAccount. Customer messages cannot override it.

This illustrative Telebirr request uses a test reference and a test account.

{
  "bank": "telebirr",
  "transactionNumber": "ABC1234567",
  "settlementAccount": "0912345678"
}

The same settlement field accompanies CBE, Kaafi, COOPay, and supported image submissions. Recipient names help customers recognize the destination on screen. A displayed name does not prove that the shop received the payment.

VerifyET returns receiver evidence in settlementAccountMatch. Our acceptance policy requires matched to be exactly true and ambiguous to be exactly false.

If the match includes a bank, it must agree with the checkout. If it includes matchedSettlementAccount, its normalized value must agree with the account saved in the checkout. Local Ethiopian mobile numbers are normalized for this comparison.

Missing, unmatched, or ambiguous receiver evidence produces wrong_receiver. The customer sees the correct configured account and guidance to contact support if money went elsewhere.

VerifyET can also use saved account records when a request omits settlementAccount. This bot explicitly supplies the checkout's account so each request uses the destination shown to that customer.

Step 5. Submit proof with a saved idempotency key

Before making an HTTP call, the bot saves a verification attempt. The attempt includes the normalized payload, proof fingerprint, submission time, retry information, and a unique idempotency key.

The adapter submits to the following endpoint.

POST https://verify.et/api/verify?waitMs=5000
x-api-key: <server environment value>
Idempotency-Key: naze-<saved attempt ID>

Text proof uses JSON. Supported screenshots use multipart form data with a file part named image.

Retries reuse the saved key and unchanged payload. If the first request reached VerifyET but the connection failed before the response arrived, the retry still refers to the same attempt.

The proof fingerprint combines the payload, image bytes when present, and MIME type. Repeated proof on the same checkout returns the existing pending attempt or the saved rejection. It does not create a new verification attempt each time the customer presses a button.

The transport also disables redirects. Polling uses the fixed VerifyET origin and a validated request ID rather than following an arbitrary response URL with the API key.

Step 6. Handle inline results and queued verification

waitMs=5000 gives VerifyET time to return an inline result. It does not guarantee that verification finishes during that request.

The bot handles the response according to its state.

ResponseBot behavior
200 with complete terminal evidenceEvaluate the payment acceptance rules.
202, or a queued or running processing stateSave requestId, keep the attempt pending, and poll later.
Successful inline response missing currency or confirmation historyRetrieve the canonical result through the saved request ID before deciding.
Terminal result with incomplete or invalid evidenceRefuse approval and preserve the payment reference for follow-up.

The adapter polls GET /api/verify/:requestId. A recovery loop wakes every eight seconds and selects attempts whose saved retry time and processing lease allow work. Individual requests can wait longer because of backoff or Retry-After.

Pending attempts survive restarts. An expiring lease lets recovery resume work after a worker stops, while its token prevents a stale worker from finalizing another worker's attempt.

A proof received within twenty minutes can finish verification after the deadline. The bot uses the Telegram receipt arrival time, including when downloading a screenshot takes additional time.

This deployment depends on the bot process running to poll and notify customers. A webhook deployment is another integration option, but it is separate from the polling flow described here.

Step 7. Require complete evidence before accepting payment

The _evidence function in local_payments.py applies the shop's acceptance policy. An HTTP success response alone cannot mark an order paid.

CheckRequired evidence
Terminal verificationTop-level success is true, processingStatus is completed, verification status is success, and verified is true. Result-level status must not contradict success.
BankThe verified transaction belongs to the selected provider.
AmountThe verified amount exactly equals the checkout amount.
CurrencyThe result explicitly reports ETB.
RecipientSettlement matching succeeds without ambiguity and any returned destination agrees with the checkout.
Previous confirmationconfirmationHistory.confirmedBefore is explicitly false.
Payment timeA timezone-aware transaction timestamp falls strictly after checkout creation and no later than proof submission.
Receipt identityThe result contains a usable transaction identifier.
Local receipt ownershipNo other purchase or deposit has already claimed that transaction.

Telebirr text submissions also compare the submitted transaction number with the returned receipt identifier. A mismatch returns receipt_mismatch.

The bot accepts supported response envelopes, including a single result in data or a result nested under verification metadata. A completed response containing only request metadata cannot satisfy the payment policy.

Step 8. Check whether the payment was verified before

We use VerifyET's confirmationHistory.confirmedBefore field as a repeat-confirmation signal.

If the value is true, the bot refuses automatic acceptance with already_verified. If the object or boolean is absent, the bot also refuses acceptance. A missing value cannot prove that the transaction is unused.

This policy is deliberately strict. A legitimate payment might need support review if it was previously confirmed through another verification action. The bot does not treat a repeat-confirmation signal as proof that the customer committed fraud.

We also keep a local receipt claim table with a unique key on (bank, receipt_id). It records canonical transaction identifiers and aliases, including the submitted identifier when available.

The API signal and local claim solve different problems. Confirmation history supplies VerifyET's prior-confirmation evidence. The database prevents the shop from using the same transaction for two purchases, two deposits, or one of each, even when submissions arrive concurrently.

Idempotency protects retries of a verification attempt. Receipt claims protect allocation of a payment. Both are needed.

Step 9. Return the right response for invalid proof and payments

We keep an internal outcome code separate from the message shown in Telegram. This lets the payment service record why an attempt failed while the interface gives the customer a useful next step.

Important payment instructions and errors appear in English followed by Amharic. Amounts, accounts, and references appear once so customers can read the facts without duplicated numbers.

Input and payment policy errors

ConditionOutcome codeCustomer guidance or behavior
Unreadable reference, unsupported link, missing fields, or conflicting receipt valuesinvalid_proof with parser detailsAsk for the complete SMS, official receipt link, or correct identifier. Show missing fields and the provider guide where applicable.
Receipt belongs to another providerwrong_bankAsk the customer to choose the matching payment method and submit its receipt.
Amount differs from the requestwrong_amountShow the expected amount and verified receipt amount. Ask for the matching receipt or support review.
Currency is missing or differs from ETBwrong_currencyExplain that the receipt could not be confirmed as an ETB payment.
Receiver is missing, different, or ambiguouswrong_receiverRepeat the correct configured account and explain how to seek support.
Transaction was previously confirmedalready_verifiedUse the same customer guidance as an already-used receipt.
Receipt is already claimed locallyreceipt_usedExplain that it cannot pay for another order or wallet top-up. Link the customer to payment history or support.
Payment predates checkout or follows proof submissionwrong_payment_timeAsk for the matching receipt or support review before another payment.
Missing or unusable timestampmissing_payment_timeBlock approval because the payment time cannot be established.
No usable transaction identifiermissing_receipt_idBlock approval because the receipt cannot be safely claimed.
Telebirr result differs from the submitted identifierreceipt_mismatchBlock approval.
Terminal transaction is unsuccessful or unverifiednot_verifiedRefuse acceptance without changing the wallet balance or marking the purchase paid.
Proof arrives outside the twenty-minute windowproof_expiredShow the expired request and a new-checkout action.
Checkout or deposit is no longer eligible for paymentorder_not_pending or deposit_not_pendingRefuse another financial update.

Not every internal code has dedicated bilingual copy. Detailed customer messages cover the mapped cases above. Other terminal refusals use the shared rejected-payment explanation and keep the reference available for support.

Rejecting proof does not reverse a bank transfer or automatically refund money. It means the bot has not accepted that receipt for the current request. For a wrong amount or destination, the interface directs the customer to support.

Transport, configuration, and retry errors

The transport parses JSON defensively. A network failure becomes an internal status of 0, which the payment service handles as a temporary failure.

FailureBot behavior
Network failure, HTTP 429, 500, 502, 503, or 504Keep the attempt pending and retry with exponential backoff, unless a specific terminal image error applies.
API error with retryable: trueKeep the attempt pending and respect the retry schedule.
IMAGE_PROCESSING_IN_PROGRESSKeep the existing image attempt pending.
HTTP 401, 402, or 403Return provider_setup_required, retain the attempt, and schedule a slower retry of at least fifteen minutes. The operator must resolve credentials, permissions, or credits.
Other HTTP errorsReturn provider_refused and reject the attempt.
Missing request ID or inconsistent response identityReturn missing_request_id or invalid_provider_response. Do not fulfill.
More than 120 processing tries or more than 24 hours since submissionReturn verification_timeout and direct the customer to support before paying again.

The adapter accepts Retry-After as seconds or an HTTP date. Retry timing uses the greater of that value and the local backoff. Queued responses can also provide links.pollAfterMs.

We do not automatically replace the payload or generate a new key to work around an HTTP 409. An idempotency conflict requires investigation.

For pending verification, the interface tells the customer to wait and avoid paying again. For an operator setup problem, it explains that the receipt is saved and asks the customer to contact support.

Step 10. Accept screenshots without trusting screenshot text

This bot accepts screenshots for CBE and Telebirr. Kaafi and COOPay use text proof in the current checkout.

The bot accepts JPEG, PNG, or WebP bytes up to 8 MiB. It checks the declared MIME type against the file signature before submission.

Before the first image upload, it calls GET /api/image-processing and checks that the account has Image Processing enabled and at least one image credit. Enablement and credit purchases happen through the billing owner's dashboard.

The adapter submits the file as multipart image alongside the selected bank and saved settlement account. VerifyET's image path extracts receipt information and continues transaction verification. The extracted text alone never marks a payment accepted.

Specific image failures produce specific recovery paths.

ConditionBot response
Invalid file type, signature, empty bytes, or excessive sizeReturn invalid_image and request a supported image or transaction identifier.
Image Processing disabled or image credits emptyOffer transaction ID, receipt link, or full SMS verification.
Image status check unavailableKeep the attempt for retry with image_preflight_unavailable, or operator guidance for authentication and billing failures.
IMAGE_UNREADABLE, IMAGE_FIELD_CONFLICT, or IMAGE_EXTRACTION_INCOMPLETEReject the image attempt and request clearer proof or text input.
IMAGE_PROVIDER_FAILURE or IMAGE_OCR_TIMEOUTTreat that image attempt as terminal and offer clearer proof or text input.

Image bytes remain in the private attempt only while needed for an uncertain upload retry. Once a request ID is saved, or a terminal result is recorded, the bot removes those bytes. Subsequent processing can use the request ID.

Step 11. Commit the accepted payment once

Acceptance happens inside a database transaction. The bot claims the receipt identifiers and applies the financial change together.

For a purchase, it marks the order and checkout paid and marks the verification attempt accepted. Only a newly paid purchase triggers the existing delivery path. A delivery guard checks the paid order, paid checkout, and accepted receipt claim before supplier fulfillment.

For a wallet deposit, the same transaction claims the receipt, adds the exact amount to the balance, writes one wallet ledger entry, marks the deposit credited, and marks the checkout paid.

A wallet deposit creates no product order and does not start supplier delivery or a referral reward. A direct product payment does not also add wallet balance.

If two requests race to claim the same bank transaction, the unique receipt key allows one owner. A repeated submission to an already-paid checkout returns its paid status with newly_paid set to false.

Step 12. Let customers recover payments from Telegram history

Payment state lives in SQLite, so a lost Telegram session does not erase the checkout or pending verification.

Customers can open payment history, inspect a saved request, and resume eligible receipt input using its saved amount, bank, destination, and deadline.

Checking status reads the owned checkout. It does not submit another verification, credit the wallet, or trigger supplier delivery. Closing receipt input also leaves submitted verification running.

The recovery loop processes due attempts after a restart and sends terminal results back to the customer. A failed notification does not undo a committed payment. History remains available to inspect the saved state.

How we tested the Telegram payment integration

The automated checks use temporary SQLite databases and simulated VerifyET responses. They exercise the bot's payment behavior without submitting live bank transactions.

The checks cover successful purchases and wallet deposits, wrong banks, wrong amounts, ambiguous receivers, missing evidence, prior confirmation, reused receipts, queued results, restart recovery, retry headers, image uploads, and concurrent receipt claims.

They also check that purchases do not accidentally fund wallets, deposits do not start delivery, and foreign users cannot resume another customer's payment.

The focused payment check command is reproducible from the bot's project directory.

.venv/bin/python -m unittest tests.test_verifyet_payments tests.test_verifyet_wallet tests.test_receipt_input tests.test_cbe_payments tests.test_payment_recovery_flow

Before accepting live payments, validate both inline and queued responses with your own configured VerifyET account and receiving accounts. The automated suite verifies application behavior with simulated responses.

Build your own Telegram bot payment verification flow

The integration works because the checkout retains the facts needed to judge a receipt. Bank selection, exact amounts, settlement accounts, timing, confirmation history, and receipt claims all contribute to the final decision.

For another Python Telegram bot, start with a server-side VerifyET adapter and a durable payment request. Add the receiver and duplicate checks before connecting successful verification to wallet credit or product delivery.

Use the VerifyET getting started guide to configure API access, then follow the API reference for the current request and response contracts. For the merchant decision behind this design, read manual verification versus API verification.

Common questions

How do I integrate Telebirr payment verification into a Telegram bot?

Create a server-side payment request with an exact amount and receiving account. Send the Telebirr transaction number to VerifyET with bank set to telebirr and settlementAccount set to your saved destination. Accept the payment only after terminal verification, amount, ETB currency, receiver, payment time, and duplicate checks pass.

Can a Python Telegram bot verify CBE receipts with VerifyET?

Yes. This bot accepts new CBE Receipt IDs, supported official receipt links, full SMS messages containing usable receipt information, and supported screenshots. New Receipt IDs need no account suffix. Older CBE FT references require an eight-digit account suffix.

How does the bot confirm that a payment reached our account?

The bot saves the configured receiving account in the checkout and passes it as settlementAccount on every verification. It requires settlementAccountMatch.matched to be true and ambiguous to be false. Any returned bank and matchedSettlementAccount must agree with the saved checkout.

How do we detect a payment that was verified before?

The bot checks confirmationHistory.confirmedBefore and accepts automatic payment only when the value is explicitly false. It also uses a unique local bank and receipt claim to prevent reuse across purchases and wallet deposits. A previously confirmed payment requires support review under this strict policy.

What happens when VerifyET returns a queued response?

The bot saves requestId and polls GET /api/verify/:requestId using persisted retry timing. Pending attempts survive restarts. A proof submitted before the checkout deadline can complete verification later. Customers see a pending message and guidance to avoid paying again.

Can customers submit payment screenshots in Telegram?

This integration accepts CBE and Telebirr screenshots as JPEG, PNG, or WebP files up to 8 MiB. The account needs Image Processing enabled and image credits. Extracted receipt text still goes through transaction verification and the same acceptance checks as text proof.

Does an invalid receipt automatically refund the customer?

No. Rejecting a receipt means the bot has not accepted it for the current payment request. It does not reverse a bank transfer. Customers who paid the wrong amount or sent money to another account receive support guidance.

Does this Telegram bot need a public webhook server?

The implementation described here uses durable polling from the existing bot process. It does not add a public webhook receiver. VerifyET also supports webhooks, which can be used in a separate deployment that needs server callbacks.

Where should the VerifyET API key be stored?

Store the key in the bot server environment or its private .env file. Send it in the x-api-key header from the backend. Keep it out of Telegram messages, source files, logs, and public downloads. This bot uses verification:write and verification:read permissions.