HPP Integration
Complete guide to integrating the Hosted Payment Page. If you are looking for a quick overview, see Your First Payment.
PCI scope: HPP is the simplest integration path. Your server never touches card data, so you qualify for SAQ-A -- the lightest PCI self-assessment. Card data entered on the checkout page is tokenized same-origin before a payment is created -- raw card details never leave the platform and are never exposed to your infrastructure.
How it works
The HPP flow is a server-to-server + redirect pattern:
- Your server creates a Checkout Session by calling
POST /v1/checkout-sessionswith a secret key that holdspayment-experience:checkout:create. - You redirect the customer's browser to the
urlreturned in the response. - The customer lands on the hosted checkout page, selects a payment method, enters card details (or other method), and completes 3D Secure if required.
- The platform processes the payment and sends a webhook to your server with the result.
- The customer is redirected to your
successUrlafter a successful payment. After a failed payment or an expired session, the customer can return to yourcancelUrl, if you set one.
Both the webhook and the redirect happen in parallel. The webhook is the source of truth — see Handling the redirect below.
Create a Checkout Session
Call POST /v1/checkout-sessions with the order details. The response includes a url that you redirect the customer to.
Parameters
Required
| Parameter | Type | Description |
|---|---|---|
amount | MoneyAmount | Total order amount. currency (ISO 4217, 3 letters) and valueMinor (integer minor units — cents for USD, yen for JPY). |
lineItems | LineItem[] | Order line items displayed on the checkout page. Send an empty array if you have none. See Line items below. |
merchantReference | string | Your order ID or reference. Used for reconciliation. |
successUrl | string | URL to redirect the customer to after a successful payment. |
Optional
| Parameter | Type | Default | Description |
|---|---|---|---|
cancelUrl | string | — | URL the customer can return to after a failed payment or an expired session. |
returnUrl | string | successUrl | URL used for the 3D Secure redirect. |
locale | string | customer's browser language | Language of the checkout page: en, ru or kk. See Locale. |
captureMethod | string | "automatic" | "automatic" captures immediately. "manual" authorizes only — you call capture later. See Capture. |
customerInfo | CustomerInfo | — | Pre-fill customer information (name, email, phone) on checkout. |
billingAddress | BillingAddress | — | Pre-fill billing address fields. |
shippingAddress | ShippingAddress | — | Pre-fill shipping address fields. |
paymentMethodTypes | string[] | all enabled | Include filter: only show these payment method types (e.g., ["card", "qr"]). |
brands | string[] | all enabled | Include filter: only show these brands (e.g., ["visa", "mastercard"]). |
excludePaymentMethodTypes | string[] | — | Exclude filter: hide these payment method types. Exclude takes priority over include. |
excludeBrands | string[] | — | Exclude filter: hide these brands. |
modifiers | PaymentModifier[] | — | Discounts, coupons, or promotions applied to the order. |
metadata | object | — | Key-value pairs (string values) stored with the session. |
idempotencyKey | string | — | Deprecated. Use the Idempotency-Key header. |
Session expiration is configured at the platform level (via your Integration Profile), not per request: 30 minutes by default, anywhere from 30 minutes to 24 hours. Contact your operator to adjust.
Example
curl -X POST https://<api-host>/v1/checkout-sessions \
-H "Authorization: Bearer sk_test_your_secret_key" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-5678" \
-d '{
"amount": {
"currency": "USD",
"valueMinor": 12500
},
"lineItems": [
{
"label": "Premium Plan (Annual)",
"unitAmount": { "currency": "USD", "valueMinor": 12500 },
"quantity": 1
}
],
"merchantReference": "order-5678",
"successUrl": "https://your-site.com/payment/success",
"cancelUrl": "https://your-site.com/payment/cancel",
"locale": "en",
"customerInfo": {
"email": "customer@example.com",
"firstName": "Jane",
"lastName": "Doe"
},
"metadata": {
"plan": "premium",
"billingCycle": "annual"
}
}'const response = await fetch('https://<api-host>/v1/checkout-sessions', {
method: 'POST',
headers: {
'Authorization': 'Bearer sk_test_your_secret_key',
'Content-Type': 'application/json',
'Idempotency-Key': 'order-5678',
},
body: JSON.stringify({
amount: {
currency: 'USD',
valueMinor: 12500,
},
lineItems: [
{
label: 'Premium Plan (Annual)',
unitAmount: { currency: 'USD', valueMinor: 12500 },
quantity: 1,
},
],
merchantReference: 'order-5678',
successUrl: 'https://your-site.com/payment/success',
cancelUrl: 'https://your-site.com/payment/cancel',
locale: 'en',
customerInfo: {
email: 'customer@example.com',
firstName: 'Jane',
lastName: 'Doe',
},
metadata: {
plan: 'premium',
billingCycle: 'annual',
},
}),
});
const { data: session } = await response.json();
// session.url → redirect the customer hereResponse
{
"data": {
"id": "019560a1-b4c0-7d3f-8e2a-1a2b3c4d5e6f",
"livemode": false,
"status": "open",
"url": "<checkout-url>",
"clientSecret": "cs_secret_...",
"amount": {
"currency": "USD",
"valueMinor": 12500
},
"merchantReference": "order-5678",
"metadata": {
"plan": "premium",
"billingCycle": "annual"
},
"createdAt": "2026-04-09T10:00:00Z",
"expiresAt": "2026-04-09T10:30:00Z",
"updatedAt": "2026-04-09T10:00:00Z"
}
}The response above is shortened. Keep the session id with your order to look the session up later. The clientSecret is returned only here and is meant for the embedded payment widget; a redirect needs only url. Never put clientSecret in a URL.
About valueMinor: All amounts are in the smallest currency unit. For USD, 12500 means $125.00. For JPY (zero-exponent currency), 12500 means 12,500 yen. See the Merchant API Reference for the full schema.
Redirect the customer
After creating the session, redirect the customer's browser to session.url. Use HTTP 303 so the browser switches from POST to GET. The url carries a one-time token: create a new session for every checkout attempt instead of reusing a link.
app.post('/checkout', async (req, res) => {
const response = await fetch('https://<api-host>/v1/checkout-sessions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.MIRACLE_SECRET_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': req.body.orderId,
},
body: JSON.stringify({
amount: { currency: 'USD', valueMinor: req.body.amountMinor },
lineItems: req.body.lineItems,
merchantReference: req.body.orderId,
successUrl: `${process.env.BASE_URL}/payment/success`,
cancelUrl: `${process.env.BASE_URL}/payment/cancel`,
}),
});
const { data: session } = await response.json();
res.redirect(303, session.url);
});<!-- Or link directly if you already have the URL -->
<a href="<checkout-url>">Pay now</a>Session lifecycle
A Checkout Session moves through three statuses:
| Status | Description |
|---|---|
open | Session created, waiting for the customer to complete payment. |
completed | The payment succeeded or was captured. paymentId holds the id of the payment. |
expired | Session reached expiresAt without completing. The customer can no longer start a payment. |
open ──────→ completed
│ ▲
└──→ expired ──┘A completed session never changes status again. An expired session can still become completed when a payment the customer started before expiry goes through.
Checking session status
You can retrieve a session at any time:
curl https://<api-host>/v1/checkout-sessions/019560a1-b4c0-7d3f-8e2a-1a2b3c4d5e6f \
-H "Authorization: Bearer sk_test_your_secret_key"The key needs payment-experience:checkout:view. When the session is completed, the response includes paymentId:
{
"data": {
"id": "019560a1-b4c0-7d3f-8e2a-1a2b3c4d5e6f",
"status": "completed",
"paymentId": "019560a2-d7e1-7a4b-9c3d-2e4f6a8b0c1d",
"amount": {
"currency": "USD",
"valueMinor": 12500
},
"merchantReference": "order-5678",
"createdAt": "2026-04-09T10:00:00Z",
"expiresAt": "2026-04-09T10:30:00Z",
"updatedAt": "2026-04-09T10:02:30Z"
}
}To see the payment itself, call GET /v1/payments/{id} with that paymentId.
Handling the redirect
After payment, the customer is redirected to your successUrl exactly as you set it: the platform adds no parameters. To know which order the customer is returning for, put your own reference into the URL when you create the session, and store the session id with the order:
https://your-site.com/payment/success?order=order-5678On your success page, fetch the session to confirm the payment status before showing a confirmation:
app.get('/payment/success', async (req, res) => {
// Your own lookup: the order stores the session id from the create response
const order = await findOrder(req.query.order);
const response = await fetch(
`https://<api-host>/v1/checkout-sessions/${order.checkoutSessionId}`,
{
headers: {
'Authorization': `Bearer ${process.env.MIRACLE_SECRET_KEY}`,
},
}
);
const { data: session } = await response.json();
if (session.status === 'completed') {
res.render('payment-success', {
orderId: session.merchantReference,
amount: session.amount,
});
} else {
res.render('payment-pending', {
message: 'Your payment is being processed.',
});
}
});If the payment fails or the session expires, the customer can return to your cancelUrl, if you set one. That link may carry miracle_payment_status=failed or miracle_payment_status=canceled; use it only to choose what page to show, not as the payment result.
The webhook is the source of truth, not the redirect. Do not fulfill orders based on the redirect alone. The customer could have manipulated the URL, or the redirect may arrive before the payment is fully processed. Always use the webhook event (payment.succeeded) to update your order state. The redirect is only for showing the customer an appropriate page.
Line items
Line items describe what the customer is paying for. They are displayed on the checkout page and stored with the session.
| Field | Type | Required | Description |
|---|---|---|---|
label | string | Yes | Product name or description. |
unitAmount | MoneyAmount | Yes | Price per unit. Use the same currency as amount. |
quantity | integer | Yes | Quantity, 1 or more. |
description | string | No | Additional detail shown below the label. |
imageUrl | string | No | Product image URL displayed on checkout. |
productId | string | No | Your internal product identifier. |
taxRate | number | No | Tax rate for this line item. |
taxAmount | MoneyAmount | No | Tax amount for this line item. |
metadata | object | No | Key-value pairs attached to this line item. |
Amount consistency: When you send line items, the sum of unitAmount.valueMinor * quantity over all items must equal amount.valueMinor. Tax amounts and modifiers are not added to that sum. A mismatch answers 400 with code REQUEST_INVALID.
Customization
Branding
The checkout page displays two levels of branding:
- Merchant name -- shown in the order summary so the customer knows who they are paying.
- Tenant / PSP brand (footer: "Powered by", terms of service, privacy policy) -- shown in the page footer so the customer knows who processes the payment.
Neither is set per session: ask your operator to change them. The page has no logo or color settings.
The underlying platform is invisible to the customer -- HPP is fully white-label.
Locale
Set the locale parameter when creating a session to choose the language of the checkout page: en, ru or kk (a regional form such as ru-RU works too). A language the customer picked on the page earlier wins over locale. Without locale, or with a language the page does not support, the page follows the customer's browser language and falls back to English.
Payment method filtering
Control which payment methods appear on checkout:
{
"paymentMethodTypes": ["card"],
"brands": ["visa", "mastercard"],
"excludeBrands": ["amex"]
}paymentMethodTypesandbrandsare include filters (AND with your merchant configuration).excludePaymentMethodTypesandexcludeBrandsare exclude filters (applied after include, always win).- If no filters are set, all payment methods enabled in your merchant configuration are shown.
Manual capture
If you need to authorize first and capture later (e.g., for pre-orders or reservations), set captureMethod to "manual":
{
"captureMethod": "manual"
}The payment will be authorized but not charged. You must call capture within the authorization window. See Capture for details.
Metadata
Attach key-value pairs to a session. Both keys and values must be strings. Metadata is stored with the session and returned unchanged when you read it.
{
"metadata": {
"orderId": "order-5678",
"customerId": "cust-1234",
"source": "website"
}
}Error handling
Session creation errors
If the request is rejected, the API answers with an error object:
{
"error": {
"type": "https://api.miraclepsp.com/errors/request-invalid",
"title": "Request invalid",
"status": 400,
"detail": "The total of line items must match the requested amount.",
"code": "REQUEST_INVALID",
"retryable": false
}
}Common errors:
| Status and code | Cause |
|---|---|
400 REQUEST_INVALID | A required field (amount, lineItems, merchantReference, successUrl) is missing, a field is unknown or has the wrong type, or a URL is not an absolute http:// or https:// URL. detail is Request validation failed, and details lists each problem. |
400 REQUEST_INVALID | amount.valueMinor is zero, or the line items do not add up to amount. detail says which. |
400 REQUEST_INVALID | The Idempotency-Key header is missing. |
403 FORBIDDEN | The key lacks payment-experience:checkout:create, your merchant is not active, or the hosted payment page is not enabled for it. |
409 DUPLICATE_TRANSACTION | The same Idempotency-Key was already used with a different request body. |
422 UNSUPPORTED_CURRENCY | The currency is not enabled for your merchant. |
Expired sessions
If a customer tries to pay after the session has expired, they see an expiration message on the checkout page. You do not need to handle this in your code. Create a new session if the customer wants to try again.
Customer abandonment
If the customer closes the browser or navigates away without completing payment, the session remains open until it expires. You can monitor for this by checking for sessions that are still open past a reasonable time.
Best practices
- Always set
merchantReferencewith your order ID. This is your primary key for reconciliation and support lookups. - Use
metadatato link sessions to your internal records (customer ID, cart ID, campaign source). - Set the
Idempotency-Keyheader on every session creation request. This prevents duplicate sessions if your server retries due to a network timeout. Use your order ID as the key. - Configure your webhook URL in the portal under Settings > Webhooks. Sessions have no per-session webhook setting.
- Verify payment via webhook, not the redirect. See Handle Webhooks.
- Handle expired sessions gracefully. If a customer returns to an expired session, create a new one and redirect them.
- Use HTTPS for all URLs. The API also accepts
http://forsuccessUrlandcancelUrl, which is only fit for local testing.
Related guides
- Your First Payment — quickstart walkthrough
- Handle Webhooks — webhook setup and signature verification
- Capture — manual capture for authorized payments
- Payment Methods — available payment methods and filtering
- Test Cards — sandbox test scenarios
- Go-Live Checklist — production readiness checklist