Production-minded backend for one-time messenger account authorization from BotMarketing to RetailCRM.
- Python 3.12
- FastAPI
- httpx
- pydantic
- SQLAlchemy + SQLite
- uvicorn
- python-dotenv
- standard
logging
app/
main.py
config.py
schemas.py
models.py
db.py
routers/auth.py
services/retailcrm.py
services/deduper.py
services/greensms.py
services/recommended_offer_catalog.py
services/recommended_orders.py
services/otp.py
services/messenger_identity.py
services/phone.py
utils/masking.py
utils/time.py
GET /healthGET /auth/statusPOST /auth/startPOST /auth/checkPOST /auth/resendPOST /auth/unlinkGET /ordersPOST /orders/repeatPOST /orders/recommended/previewPOST /orders/recommended/createPOST /orders/recommended/startPOST /orders/recommended/check
POST endpoints accept both application/json and application/x-www-form-urlencoded.
Copy .env.example to .env and fill real values:
cp .env.example .envImportant values:
RETAILCRM_BASE_URL: RetailCRM account base URL, for examplehttps://example.retailcrm.ruRETAILCRM_API_KEY: RetailCRM API keyRETAILCRM_SITE: RetailCRM site code if your account requires itRETAILCRM_CF_TELEGRAM_ID: custom field code for Telegram stable account idRETAILCRM_CF_VK_ID: custom field code for VK stable account idRETAILCRM_CF_WHATSAPP_ID: custom field code for WhatsApp stable account idRETAILCRM_CF_MAX_ID: custom field code for MAX stable account idRETAILCRM_CF_INSTAGRAM_ID: custom field code for Instagram stable account idRETAILCRM_CF_LAST_VERIFIED_CHANNEL: service custom field for the last verified channelRETAILCRM_CF_LAST_VERIFIED_AT: service custom field for verification datetimeRETAILCRM_CF_LAST_VERIFICATION_METHOD: service custom field for the actual OTP methodGREENSMS_TOKEN: GreenSMS JWT/bearer token, if you use token authGREENSMS_USERandGREENSMS_PASS: GreenSMS username/password, if you use user/pass authGREENSMS_SMS_FROM: approved SMS sender nameGREENSMS_VK_FROM: approved VK sender shortname, required by GreenSMS/vk/sendDEDUPE_BASE_URL: deduper API base URL, for examplehttps://dedupe.example.comDEDUPE_API_KEY: API key sent asX-API-Keyto the deduperDEDUPE_TIMEOUT_SECONDS: bounded timeout for deduper calls, default10RETAILCRM_ORDER_EXCLUDED_STATUS_CODES: comma-separated order status codes hidden from/ordersRETAILCRM_ORDER_EXCLUDED_STATUS_NAMES: comma-separated order status names hidden from/ordersRETAILCRM_ORDER_EXCLUDED_GROUP_CODES: comma-separated RetailCRM status group codes hidden from/ordersRETAILCRM_ORDER_GROUP_COMPLETE_CODES: status group codes rendered ascompleteRETAILCRM_ORDER_GROUP_DELIVERY_CODES: status group codes rendered asdeliveryRETAILCRM_ORDER_GROUP_COMPLETE_NAMES: status group names rendered ascompleteRETAILCRM_ORDER_GROUP_DELIVERY_NAMES: status group names rendered asdeliveryORDERS_DEFAULT_LIMIT: default number of orders shown by/orders, max 5ORDERS_MAX_LOOKBACK: how many recent CRM orders to fetch before local filtering; RetailCRM supports page sizes 20, 50, 100REPEAT_ORDER_NUMBER_PREFIX: prefix for repeated order numbers, defaultCHTREPEAT_ORDER_NUMBER_SUFFIX: suffix for repeated order numbers, default-BOTREPEAT_ORDER_COMMENT_TEMPLATE: manager-facing comment template for repeated orders
Repeated order numbers are generated as CHT<short random value>-BOT by default. The original RetailCRM order number is kept in sourceOrderNumber and manager comments, but is not embedded into the new order number.
GreenSMS official docs are at https://api3.greensms.ru/. The implemented calls use documented endpoints:
POST /telegram/send:to,txt, optionalttlPOST /vk/send:to,txt,fromPOST /sms/send:to,txt, optionalfrom- Auth:
Authorization: Bearer <token>oruser/pass
Stable messenger identity is (channel.type, customer.external_id).
The backend extracts the messenger account id in this order:
messengerUserIdcustomerExternalIdmessengerAccountIdmessengerAccountIdFallback
messengerAccountIdFallback is intended for BotMarketing's last_message.from.external_id fallback. chatExternalId is only a service/chat identifier and is never used as the CRM binding key. Username, first name and display name are not accepted for authorization.
Final channel mapping:
| channelType | RetailCRM field env | Default field code |
|---|---|---|
telegram |
RETAILCRM_CF_TELEGRAM_ID |
bot_telegram_account_id |
vk |
RETAILCRM_CF_VK_ID |
bot_vk_account_id |
whatsapp |
RETAILCRM_CF_WHATSAPP_ID |
bot_whatsapp_account_id |
max |
RETAILCRM_CF_MAX_ID |
bot_max_account_id |
instagram |
RETAILCRM_CF_INSTAGRAM_ID |
bot_instagram_account_id |
Service fields written after successful /auth/check:
| Purpose | RetailCRM field env | Default field code |
|---|---|---|
| Last verified channel | RETAILCRM_CF_LAST_VERIFIED_CHANNEL |
bot_last_verified_channel |
| Last verified datetime | RETAILCRM_CF_LAST_VERIFIED_AT |
bot_last_verified_at |
| Actual verification method | RETAILCRM_CF_LAST_VERIFICATION_METHOD |
bot_last_verification_method |
bot_last_verification_method stores the actual route that accepted the code delivery request: telegram_otp, vk_otp, or sms_otp. If Telegram/VK falls back to SMS, the stored value is sms_otp.
The backend also marks customer chatbot interaction in RetailCRM best-effort by setting customer custom field chatbot_interacted=true. This is a customer field, not an order field. Marking is attempted when a customer can be safely resolved by explicit customerId, current messenger binding, successful auth/check, orders/repeat flows, or recommended order flows. Marking errors are logged and do not fail the main BotMarketing scenario.
/auth/start keeps the existing behavior for clear phone lookup results:
- 0 RetailCRM customers: creates the current anti-enumeration fake OTP session and does not send a code.
- 1 RetailCRM customer: creates a normal OTP session for that customer.
- More than 1 RetailCRM customer: calls
POST {DEDUPE_BASE_URL}/api/dedupe/auto-resolve.
Deduper request:
{
"phone": "79991234567",
"site": "your-site",
"requestSource": "auth-backend",
"requestId": "uuid4",
"waitForConfirmation": false
}The request is sent with X-API-Key: {DEDUPE_API_KEY} and uses DEDUPE_TIMEOUT_SECONDS.
Usable deduper statuses are single, resolved, and resolved_with_fallback_primary. For these statuses, finalCustomerId is saved into the OTP session as crm_customer_id. /auth/check then uses this session value and does not search by phone again.
If the deduper returns not_found, error, an empty finalCustomerId, times out, or is unavailable, /auth/start returns status="error" with a neutral message and does not send an OTP.
python -m venv .venv
.\.venv\Scripts\Activate.ps1
pip install -r requirements.txt
cp .env.example .env
uvicorn app.main:app --host 0.0.0.0 --port 8000SQLite tables are created automatically on startup.
cp .env.example .env
docker compose up -d --buildThe SQLite database is persisted in ./data.
Example unit:
[Unit]
Description=Gurugrow Auth Backend
After=network.target
[Service]
WorkingDirectory=/opt/gurugrow-auth
EnvironmentFile=/opt/gurugrow-auth/.env
ExecStart=/opt/gurugrow-auth/.venv/bin/uvicorn app.main:app --host ${APP_HOST} --port ${APP_PORT}
Restart=always
RestartSec=5
User=www-data
[Install]
WantedBy=multi-user.targetReplace YOUR-AUTH-SERVER.example.com in BotMarketing with your backend host:
https://your-host.example.com/auth/statushttps://your-host.example.com/auth/starthttps://your-host.example.com/auth/checkhttps://your-host.example.com/auth/resend
Use application/x-www-form-urlencoded for POST requests. Returned fields are stable:
authStatusResponse.statusauthStatusResponse.customerIdauthStatusResponse.maskedPhoneauthStatusResponse.errorCodeauthStatusResponse.messageauthStartResponse.statusauthStartResponse.sessionIdauthStartResponse.maskedPhoneauthStartResponse.retryAfterSecauthStartResponse.errorCodeauthStartResponse.messageauthCheckResponse.statusauthCheckResponse.authorizedauthCheckResponse.customerIdauthCheckResponse.verifiedUntilauthCheckResponse.maskedPhoneauthCheckResponse.errorCodeauthCheckResponse.messageauthResendResponse.statusauthResendResponse.retryAfterSecauthResendResponse.maskedPhoneauthResendResponse.errorCodeauthResendResponse.message
Accepted identity input fields are tolerant for BotMarketing scenarios: messengerUserId, customerExternalId, messengerAccountId, and messengerAccountIdFallback.
For the "My orders" block, configure BotMarketing to call:
http://your-host:8000/ordershttp://your-host:8000/orders/repeat
Required query params:
channelType- one stable identity value: preferably
messengerUserIdorcustomerExternalId
Optional query params:
chatExternalIdcustomerIdlimit, 1..5
Important: /orders does not trust customerId by itself. It first resolves the customer through the current messenger binding, then uses customerId only as a consistency check. If the supplied customerId differs from the bound CRM customer, the endpoint returns status="conflict" and does not return orders.
/orders/repeat uses the same rule. It first resolves the bound customer through messenger identity, validates optional customerId, loads sourceOrderId or fallback sourceOrderNumber, and checks that the source order belongs to the bound customer before creating anything.
/auth/unlink also uses the current messenger binding as the access key. It finds the CRM customer by channelType + messenger account id; optional customerId is only a consistency check. On success it clears only the channel binding field, for example bot_telegram_account_id. It does not clear bot_last_verified_channel, bot_last_verified_at, or bot_last_verification_method.
BotMarketing can create orders for recommended sets through:
POST /orders/recommended/previewPOST /orders/recommended/createPOST /orders/recommended/startPOST /orders/recommended/check
The frontend sends only offerCode. The mapping offerCode -> bundleArticle/title/components lives in app/services/recommended_offer_catalog.py. components are used only for preview text. Before order creation, the backend resolves bundleArticle through RetailCRM /api/v5/store/products; the order receives a real SKU reference through offer.id, offer.externalId, or offer.xmlId with quantity=1, so RetailCRM recalculates current prices from the catalog. If the bundle article is not found in RetailCRM, the order is not created as a free noname item.
Recommended orders are created with:
site:1minoxidil-rustatus:ruchnaia-obrabotka- no manager assignment
- no delivery/address
customerComment:Заказ создан чат-ботом по рекомендации: {offerCode}
For an authorized customer, BotMarketing calls /orders/recommended/preview, then /orders/recommended/create. The backend resolves the current customer through messenger binding and treats customerId only as a consistency check.
For a non-authorized customer, BotMarketing calls /orders/recommended/start, then /orders/recommended/check. start sends an OTP and stores offerCode, recipient data, normalized phone, optional resolved CRM customer id, and a flag to create a customer after successful OTP. check verifies the OTP, creates a CRM customer if needed, binds the messenger account, and creates the order.
Invalid email values are ignored and treated as empty. If RetailCRM order creation fails with a transient error, the backend does one retry. If the retry also fails, the response contains manager handoff text.
Health:
curl "http://localhost:8000/health"Status:
curl "http://localhost:8000/auth/status?channelType=telegram&chatExternalId=chat-1&customerExternalId=cust-1&messengerUserId=123456789"Start, urlencoded:
curl -X POST "http://localhost:8000/auth/start" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "phone=+7 999 123-45-67" \
-d "channelType=telegram" \
-d "deliveryRoute=telegram" \
-d "chatExternalId=chat-1" \
-d "customerExternalId=cust-1" \
-d "messengerUserId=123456789"Start, JSON:
curl -X POST "http://localhost:8000/auth/start" \
-H "Content-Type: application/json" \
-d '{"phone":"+7 999 123-45-67","channelType":"telegram","deliveryRoute":"telegram","chatExternalId":"chat-1","customerExternalId":"cust-1","messengerUserId":"123456789"}'If RetailCRM has duplicates for that phone, the same /auth/start request goes through the deduper automatically before the OTP is sent:
curl -X POST "http://localhost:8000/auth/start" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "phone=+7 999 123-45-67" \
-d "channelType=telegram" \
-d "deliveryRoute=telegram" \
-d "chatExternalId=chat-1" \
-d "customerExternalId=123456789" \
-d "messengerUserId=123456789"Check, urlencoded:
curl -X POST "http://localhost:8000/auth/check" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "sessionId=SESSION_ID" \
-d "code=123456" \
-d "channelType=telegram" \
-d "chatExternalId=chat-1" \
-d "messengerUserId=123456789"Resend, urlencoded:
curl -X POST "http://localhost:8000/auth/resend" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "sessionId=SESSION_ID" \
-d "channelType=telegram" \
-d "deliveryRoute=telegram"Unlink, urlencoded:
curl -X POST "http://localhost:8000/auth/unlink" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "channelType=telegram" \
-d "customerExternalId=73110987" \
-d "messengerUserId=73110987" \
-d "customerId=626518"Orders:
curl "http://localhost:8000/orders?channelType=telegram&customerExternalId=123456789&messengerUserId=123456789&chatExternalId=chat-1&customerId=626518"Repeat order, urlencoded:
curl -X POST "http://localhost:8000/orders/repeat" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "channelType=telegram" \
-d "customerExternalId=123456789" \
-d "messengerUserId=123456789" \
-d "customerId=626518" \
-d "sourceOrderId=12345"Repeat order, JSON:
curl -X POST "http://localhost:8000/orders/repeat" \
-H "Content-Type: application/json" \
-d '{"channelType":"telegram","customerExternalId":"123456789","messengerUserId":"123456789","customerId":"626518","sourceOrderId":"12345"}'Recommended preview:
curl -X POST "http://localhost:8000/orders/recommended/preview" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "offerCode=male_hairloss_basic" \
-d "channelType=telegram" \
-d "customerExternalId=123456789" \
-d "messengerUserId=123456789" \
-d "customerId=626518"Recommended create for authorized customer:
curl -X POST "http://localhost:8000/orders/recommended/create" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "offerCode=male_hairloss_basic" \
-d "customerId=626518" \
-d "firstName=Иван" \
-d "lastName=Иванов" \
-d "phone=79991234567" \
-d "email=mail@example.com" \
-d "channelType=telegram" \
-d "customerExternalId=123456789" \
-d "messengerUserId=123456789"Recommended start for non-authorized customer:
curl -X POST "http://localhost:8000/orders/recommended/start" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "offerCode=male_hairloss_basic" \
-d "firstName=Иван" \
-d "lastName=Иванов" \
-d "phone=79991234567" \
-d "email=mail@example.com" \
-d "channelType=telegram" \
-d "customerExternalId=123456789" \
-d "messengerUserId=123456789" \
-d "deliveryRoute=telegram"Recommended check:
curl -X POST "http://localhost:8000/orders/recommended/check" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "sessionId=SESSION_ID" \
-d "code=123456" \
-d "channelType=telegram" \
-d "customerExternalId=123456789" \
-d "messengerUserId=123456789"Successful /orders response:
{
"status": "ok",
"customerId": "626518",
"shownCount": 1,
"orders": [
{
"sourceOrderId": "12345",
"sourceOrderNumber": "12345",
"orderNumber": "12345",
"createdAt": "2026-06-01T10:00:00Z",
"createdAtText": "01.06.2026",
"itemsText": "• Миноксидил 5% x1",
"deliveryAddressText": "Москва, Тверская",
"statusGroup": "delivery",
"statusLabel": "В доставке",
"deliveryCode": "pr-rs",
"trackingCode": "TRACK123456",
"trackingUrl": "https://www.pochta.ru/tracking?barcode=TRACK123456",
"canTrackOnline": true,
"detailText": "Вот этот заказ 👇\n\n12345\n01.06.2026\nВ доставке\n\n• Миноксидил 5% x1\n\nПочта РФ: Москва, Тверская\n\nЧто сделаем?",
"repeatConfirmText": "Заказ 12345\n\nВот этот заказ 👇\n\n12345\n01.06.2026\n\n🗒️Состав:\n• Миноксидил 5% x1\n\n🚙Доставка:\nМосква, Тверская\n\nУверены, что хотите его повторить?",
"trackingText": "📬 Ваш заказ уже едет к вам\n\nВот ссылка для отслеживания 👇\nhttps://www.pochta.ru/tracking?barcode=TRACK123456",
"trackingNumber": "TRACK123456",
"deliveryStatus": "В доставке"
}
],
"text": "Твои заказы 👇\n\nМожно отследить текущий заказ или быстро повторить прошлый.\n\n🗒️Номер заказа: 12345\n📆Оформлен: 01.06.2026\n\n🚚 Уже едет к вам\n\n• Миноксидил 5% x1\n\n🚙Москва, Тверская\n🔍Отследить заказ: https://www.pochta.ru/tracking?barcode=TRACK123456",
"errorCode": null,
"message": null
}Each order contains:
orderNumbersourceOrderIdsourceOrderNumbercreatedAtcreatedAtTextitemsTextdeliveryAddressTextstatusGroup:assembling,delivery, orcompletestatusLabeldeliveryCodetrackingCodetrackingUrlcanTrackOnlinedetailTextrepeatConfirmText: clean confirmation text for BotMarketing repeat-order confirmation; unlikedetailText, it does not include status or the "Что сделаем?" prompttrackingText- compatibility aliases:
trackingNumber,deliveryStatus
Tracking URLs are built from deliveryCode and trackingCode:
5post:https://fivepost.ru/tracking/?id={trackingCode}pr-rs:https://www.pochta.ru/tracking?barcode={trackingCode}sdek-v-2-7b30522f5b0a597f679345a92b57c324:https://www.cdek.ru/ru/tracking/?order_id={trackingCode}
For other delivery codes, or when there is no tracking code, trackingUrl=null and canTrackOnline=false.
Status groups are resolved through RetailCRM /api/v5/reference/statuses:
complete: group code/name configured byRETAILCRM_ORDER_GROUP_COMPLETE_*delivery: group code/name configured byRETAILCRM_ORDER_GROUP_DELIVERY_*assembling: every other non-excluded status group
/orders statuses:
ok: orders found and returnedempty: authorized customer has no visible non-excluded orders in the lookback windownot_authorized: messenger account is not bound to a CRM customerconflict: ambiguous binding orcustomerIdmismatcherror: CRM or request error
/auth/unlink response on success:
{
"status": "unlinked",
"customerId": "626518",
"channelType": "telegram",
"text": "Учётная запись отвязана. Для входа в защищённые разделы потребуется снова подтвердить номер телефона.",
"errorCode": null,
"message": null
}/auth/unlink statuses:
unlinked: current messenger account binding was clearednot_authorized: current messenger account is already not boundconflict: ambiguous binding orcustomerIdmismatcherror: unsupported channel, invalid identity, or RetailCRM error
/orders/repeat response on success:
{
"status": "created",
"customerId": "626518",
"sourceOrderId": "123456",
"sourceOrderNumber": "ELM278345",
"newOrderId": "987654",
"newOrderNumber": "CHT48291357-BOT",
"unavailableItemName": null,
"text": "Заказ оформлен! 👍\n\nНомер вашего заказа: CHT48291357-BOT\n\nПока что мы не можем оформить оплату через бота, поэтому придётся подключить менеджера. Уже спешим на помощь! 🦸\n\nА если хотите что-то поменять, то можете пока написать, что нужно скорректировать, мы обязательно поможем.",
"errorCode": null,
"message": null
}Repeated order creation:
- Copies items and quantities, delivery address, phone, recipient name, and email.
- Does not copy historical prices, old manager, or old status.
- Creates the new order in status
new. - Sends only offer id/externalId/xmlId and quantity for items, so RetailCRM can recalculate current prices.
- Builds number as
CHT<short random value>-BOT. - If RetailCRM rejects the number as duplicate, retries with a newly generated random value.
- Adds manager-facing
customerCommentfromREPEAT_ORDER_COMMENT_TEMPLATE.
If RetailCRM rejects an item because it is missing/unavailable, /orders/repeat returns status="unavailable_item" and does not create a partial order.
- OTP codes are stored only as PBKDF2-SHA256 hashes with per-code salt.
- OTP sessions are stored in SQLite, not process memory.
- New OTP invalidates previous active OTP for the same phone and messenger identity.
- Username, first name and display name are never used for authorization.
messengerUserIdis preferred;customerExternalIdis the primary BotMarketing stable identity;messengerAccountIdandmessengerAccountIdFallbackare compatibility aliases;chatExternalIdis auxiliary.- WhatsApp
customer.external_idis stored as a string even when it looks like a phone number. - If a phone is not found in RetailCRM, the backend returns neutral
status="sent"and creates a fake session without sending a code. - If several CRM customers are found by phone,
/auth/startcalls the configured deduper once and stores only the resolvedfinalCustomerIdin the OTP session. - Conflicts in RetailCRM return
status="conflict"and do not bind anything. /auth/unlinknever unlinks by externalcustomerIdalone; it first resolves the current messenger binding and clears only the mapped channel custom field./ordersnever uses externalcustomerIdas the access key; access is based on messenger binding only./orders/repeatnever trusts externalcustomerId,sourceOrderId, orsourceOrderNumberwithout checking them against the current messenger binding.
- Verify your real RetailCRM custom field codes and put them into
RETAILCRM_CF_*. - Confirm whether your RetailCRM API key has read/write access to customers and custom fields.
- Confirm your GreenSMS authentication mode: bearer token or
user/pass. - Confirm approved GreenSMS sender names:
GREENSMS_SMS_FROMandGREENSMS_VK_FROM. - GreenSMS docs expose
cascadeparameters but do not fully specify safe values for every route in the snippets used here. This backend performs application-level fallback to SMS only when the initial Telegram/VK request fails synchronously. If you need asynchronous fallback on delivery failure, add GreenSMS status polling or a documented cascade configuration after confirming the exact official parameters for your account. - RetailCRM phone lookup uses documented customer search plus exact local normalization against
customer.phones[].number; if your account stores phone data differently, adjustRetailCRMClient.find_customers_by_phone. - Deduper integration assumes
POST /api/dedupe/auto-resolvereturnsstatus,finalCustomerId, optionalrequestId, and optionalwarnings. If your deduper response shape differs, adjustapp/services/deduper.py. - RetailCRM delivery integrations differ.
/ordersextracts tracking and address only from fields that are actually present in the order payload, such asdelivery.code,delivery.integrationCode,delivery.trackNumber,delivery.trackingNumber,delivery.data.trackNumber, anddelivery.address. If your delivery integration stores these values elsewhere, extendapp/services/orders.pywith that documented field path. - RetailCRM create-order validation can vary by catalog and delivery integration.
/orders/repeatbuilds a minimal payload with customer id, current items by offer id/externalId/xmlId, quantities, delivery address, phone, name and email. If your account requires additional mandatory fields for bot-created orders, extendapp/services/order_repeat.py.