Skip to Content
React SDKPayments
View .md

In-App Payments in Alien Mini Apps: React SDK Guide

Accept payments in your mini app using the Alien React SDK. Users can pay with USDC, SOL (on Solana) or ALIEN tokens directly from their wallet.

How In-App Payments Work in Alien

The payment flow involves both frontend and backend:

  1. Frontend creates an invoice on your backend, then opens the Alien payment UI
  2. User approves the payment; the transaction is broadcast
  3. Frontend immediately receives the result (paid, cancelled, or broadcast failed)
  4. Backend later receives a webhook when the transaction is confirmed on-chain, and fulfills the order

Payment Prerequisites and Setup

  • @alien-id/miniapps-react installed and AlienProvider wrapping your app
  • A webhook registered in the Developer Portal
  • A backend endpoint to receive and verify webhook callbacks

Supported Tokens

TokenNetworkDecimalsRecipient
USDCsolana6Your Solana wallet address
SOLsolana9Your Solana wallet address
ALIENalien9Your provider address from the Dev Portal

Using the usePayment Hook

The usePayment hook provides full state management for payments.

function CheckoutButton() { const { authToken } = useAlien(); const { pay, status, isLoading, isPaid, isCancelled, isFailed, txHash, errorCode, error, reset, callable, } = usePayment({ onPaid: (txHash) => { console.log('Payment successful:', txHash); }, onCancelled: () => { console.log('Payment cancelled by user'); }, onFailed: (errorCode, error) => { console.error('Payment failed:', errorCode, error); }, }); const handlePayment = async () => { // Step 1: Create invoice on your backend const res = await fetch('/api/invoices', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authToken}`, }, body: JSON.stringify({ recipientAddress: 'YOUR_WALLET_ADDRESS', amount: '10000', token: 'USDC', network: 'solana', productId: 'my-product', }), }); const { invoice } = await res.json(); // Step 2: Open Alien payment UI await pay({ recipient: 'YOUR_WALLET_ADDRESS', amount: '10000', token: 'USDC', network: 'solana', invoice, item: { title: 'Premium Plan', iconUrl: 'https://example.com/icon.png', quantity: 1, }, }); }; if (!callable) { return <p>Payments not supported in this environment</p>; } return ( <button onClick={handlePayment} disabled={isLoading}> {isLoading ? 'Processing...' : 'Pay Now'} </button> ); }

Hook Options

OptionTypeDefaultDescription
timeoutnumber120000Payment timeout in ms (2 min)
onPaid(txHash: string) => void-Called on success
onCancelled() => void-Called on user cancel
onFailed(code, error?) => void-Called on failure
onStatusChange(status) => void-Called on any change

Hook Return Values

ValueTypeDescription
pay(params) => Promise<PaymentResult>Initiates a payment
statusPaymentStatus'idle', 'loading', 'paid', etc.
isLoadingbooleanPayment is in progress
isPaidbooleanPayment completed successfully
isCancelledbooleanUser cancelled the payment
isFailedbooleanPayment failed
txHashstring | undefinedTransaction hash (when paid)
errorCodePaymentErrorCode | undefinedError code (when failed)
errorError | nullError object (when failed), else null
reset() => voidReset state to idle
callablebooleanWhether payments are supported by the host

Pre-call refusal. If the host can’t run the payment (no bridge, or too old), onFailed fires with errorCode: 'unknown' and error set to a BridgeError subclass — that’s how “host too old” surfaces. Check callable first to show an update prompt instead.

Payment Parameters

ParameterTypeRequiredDescription
recipientstringYesWallet address to receive payment
amountstringYesAmount in token’s smallest unit
tokenstringYes'USDC', 'SOL', 'ALIEN', or identifier
networkstringYes'solana' or 'alien'
invoicestringYesUnique payment identifier
itemobjectNoDisplay info for approval screen
item.titlestringYes*Item title
item.iconUrlstringYes*Item icon URL
item.quantitynumberYes*Item quantity
testPaymentTestScenarioNoTest scenario string

*Required if item is provided.

Invoice

The invoice field is a memo string attached to the on-chain transaction. It serves as a unique identifier that links the payment to an order in your backend.

You can use any format for the invoice value — UUID, SHA-256 hash, Nano ID, or any custom string — as long as it is unique per payment and your backend can look it up when the webhook arrives.

Size limit: The invoice is written to the transaction memo and must be strictly less than 64 bytes (512 bits). SHA-256 hashes (32 bytes raw), UUIDs (36 chars), and Nano IDs (21 chars) all fit comfortably within this limit.

Examples of valid invoice values:

// UUID (36 bytes) const invoice = crypto.randomUUID(); // SHA-256 as base64url (43 bytes) — fits well under 64 const invoice = createHash('sha256') .update(orderId) .digest('base64url'); // Nano ID (21 bytes) const invoice = nanoid(); // Custom short ID const invoice = `ord_${Date.now()}`;

Error Codes

CodeDescription
insufficient_balanceUser does not have enough tokens
network_errorBlockchain network issue
unknownUnexpected error occurred

Webhook Setup

Before accepting payments, you must register a webhook in the Alien Dev Portal:

  1. Open the Developer Portal dashboard, go to Mini Apps, and open your Mini App

  2. Switch to the Payments tab and click Create webhook

  3. Set the Webhook URL to your payment endpoint — a publicly reachable HTTPS URL that can receive POST requests:

    https://<your-domain>/your-webhook-path

    If you’re using the Next.js boilerplate, this is https://<your-domain>/api/webhooks/payment.

  4. Optionally add your Solana pubkey if you want to receive USDC and SOL payments

  5. Click Create

After creation, you will be shown your webhook public key (Ed25519, hex-encoded). Copy it immediately — this is your WEBHOOK_PUBLIC_KEY environment variable. It is only shown once.

If you need to rotate the key, find your webhook in the Payments tab and click Rotate keypair. A new key pair is generated without recreating the webhook — the old key stops working immediately, so update WEBHOOK_PUBLIC_KEY on your server right away.

Webhook Versions

Webhook payloads are versioned. Each webhook is pinned to the schema version it was created with; newly created webhooks use the latest version (currently 3, documented below). Every delivery carries its version in the X-Webhook-Version header.

Your handler should check X-Webhook-Version and reject versions it does not implement (e.g. with a 400 response) instead of silently misparsing an unexpected schema.

To upgrade an existing webhook to a newer version, open the Payments tab in the Dev Portal — webhooks on an older version show an upgrade arrow next to the version number, with a side-by-side comparison of the old and new payload schemas. Update your handler first, then upgrade.

Webhook Payload

The Alien platform sends a POST request to your webhook URL. The version 3 JSON body is:

interface WebhookPayload { invoice: string; recipient: string; amount: string; // in the token's smallest unit decimals: number; // 9 for SOL and ALIEN, 6 for USDC token: string; // normalized: mint address or 'native' network: string; // 'solana' or 'alien' status: 'finalized' | 'failed'; txHash?: string; test: boolean; // false for real payments, true for test events }

token is normalized by the platform — it is not the token slug you passed to pay(). SPL tokens arrive as their mint address (e.g. EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v for USDC), and chain-native tokens (SOL on solana, ALIEN on alien) arrive as 'native'. Never compare the webhook’s token against the requested slug — look up your order by invoice and cross-check recipient, amount, and network instead.

Webhook Signature Verification

Security: Always verify the webhook signature. Without verification, any external party could send fake webhook requests to your endpoint and trick your backend into fulfilling orders that were never paid.

Every webhook request includes an X-Webhook-Signature header containing a hex-encoded Ed25519 signature of the raw request body (signed with the key pair created for your webhook). You must verify this signature before processing the payload — reject the request if the signature is missing or invalid.

The public key used to verify signatures is shown once when you create your webhook in the Dev Portal. Store it as the WEBHOOK_PUBLIC_KEY environment variable on your server.

Verification Algorithm

  1. Read the raw request body as a UTF-8 string
  2. Get the X-Webhook-Signature header value (hex-encoded Ed25519 signature)
  3. Import your WEBHOOK_PUBLIC_KEY (hex-encoded Ed25519 public key)
  4. Verify the signature against the raw body using Ed25519

Implementation (Web Crypto API)

async function verifySignature( publicKeyHex: string, signatureHex: string, body: string, ): Promise<boolean> { // Import the Ed25519 public key from hex const publicKey = await crypto.subtle.importKey( 'raw', Buffer.from(publicKeyHex, 'hex'), { name: 'Ed25519' }, false, ['verify'], ); // Verify the signature return crypto.subtle.verify( 'Ed25519', publicKey, Buffer.from(signatureHex, 'hex'), Buffer.from(body), ); }

Complete Webhook Handler

Here is a complete webhook handler example based on the Next.js boilerplate. Adapt the route path and database logic to your own backend:

// verifySignature() from the previous section // The webhook schema version this handler implements const SUPPORTED_WEBHOOK_VERSION = '3'; export async function POST(request: Request) { // 1. Read the raw body (must be read before parsing JSON) const rawBody = await request.text(); // 2. Get the signature from headers const signatureHex = request.headers.get('x-webhook-signature') ?? ''; if (!signatureHex) { return NextResponse.json( { error: 'Missing webhook signature' }, { status: 401 }, ); } // 3. Reject schema versions you don't implement const version = request.headers.get('x-webhook-version'); if (version !== null && version !== SUPPORTED_WEBHOOK_VERSION) { return NextResponse.json( { error: `Unsupported webhook version: ${version}` }, { status: 400 }, ); } try { // 4. Verify the Ed25519 signature const isValid = await verifySignature( process.env.WEBHOOK_PUBLIC_KEY!, signatureHex, rawBody, ); if (!isValid) { return NextResponse.json( { error: 'Invalid signature' }, { status: 401 }, ); } // 5. Parse the payload and look up your stored order const payload = JSON.parse(rawBody); const order = await findOrderByInvoice(payload.invoice); if (!order) { return NextResponse.json( { error: 'Invoice not found' }, { status: 404 }, ); } // 6. Cross-check the payload against the stored order. // Compare recipient, amount, and network — NOT token: the // platform sends a normalized slug ('native' or a mint // address), which differs from the slug used in pay(). if ( payload.recipient !== order.recipientAddress || payload.amount !== order.amount || payload.network !== order.network ) { return NextResponse.json( { error: 'Payload does not match order' }, { status: 400 }, ); } // 7. Update the order status if (payload.status === 'finalized') { // Transaction confirmed on-chain — fulfill the order await fulfillOrder(payload.invoice, payload.txHash); } else if (payload.status === 'failed') { // Transaction failed — handle accordingly await markOrderFailed(payload.invoice); } return NextResponse.json({ success: true }); } catch (error) { console.error('Webhook processing error:', error); return NextResponse.json( { error: 'Failed to process webhook' }, { status: 500 }, ); } }

Idempotency: make fulfillment idempotent (e.g. a conditional pending → completed status transition) so a re-delivered webhook can’t fulfill the same order twice. Respond with a 2xx status to acknowledge the webhook.

Webhook Statuses

StatusDescriptionAction
finalizedTransaction confirmed on-chainFulfill order
failedTransaction failed on-chainMark order as failed

Test Mode

Test payments without transferring real tokens by passing a test scenario string. The test parameter accepts a PaymentTestScenario string that simulates different outcomes:

await pay({ recipient: 'YOUR_WALLET_ADDRESS', amount: '10000', token: 'USDC', network: 'solana', invoice: 'test-order-123', test: 'paid', // Simulate successful payment });

Test Scenarios

ScenarioClient seesWebhook sentWebhook status
'paid'paidYesfinalized
'paid:failed'paidYesfailed
'cancelled'cancelledNo-
'error:insufficient_balance'failedNo-
'error:network_error'failedNo-
'error:unknown'failedNo-
  • 'paid' — Happy path. Client shows success, webhook reports finalized.
  • 'paid:failed' — Client shows success, but the webhook reports failed. Useful for testing rollback logic.
  • 'cancelled' — Simulates user cancellation. No webhook is sent.
  • 'error:*' — Pre-broadcast failures. No transaction is created and no webhook is sent.

Webhooks for test transactions carry test: true in the payload (real payments carry test: false) — the scenario string itself is never sent to your backend. The webhook status reflects the scenario: finalized for 'paid', failed for 'paid:failed'.

You can also deliver a sample webhook without going through the payment UI: open your webhook in the Dev Portal Payments tab and click Send test event.

Handling Payment States

function PaymentStatus() { const { status, isPaid, isFailed, isCancelled, txHash, errorCode, reset, } = usePayment(); if (isPaid) { return ( <div> <h2>Payment Successful</h2> <p>Transaction: {txHash}</p> <button onClick={reset}>Make Another Payment</button> </div> ); } if (isFailed) { return ( <div> <h2>Payment Failed</h2> <p>Error: {errorCode}</p> <button onClick={reset}>Try Again</button> </div> ); } if (isCancelled) { return ( <div> <h2>Payment Cancelled</h2> <button onClick={reset}>Try Again</button> </div> ); } return <p>Status: {status}</p>; }

Complete Example

A full purchase flow with invoice creation, payment, and webhook handling.

Frontend (React)

function BuyButton({ product }) { const { authToken } = useAlien(); const payment = usePayment({ onPaid: (txHash) => console.log('Paid!', txHash), onCancelled: () => console.log('Cancelled'), onFailed: (code) => console.log('Failed:', code), }); const handlePurchase = useCallback(async () => { if (!authToken) return; // 1. Create invoice on your backend const res = await fetch('/api/invoices', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authToken}`, }, body: JSON.stringify({ recipientAddress: product.recipientAddress, amount: product.amount, token: product.token, network: product.network, productId: product.id, }), }); const { invoice } = await res.json(); // 2. Open payment UI await payment.pay({ recipient: product.recipientAddress, amount: product.amount, token: product.token, network: product.network, invoice, item: { title: product.name, iconUrl: product.iconUrl, quantity: product.quantity, }, }); }, [authToken, payment, product]); if (!payment.callable) { return <p>Payments not available</p>; } return ( <button onClick={handlePurchase} disabled={payment.isLoading}> {payment.isLoading ? 'Processing...' : `Buy for ${product.price}`} </button> ); }

Backend Invoice Route

Example using Next.js App Router (adapt to your framework). This route authenticates the user, validates the request body, verifies the product parameters match your catalog, and creates a payment intent:

const authClient = createAuthClient({ audience: 'your-miniapp-provider-address', }); function extractBearerToken( header: string | null, ): string | null { return header?.startsWith('Bearer ') ? header.slice(7) : null; } export async function POST(request: Request) { try { const token = extractBearerToken( request.headers.get('Authorization'), ); if (!token) { return NextResponse.json( { error: 'Missing authorization token' }, { status: 401 }, ); } // Verify JWT — `sub` is the user's Alien ID const { sub } = await authClient.verifyToken(token); const body = await request.json(); const { recipientAddress, amount, token: paymentToken, network, productId, } = body; // Validate that the product parameters match your catalog // to prevent clients from sending arbitrary amounts const product = PRODUCTS.find((p) => p.id === productId); if ( !product || product.amount !== amount || product.token !== paymentToken || product.network !== network || product.recipientAddress !== recipientAddress ) { return NextResponse.json( { error: 'Invalid product parameters' }, { status: 400 }, ); } // Any unique string under 64 bytes const invoice = randomUUID(); // Save payment intent to your database await db.paymentIntents.create({ invoice, senderAlienId: sub, recipientAddress, amount, token: paymentToken, network, productId, status: 'pending', }); return NextResponse.json({ invoice }); } catch (error) { if (error instanceof JwtErrors.JWTExpired) { return NextResponse.json( { error: 'Token expired' }, { status: 401 }, ); } if (error instanceof JwtErrors.JOSEError) { return NextResponse.json( { error: 'Invalid token' }, { status: 401 }, ); } console.error('Failed to create invoice:', error); return NextResponse.json( { error: 'Failed to create invoice' }, { status: 500 }, ); } }

Important: Always validate that the product parameters (amount, token, network, recipient) match your server-side product catalog. Never trust client-submitted amounts directly.

Next Steps

Last updated on