Terminal Purchase
Complete purchases without leaving the terminal
Terminal Purchase
For returning customers, Tuish supports a full in-terminal purchase flow using saved cards and SMS OTP verification.
When to Use
- Returning customers with saved payment methods
- Terminal-native apps where users prefer not to leave the CLI
- Automated/scripted purchase scenarios
For first-time customers, use Browser Checkout instead.
Prerequisites
- Customer must have previously purchased (has saved cards)
- Customer must have a verified phone number
How It Works
┌─────────────────────────────────────────────────────────────────┐
│ Terminal Flow │
├─────────────────────────────────────────────────────────────────┤
│ 1. Enter email → "user@example.com" │
│ 2. Receive OTP → SMS to verified phone │
│ 3. Enter login OTP → "123456" │
│ 4. Select card → "Visa ending in 4242" │
│ 5. Receive OTP → SMS for purchase confirmation │
│ 6. Enter purchase OTP → "789012" │
│ 7. License activated! │
└─────────────────────────────────────────────────────────────────┘Quick Flow
The purchaseInTerminal method handles the entire flow:
const result = await tuish.purchaseInTerminal({
email: 'user@example.com',
// Called when login OTP is sent
getLoginOtp: async (phoneMasked) => {
return await prompt(`Enter code sent to ${phoneMasked}: `);
},
// Called to select payment card
selectCard: async (cards, amount, currency) => {
console.log(`Total: ${formatMoney(amount, currency)}`);
for (const card of cards) {
console.log(`${card.id}: ${card.brand} ****${card.last4}`);
}
return await prompt('Select card ID: ');
},
// Called when purchase OTP is sent
getPurchaseOtp: async (phoneMasked) => {
return await prompt(`Confirm purchase with code sent to ${phoneMasked}: `);
},
});
if (result.success) {
console.log('Purchase complete!');
console.log('Receipt:', result.receiptUrl);
}ctx := context.Background()
reader := bufio.NewReader(os.Stdin)
// Get email
fmt.Print("Email: ")
email, _ := reader.ReadString('\n')
email = strings.TrimSpace(email)
// Request login OTP
loginOtp, err := sdk.RequestLoginOtp(ctx, email)
if err != nil {
log.Fatalf("Login failed: %v", err)
}
// Verify login
fmt.Printf("Enter code sent to %s: ", loginOtp.PhoneMasked)
code, _ := reader.ReadString('\n')
code = strings.TrimSpace(code)
_, err = sdk.VerifyLogin(ctx, email, loginOtp.OtpID, code)
if err != nil {
log.Fatalf("Verification failed: %v", err)
}
fmt.Println("Logged in!")
// Init purchase
purchase, err := sdk.InitTerminalPurchase(ctx)
if err != nil {
log.Fatalf("Init purchase failed: %v", err)
}
fmt.Printf("\n%s - $%.2f\n\n", purchase.ProductName, float64(purchase.Amount)/100)
fmt.Println("Saved cards:")
for i, card := range purchase.Cards {
fmt.Printf(" %d. %s ****%s\n", i+1, card.Brand, card.Last4)
}
// Select card
fmt.Print("\nSelect card: ")
cardStr, _ := reader.ReadString('\n')
cardIdx, _ := strconv.Atoi(strings.TrimSpace(cardStr))
card := purchase.Cards[cardIdx-1]
// Request purchase OTP
purchaseOtpID, _, err := sdk.RequestPurchaseOtp(ctx)
if err != nil {
log.Fatalf("OTP request failed: %v", err)
}
// Confirm purchase
fmt.Printf("Enter code sent to %s: ", purchase.PhoneMasked)
purchaseCode, _ := reader.ReadString('\n')
purchaseCode = strings.TrimSpace(purchaseCode)
result, err := sdk.ConfirmTerminalPurchase(ctx, card.ID, purchaseOtpID, purchaseCode)
if err != nil {
log.Fatalf("Purchase failed: %v", err)
}
if result.Success {
fmt.Println("\nPurchase complete!")
fmt.Printf("Receipt: %s\n", result.ReceiptURL)
}fn prompt(message: &str) -> String {
print!("{}", message);
io::stdout().flush().unwrap();
let mut input = String::new();
io::stdin().read_line(&mut input).unwrap();
input.trim().to_string()
}
let result = tuish.purchase_in_terminal(
"user@example.com",
// Callback: get login OTP from user
|| prompt("Enter login OTP: "),
// Callback: select payment method
|cards| {
println!("Select a card:");
for (i, card) in cards.iter().enumerate() {
println!(" {}. {} ending in {}", i + 1, card.brand, card.last4);
}
let choice = prompt("Enter number: ");
let idx: usize = choice.parse().ok()?;
cards.get(idx - 1).map(|c| c.id.clone())
},
// Callback: get purchase OTP from user
|| prompt("Enter purchase OTP: "),
).await?;
if result.valid {
println!("Purchase complete!");
}Step-by-Step Implementation
For more control, you can use the individual methods:
// 1. Request login OTP
const loginOtp = await tuish.requestLoginOtp('user@example.com');
console.log(`Code sent to ${loginOtp.phoneMasked}`);
// 2. Verify login
const code = await getInput('Enter code: ');
const login = await tuish.verifyLogin({
email: 'user@example.com',
otpId: loginOtp.otpId,
otp: code,
});
// 3. Initialize purchase (get saved cards)
const purchase = await tuish.initTerminalPurchase();
console.log(`${purchase.productName}: $${purchase.amount / 100}`);
// 4. User selects card
const cardId = selectCard(purchase.cards);
// 5. Request purchase confirmation OTP
const confirmOtp = await tuish.requestPurchaseOtp();
// 6. Confirm purchase
const purchaseCode = await getInput('Confirm purchase: ');
const result = await tuish.confirmTerminalPurchase({
cardId,
otpId: confirmOtp.otpId,
otp: purchaseCode,
});
if (result.success) {
console.log('Done!', result.receiptUrl);
}// 1. Request login OTP
otp, err := sdk.RequestLoginOtp(ctx, "customer@example.com")
fmt.Printf("Enter the code sent to %s: ", otp.PhoneMasked)
// 2. Verify login
var code string
fmt.Scanln(&code)
login, err := sdk.VerifyLogin(ctx, "customer@example.com", otp.OtpID, code)
fmt.Printf("Logged in! Found %d existing licenses\n", len(login.Licenses))
// 3. Initialize purchase
purchase, err := sdk.InitTerminalPurchase(ctx)
fmt.Printf("Product: %s\n", purchase.ProductName)
fmt.Printf("Price: $%.2f %s\n", float64(purchase.Amount)/100, purchase.Currency)
// 4. Select card
for i, card := range purchase.Cards {
fmt.Printf(" %d. %s ending in %s\n", i+1, card.Brand, card.Last4)
}
var cardIdx int
fmt.Print("Select card: ")
fmt.Scanln(&cardIdx)
selectedCard := purchase.Cards[cardIdx-1]
// 5. Request purchase OTP
otpID, _, err := sdk.RequestPurchaseOtp(ctx)
fmt.Printf("Enter the code sent to %s: ", purchase.PhoneMasked)
// 6. Confirm purchase
var purchaseCode string
fmt.Scanln(&purchaseCode)
result, err := sdk.ConfirmTerminalPurchase(ctx, selectedCard.ID, otpID, purchaseCode)
if result.Success {
fmt.Println("Purchase complete!")
}Types
interface PurchaseInitResult {
cards: SavedCard[];
amount: number; // In cents
currency: string;
phoneMasked: string;
productName: string;
}
interface SavedCard {
id: string;
brand: string; // visa, mastercard, etc.
last4: string;
expiryMonth: number;
expiryYear: number;
}
interface PurchaseConfirmResult {
success: boolean;
license?: string;
receiptUrl?: string;
requiresAction?: boolean; // 3DS required
actionUrl?: string;
error?: string;
}type PurchaseInitResult struct {
Cards []SavedCard
Amount int // In cents
Currency string // "usd", "eur", etc.
PhoneMasked string
ProductName string
}
type SavedCard struct {
ID string
Brand string // "visa", "mastercard", etc.
Last4 string
ExpiryMonth int
ExpiryYear int
}
type PurchaseConfirmResult struct {
Success bool
License string
ReceiptURL string
RequiresAction bool // 3D Secure required
ActionURL string
Error string
}pub struct SavedCard {
pub id: String,
pub brand: String,
pub last4: String,
pub exp_month: u32,
pub exp_year: u32,
}3D Secure Handling
If requiresAction is true, you need to open the actionUrl in a browser for 3D Secure verification:
if (result.requiresAction && result.actionUrl) {
console.log('3D Secure verification required');
await tuish.openUrl(result.actionUrl);
// Wait for user to complete, then re-check license
}if result.RequiresAction {
fmt.Printf("3D Secure required. Open: %s\n", result.ActionURL)
}Error Handling
try {
await tuish.purchaseInTerminal({ email, ...callbacks });
} catch (error) {
if (error.code === 'CUSTOMER_NOT_FOUND') {
console.log('No account with this email. Use browser checkout.');
} else if (error.code === 'RATE_LIMITED') {
console.log('Too many attempts. Try again later.');
}
}otp, err := sdk.RequestLoginOtp(ctx, email)
if err != nil {
if apiErr, ok := err.(*tuish.APIError); ok {
switch apiErr.Code {
case "CUSTOMER_NOT_FOUND":
fmt.Println("No account with this email. Use browser checkout.")
case "RATE_LIMITED":
fmt.Println("Too many attempts. Try again later.")
default:
fmt.Printf("Error: %s\n", apiErr.Message)
}
}
return
}match tuish.purchase_in_terminal(email, get_otp, select_card, get_purchase_otp).await {
Ok(result) if result.valid => {
println!("Success!");
}
Err(TuishError::ApiError { status: 401, .. }) => {
println!("Invalid OTP. Please try again.");
}
Err(TuishError::ApiError { status: 404, .. }) => {
println!("No account found. Use browser checkout.");
}
Err(e) => {
println!("Error: {}", e);
}
}Fallback to Browser
If terminal purchase isn't available (no saved cards, new customer):
async function purchase(email: string) {
try {
await tuish.purchaseInTerminal({ email, ...callbacks });
} catch (error) {
if (error.code === 'CUSTOMER_NOT_FOUND') {
console.log('Opening browser checkout...');
await tuish.purchaseInBrowser({ email });
}
}
}match tuish.purchase_in_terminal(&email, get_otp, select_card, get_purchase_otp).await {
Ok(result) if result.valid => {
println!("Purchase complete!");
}
Err(TuishError::ApiError { status: 404, .. }) => {
// New customer - fall back to browser
println!("No account found. Opening browser checkout...");
let session = tuish.open_checkout(Some(&email)).await?;
tuish.wait_for_checkout(&session.session_id).await?;
}
_ => {}
}Security Considerations
- OTPs expire after 5 minutes
- OTPs can only be used once
- Rate limiting applies to OTP requests
- Phone number verification required before terminal purchases
- Dual OTP (login + purchase) provides extra security against session hijacking
Related Topics
- Browser Checkout - For first-time customers
- Choosing Your Flow - When to use each flow
- Authentication API - OTP endpoints