pay.send_money_upi — Full Intent Specification
INTENT NAMESPACE: pay
INTENT NAME: send_money_upi
FULL ID: pay.send_money_upi
VERSION: v1.0.0
STATUS: live
TTBS WEIGHTS: time 0.40 · taste 0.05 · budget 0.15 · safety 0.40
LAST UPDATED: 2026-05-10
Cross-cutting utility intent. UPI peer-to-peer transfer (P2P) or peer-to-merchant (P2M). User initiates; TOMO orchestrates the partner choice + UX. TOMO never holds the rail — money flows from user's bank → recipient's bank via NPCI rails. TOMO's role is intent classification + partner choice + audit log.
1. NATURAL LANGUAGE COVERAGE
Classifies IN
- "send 500 to ravi"
- "pay 1200 to electrician"
- "transfer 8000 to mom via UPI"
- "send money to phone number 9876543210"
- "pay UPI ID xyz@hdfcbank"
- "₹2000 to my brother"
- "scan QR and pay 450"
Classifies OUT — borderline NO
- "split dinner bill" →
pay.split_bill - "top up FASTag" →
pay.fastag_topup - "pay electricity bill" →
pay.utility_bill_pay - "wallet recharge" →
pay.add_money_to_wallet - "buy hotel" →
travel.book_hotel(TOMO routes through partner's payment, not standalone UPI)
MULTI-INTENT TRIGGERS
- "send 500 to ravi for cab" →
pay.send_money_upi+ audit-link tomobility.book_intracity_ride - "pay maid 5000 monthly" →
pay.send_money_upi(standalone) — recurring set up via partner - "split dinner with friends" →
pay.split_bill(multi-recipient orchestrator that callspay.send_money_upiper friend)
2. INPUT — TOMO → PROVIDER
{
"intent": "pay.send_money_upi",
"intent_version": "v1.0.0",
"request_id": "req_upi_8f4k_2026-05-10T10:14:00Z",
"user_session_id": "anon_user_token_or_uid",
"amount_inr": 500,
"amount_locked": true,
"recipient": {
"kind": "upi_id",
"upi_id_masked": "rav•••@hdfcbank",
"phone_masked": "+91 98XXX XX234",
"vpa_resolved_name_redacted": "REDACTED",
"verified": true,
"verification_method": "vpa_resolution",
"is_self_account": false,
"is_repeat_recipient": true,
"prior_transfers_count": 4,
"last_transfer_iso": "2026-04-12T14:32:00+05:30"
},
"transfer_kind": "p2p",
"transfer_purpose": "personal_transfer",
"note": "Lunch reimbursement",
"context": {
"user_locale": "en-IN",
"user_currency_pref": "INR",
"trust_signals": {
"is_repeat_customer": true,
"prior_upi_count_30day": 24,
"user_account_age_days": 312,
"device_is_trusted": true,
"biometric_used_recently": true
}
}
}
| Field | Type | Constraint | Notes |
|---|---|---|---|
amount_inr |
INR_INTEGER | REQUIRED, ≥1 | UPI minimum is ₹1 |
amount_locked |
bool | REQUIRED | true = user typed amount; false = recipient's QR has amount |
recipient.kind |
enum | REQUIRED, STRICT upi_id | phone | qr_intent | account_ifsc | beneficiary_id |
|
recipient.upi_id_masked |
string | REQUIRED if kind=upi_id | last-4 chars before @ |
recipient.phone_masked |
string | REQUIRED if kind=phone | last-4 |
recipient.vpa_resolved_name_redacted |
string | REQUIRED, always REDACTED | actual name flows partner-direct via secure channel |
recipient.verified |
bool | REQUIRED | true if VPA already resolved |
recipient.verification_method |
enum | REQUIRED, see §6 | |
recipient.is_self_account |
bool | REQUIRED | true = user's own account (drive different limits) |
transfer_kind |
enum | REQUIRED, STRICT p2p | p2m |
|
transfer_purpose |
enum | REQUIRED, see §6 | drives risk scoring + reporting |
note |
string | REQUIRED, may be empty | passed verbatim to recipient as transaction note |
context.trust_signals.biometric_used_recently |
bool | REQUIRED | drives 2FA path choice |
Anti-fabrication preamble: no paid placement, no commission-based partner ordering, TOMO never holds the rail.
3. PROVIDER TOOLS
Tool 1: resolve_vpa
PURPOSE: resolve UPI ID / phone to verified VPA + recipient name
INPUT: { recipient.kind, recipient_id, request_id, user_session_id }
OUTPUT: { vpa_resolved, recipient_name_redacted, bank_name, vpa_status }
SLA: p95 < 800ms (NPCI dependency)
RATE LIMIT: ≤ 2/sec per (user, partner)
Tool 2: initiate_transfer
PURPOSE: create UPI transfer + return collect/intent URL
INPUT: { amount_inr, recipient, transfer_purpose, note, idempotency_key, request_id }
OUTPUT: { transfer_ref, status, payment_intent_url, expected_clearing_seconds }
SLA: p95 < 2000ms
IDEMPOTENCY: REQUIRED on idempotency_key
Tool 3: confirm_transfer
PURPOSE: finalize after user authorizes via UPI app
INPUT: { transfer_ref, npci_reference_id, request_id }
OUTPUT: TransferStatus (§5)
SLA: p95 < 1500ms
Tool 4: get_transfer_status
PURPOSE: poll status if confirm_transfer hasn't returned final
INPUT: { transfer_ref, request_id }
OUTPUT: TransferStatus (§5)
SLA: p95 < 500ms
RATE LIMIT: ≤ 1 every 3s
Tool 5: cancel_transfer
PURPOSE: user-cancel before debit (rare, only pre-auth)
INPUT: { transfer_ref, reason, request_id }
OUTPUT: { status, refund_initiated }
SLA: p95 < 1500ms
Tool 6: request_refund
PURPOSE: initiate reversal post-debit (recipient bank may decline)
INPUT: { transfer_ref, reason, request_id, user_consent_token }
OUTPUT: { refund_status, refund_eta_minutes, recipient_response_required }
SLA: p95 < 5000ms
All six REQUIRED.
4. RESPONSE SHAPE
initiate_transfer output
transfer_ref: string, REQUIRED
status: STRICT ENUM, REQUIRED # see §6
payment_intent_url: URL, REQUIRED # opens user's UPI app
intent_kind: STRICT ENUM, REQUIRED # see §6
expected_clearing_seconds: int, REQUIRED # typical NPCI clearing time
intent_expires_at: ISO_DATETIME, REQUIRED # how long the intent URL is valid
amount:
amount_inr: INR_INTEGER, REQUIRED
currency: string, REQUIRED, always "INR"
total_charged_to_user_inr: INR_INTEGER, REQUIRED # = amount_inr (UPI is free)
partner_fee_inr: INR_INTEGER, REQUIRED # 0 for personal UPI typically
gst_inr: INR_INTEGER, REQUIRED # 0 typically
limits:
daily_remaining_inr: INR_INTEGER, REQUIRED # user's remaining UPI day-limit
per_transaction_max_inr: INR_INTEGER, REQUIRED # ₹1L default, ₹5L for some banks
monthly_remaining_inr: INR_INTEGER, REQUIRED
cooling_period_required_seconds: int, REQUIRED # 0 typically; 30s for new contacts
cooling_period_reason: STRICT ENUM, REQUIRED # see §6 ("none" if no cooling)
risk:
risk_score: int, REQUIRED, 0-100
risk_signals: array<enum>, REQUIRED, may be empty # see §6
cooling_off_required: boolean, REQUIRED # NPCI rule for new contacts > ₹2000
manual_review_required: boolean, REQUIRED
trust:
partner_npci_authorized_psp: boolean, REQUIRED
partner_npci_member_kind: STRICT ENUM, REQUIRED # see §6
partner_pci_dss_compliant: boolean, REQUIRED
partner_pci_dss_level: STRICT ENUM, REQUIRED # see §6
rbi_authorization_number: string, REQUIRED
rbi_authorization_kind: STRICT ENUM, REQUIRED # see §6
_provider:
name: string, REQUIRED # "PhonePe", "GPay", etc.
tomo_partner_id: string, REQUIRED
partner_tier: STRICT ENUM, REQUIRED
customer_support_phone: string, REQUIRED
customer_support_24x7: boolean, REQUIRED # MUST be true for UPI partners
in_app_chat_supported: boolean, REQUIRED
partner_npci_uptime_pct: float, REQUIRED # last 30 days
TransferStatus (returned by confirm + status + cancel)
transfer_ref: string, REQUIRED
status: STRICT ENUM, REQUIRED # see §6
status_updated_iso: ISO_DATETIME, REQUIRED
status_history: array, REQUIRED, ≥1
- status: STRICT ENUM, REQUIRED
iso: ISO_DATETIME, REQUIRED
notes: string, REQUIRED # "" allowed
debit:
debit_status: STRICT ENUM, REQUIRED # see §6
debit_iso: ISO_DATETIME, REQUIRED # epoch sentinel if pending
user_bank: string, REQUIRED
user_bank_reference: string, REQUIRED # bank-side ref number
credit:
credit_status: STRICT ENUM, REQUIRED # see §6
credit_iso: ISO_DATETIME, REQUIRED # epoch sentinel if pending
recipient_bank: string, REQUIRED
recipient_bank_reference: string, REQUIRED
npci:
npci_reference_id: string, REQUIRED # standard 12-digit UPI ref
npci_response_code: STRICT ENUM, REQUIRED # see §6
npci_clearing_iso: ISO_DATETIME, REQUIRED # epoch sentinel if pending
failure:
failure_reason: STRICT ENUM, REQUIRED # see §6
failure_recovery_action: STRICT ENUM, REQUIRED # see §6
refund_initiated: boolean, REQUIRED
refund_eta_minutes: int, REQUIRED # 0 if no refund
evidence:
receipt_url: URL, REQUIRED # downloadable receipt
share_url: URL, REQUIRED # shareable transaction link
raised_by_npci_dispute: boolean, REQUIRED
Forbidden fields
paid_placement_score | sponsored_rank | promotion_priority |
ad_bid | hidden_psp_fee | inflated_clearing_estimate |
recipient_bank_account_full | unmasked_phone | unmasked_upi_id
5. CONTROLLED VOCABULARIES
recipient.kind
upi_id | phone | qr_intent | account_ifsc | beneficiary_id
recipient.verification_method
vpa_resolution | npci_phone_to_vpa | beneficiary_lookup |
qr_signed_by_npci | manual_aadhaar_verification
transfer_kind
p2p | p2m
transfer_purpose
personal_transfer | family_support | gift |
self_account_transfer | rent | salary_advance |
emergency | business_payment | utility_split |
service_payment | refund_to_friend | other
initiate_transfer.intent_kind
upi_collect | upi_intent_app | upi_intent_browser | qr_signed_intent | netbanking_fallback
initiate_transfer.status / TransferStatus.status
initiated | awaiting_user_authorization | user_authorized |
debit_pending | debited | clearing | credit_pending | credited |
failed_authorization | failed_debit | failed_clearing | failed_credit |
refund_initiated | refund_completed | cancelled_by_user |
manual_review_pending | timeout
limits.cooling_period_reason
none | new_contact_over_2000 | first_transfer_to_recipient |
high_amount_first_session | new_device | new_sim
risk.risk_signals
none | new_contact | high_amount_relative_to_history | unusual_time |
unusual_geographic_pattern | recipient_recently_flagged_in_npci_db |
device_fingerprint_changed | sim_changed_recently | screen_share_detected |
prior_failed_attempts_15min
trust.partner_npci_member_kind
psp_bank | tpap_non_bank | tpap_bank | upi_lite_authorized | sub_member
trust.partner_pci_dss_level
level_4 | level_3 | level_2 | level_1 | not_applicable
trust.rbi_authorization_kind
PSP | PA | PA_aggregator | bank_native | sub_PA | not_applicable
debit.debit_status
not_started | pending | succeeded | failed | reversed
credit.credit_status
not_started | pending | succeeded | failed | partial_success
npci.npci_response_code
SUCCESS | T01 | T02 | U01 | U03 | U16 | U28 | U30 | U66 |
Z6 | Z9 | XB | XF | INVALID_VPA | UNKNOWN
failure.failure_reason
none | user_declined | insufficient_funds | recipient_account_blocked |
recipient_vpa_invalid | beneficiary_bank_offline | npci_timeout |
fraud_blocked | over_daily_limit | per_transaction_limit_exceeded |
upi_max_attempts_exceeded | user_app_timeout | unknown
failure.failure_recovery_action
none | retry_with_different_psp | retry_after_cooling | reduce_amount |
contact_user_bank | contact_recipient_bank | manual_review_npci |
report_fraud | refund_via_dispute | no_action_required
cancel_transfer.reason
user_changed_mind | wrong_amount | wrong_recipient | found_alternative |
suspicious_activity | recipient_no_longer_needed
request_refund.reason
sent_to_wrong_recipient | wrong_amount_sent | duplicate_transfer |
service_not_rendered | dispute_with_recipient | fraud_suspected
6. TTBS DIMENSIONS
Per-domain weights (locked)
pay (UPI overlay): { time: 0.40, taste: 0.05, budget: 0.15, safety: 0.40 }
Time + Safety dominate. Budget barely matters (UPI is free for most users). Taste minimal.
TIME
SIGNALS USED:
- expected_clearing_seconds (lower = better) weight 0.40
- partner_npci_uptime_pct weight 0.30
- is_repeat_recipient (faster cooling-off skip) weight 0.10
- intent_kind == upi_intent_app (vs collect) weight 0.20
USER BAND HANDLING:
- emergency transfer_purpose → time weight up to 0.50
TASTE
SIGNALS USED:
- in_app_chat_supported weight 0.30
- vernacular language support weight 0.20
- share_url quality (good receipt) weight 0.20
- rich UPI ID display (recipient name + bank logo) weight 0.30
BUDGET
SIGNALS USED:
- partner_fee_inr (lower = better) weight 0.50
- gst_inr (lower = better) weight 0.30
- cashback / loyalty bonus weight 0.20
HARD FILTERS:
- partner_fee_inr > 5 INR for personal UPI → drop
(NPCI rules say P2P UPI is free; partner fees indicate non-compliant)
SAFETY (the dominant axis alongside time)
SIGNALS USED:
- trust.partner_npci_authorized_psp=true HARD FILTER
- trust.rbi_authorization_number present + valid HARD FILTER
- trust.partner_pci_dss_compliant=true HARD FILTER
- customer_support_24x7=true HARD FILTER (UPI requires)
- cooling_off_required honored for new contacts weight 0.20
- risk_score reasonable for transaction kind weight 0.20
- 2FA path uses biometric (per user.biometric_used_recently) weight 0.20
- npci_response_code accuracy in failure flows weight 0.20
- dispute resolution SLA documented weight 0.20
Hidden ranking factor
information_completeness_score weight 0.10.
historical_settlement_success_rate weight 0.20 — partners with >2% UPI failure rate get penalized.
7. COMPLETION CONTRACT
POST /api/v1/cpc/mcp_provider/{tomo_partner_id}
X-TOMO-Timestamp: <ms>
X-TOMO-Signature: sha256=<hex>
{
"intent": "pay.send_money_upi",
"intent_version": "v1.0.0",
"external_id": "PHONEPE-T-XYZ",
"amount_inr": 500,
"closed_at": "2026-05-10T10:18:14+05:30",
"request_id": "req_upi_8f4k_...",
"status": "credited",
"currency": "INR",
"transfer_ref": "PHONEPE-T-XYZ",
"transfer_kind": "p2p",
"transfer_purpose": "personal_transfer",
"amount_inr_credited": 500,
"partner_fee_inr": 0,
"credit_iso": "2026-05-10T10:18:14+05:30",
"npci_reference_id": "412345678901",
"npci_response_code": "SUCCESS",
"notes": ""
}
Status enum: credited | failed_authorization | failed_debit | failed_clearing | failed_credit | refund_completed | cancelled_by_user
TOMO commission scenario:
- User sends ₹500 via UPI (P2P)
- Partner_fee_inr is 0 (UPI is free per NPCI)
- TOMO commission = 10% ×
amount_inr= ₹50
However: TOMO commission on UPI P2P is currently set to 0% by founder directive (UPI rails are public infrastructure, not partner-owned). Partners report amount_inr for audit/ledger but no commission is charged. This is the only intent where the universal 10% rule does NOT apply — locked at v1.
TOMO commission scenario (P2M only):
- User sends ₹500 via UPI (P2M to merchant)
- Merchant's payment partner accepts
- TOMO commission = 10% × amount_inr if the merchant intent was TOMO-routed (e.g.,
food.order_deliveryclose)
For pure standalone P2P (no parent intent), commission = 0.
8. WIDGET
WIDGET TYPE: upi_send_card
SOURCE: src/widgets/types.ts
TYPE NAME: UpiSendPayload
RENDERED IN: components/widgets/UpiSendWidget.tsx
Confirmation card with: amount big, recipient name + bank, partner pick (auto-selected), security badges, "Send via [App]" CTA → opens UPI app intent.
9. CACHING POLICY
| Call | TTL | Rationale |
|---|---|---|
resolve_vpa |
5min | VPA resolution is stable for active accounts |
initiate_transfer |
0s | Always fresh |
confirm_transfer |
0s | |
get_transfer_status |
0s | Live |
cancel_transfer |
0s | |
request_refund |
0s |
10. ERROR CODES
| Code | HTTP | Meaning | TOMO behavior |
|---|---|---|---|
INVALID_VPA |
400 | UPI ID malformed | surface |
VPA_NOT_FOUND |
404 | NPCI doesn't recognize | surface, suggest verification |
RECIPIENT_BLOCKED |
403 | recipient flagged in NPCI | surface, terminate |
INSUFFICIENT_FUNDS |
402 | user account empty | surface |
OVER_DAILY_LIMIT |
429 | user UPI day-limit hit | surface, suggest reducing |
OVER_PER_TRANSACTION_LIMIT |
400 | > ₹1L (or bank-set max) | surface, suggest split |
COOLING_OFF_ACTIVE |
425 | new-contact cooling | surface, retry after delay |
NPCI_TIMEOUT |
504 | NPCI didn't respond | retry once |
BANK_OFFLINE |
503 | recipient bank not online | retry |
FRAUD_BLOCKED |
403 | partner-side fraud rule | surface to user, may need manual review |
USER_TIMED_OUT |
408 | user didn't authorize in time | surface, restart |
INVALID_AUTH |
401 | partner credentials | partner re-auth |
RATE_LIMITED |
429 | partner-side throttle | back off |
INTERNAL_ERROR |
500 | partner-side failure | drop partner |
11. SANDBOX → PRODUCTION CHECKLIST
[ ] All §2 inputs validated, request_id echoed
[ ] resolve_vpa returns valid name + bank for sandbox VPAs
[ ] initiate_transfer returns valid payment_intent_url
[ ] confirm_transfer reflects NPCI clearing within SLA
[ ] cancel_transfer respects pre-auth cancellation
[ ] request_refund initiates reversal flow correctly
[ ] All §4 required fields populated with REAL data
[ ] No forbidden fields anywhere
[ ] No unmasked VPA / phone / account in any response
[ ] CPC webhook arrives within 60s of credit (audit only, commission is 0% for P2P)
[ ] HMAC verification passes
[ ] NPCI authorization certificate uploaded (PSP / TPAP)
[ ] RBI license number valid + verifiable
[ ] PCI DSS attestation
[ ] customer_support 24x7 reachable (mandatory for UPI partners)
[ ] No commission-based partner ordering (1% audit cross-check)
[ ] UPI fraud reporting flow tested with sandbox fraud injection
[ ] NPCI uptime > 99% over last 30 days verified
[ ] Cooling-off rule honored (NPCI > ₹2000 to new contact = 30s wait)
12. ANTI-FABRICATION RULES
RULE 1 — No paid placement signals
RULE 2 — No partner fees on personal UPI
partner_fee_inr > 0 for transfer_kind=p2p indicates non-NPCI-compliant
partner. Detection = listing rejection.
RULE 3 — VPA resolution must be authoritative
Partner must resolve via NPCI. Cached results > 5 minutes = breach.
RULE 4 — NPCI authorization mandatory
trust.partner_npci_authorized_psp=true requires NPCI license certificate
on demand within 24h. False claims = listing rejection.
RULE 5 — PCI DSS compliance verifiable
Partner must produce current PCI DSS attestation. Lapse = listing rejection.
RULE 6 — TOMO never holds the rail
Money flows directly user-bank → recipient-bank via NPCI. TOMO never
custody. Partner intermediary holds the bank-rail relationship.
RULE 7 — Cooling-off rule enforced
NPCI mandates 30s cooling for first transfer > ₹2000 to new contact.
Partners that bypass this rule = breach.
RULE 8 — Customer support 24x7 honest
UPI is critical infrastructure. customer_support_24x7=false → drop.
RULE 9 — No commission-based partner ordering
TOMO ranks by user-fit (TTBS), not by partner commission.
RULE 10 — Right-to-evidence
Every transfer must have receipt_url + share_url that work indefinitely.
Receipts are tax-relevant; partners that delete = breach.
VERSION HISTORY
v1.0.0 — 2026-05-10 — Initial spec (Block F.4)