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:
- Frontend creates an invoice on your backend, then opens the Alien payment UI
- User approves the payment; the transaction is broadcast
- Frontend immediately receives the result (paid, cancelled, or broadcast failed)
- Backend later receives a webhook when the transaction is confirmed on-chain, and fulfills the order
Payment Prerequisites and Setup
@alien-id/miniapps-reactinstalled andAlienProviderwrapping your app- A webhook registered in the Developer Portal
- A backend endpoint to receive and verify webhook callbacks
Supported Tokens
| Token | Network | Decimals | Recipient |
|---|---|---|---|
| USDC | solana | 6 | Your Solana wallet address |
| SOL | solana | 9 | Your Solana wallet address |
| ALIEN | alien | 9 | Your 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
| Option | Type | Default | Description |
|---|---|---|---|
timeout | number | 120000 | Payment 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
| Value | Type | Description |
|---|---|---|
pay | (params) => Promise<PaymentResult> | Initiates a payment |
status | PaymentStatus | 'idle', 'loading', 'paid', etc. |
isLoading | boolean | Payment is in progress |
isPaid | boolean | Payment completed successfully |
isCancelled | boolean | User cancelled the payment |
isFailed | boolean | Payment failed |
txHash | string | undefined | Transaction hash (when paid) |
errorCode | PaymentErrorCode | undefined | Error code (when failed) |
error | Error | null | Error object (when failed), else null |
reset | () => void | Reset state to idle |
callable | boolean | Whether payments are supported by the host |
Pre-call refusal. If the host can’t run the payment (no bridge, or too old),
onFailedfires witherrorCode: 'unknown'anderrorset to aBridgeErrorsubclass — that’s how “host too old” surfaces. Checkcallablefirst to show an update prompt instead.
Payment Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
recipient | string | Yes | Wallet address to receive payment |
amount | string | Yes | Amount in token’s smallest unit |
token | string | Yes | 'USDC', 'SOL', 'ALIEN', or identifier |
network | string | Yes | 'solana' or 'alien' |
invoice | string | Yes | Unique payment identifier |
item | object | No | Display info for approval screen |
item.title | string | Yes* | Item title |
item.iconUrl | string | Yes* | Item icon URL |
item.quantity | number | Yes* | Item quantity |
test | PaymentTestScenario | No | Test 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
| Code | Description |
|---|---|
insufficient_balance | User does not have enough tokens |
network_error | Blockchain network issue |
unknown | Unexpected error occurred |
Webhook Setup
Before accepting payments, you must register a webhook in the Alien Dev Portal :
-
Open the Developer Portal dashboard, go to Mini Apps, and open your Mini App
-
Switch to the Payments tab and click Create webhook
-
Set the Webhook URL to your payment endpoint — a publicly reachable HTTPS URL that can receive POST requests:
https://<your-domain>/your-webhook-pathIf you’re using the Next.js boilerplate , this is
https://<your-domain>/api/webhooks/payment. -
Optionally add your Solana pubkey if you want to receive USDC and SOL payments
-
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
}
tokenis normalized by the platform — it is not the token slug you passed topay(). SPL tokens arrive as their mint address (e.g.EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1vfor USDC), and chain-native tokens (SOL onsolana, ALIEN onalien) arrive as'native'. Never compare the webhook’stokenagainst the requested slug — look up your order byinvoiceand cross-checkrecipient,amount, andnetworkinstead.
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
- Read the raw request body as a UTF-8 string
- Get the
X-Webhook-Signatureheader value (hex-encoded Ed25519 signature) - Import your
WEBHOOK_PUBLIC_KEY(hex-encoded Ed25519 public key) - 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 → completedstatus transition) so a re-delivered webhook can’t fulfill the same order twice. Respond with a2xxstatus to acknowledge the webhook.
Webhook Statuses
| Status | Description | Action |
|---|---|---|
finalized | Transaction confirmed on-chain | Fulfill order |
failed | Transaction failed on-chain | Mark 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
| Scenario | Client sees | Webhook sent | Webhook status |
|---|---|---|---|
'paid' | paid | Yes | finalized |
'paid:failed' | paid | Yes | failed |
'cancelled' | cancelled | No | - |
'error:insufficient_balance' | failed | No | - |
'error:network_error' | failed | No | - |
'error:unknown' | failed | No | - |
'paid'— Happy path. Client shows success, webhook reportsfinalized.'paid:failed'— Client shows success, but the webhook reportsfailed. 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
- Install Boilerplate — Pre-configured template
- Authentication — Authenticate users before checkout
- Developer Portal — Configure webhooks