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/hpp/sessions. - 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
successUrl(payment completed) orcancelUrl(customer abandoned checkout).
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/hpp/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. 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. |
cancelUrl | string | URL to redirect the customer to if they abandon checkout. |
Optional
| Parameter | Type | Default | Description |
|---|---|---|---|
returnUrl | string | — | Alternative return URL (e.g., for 3DS redirect-back flows). |
locale | string | auto-detected | Language for the checkout page (e.g., en, de, fr). |
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 and payment. Up to 50 keys. |
idempotencyKey | string | — | Alternative to the Idempotency-Key header. Header is preferred. |
Session expiration is configured at the platform level (via your Integration Profile), not per request. The default is 30 minutes. Contact your operator to adjust.
Example
curl -X POST https://api.miracle.com/v1/hpp/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.miracle.com/v1/hpp/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",
"status": "open",
"url": "https://pay.example.com/checkout?cs=cs_test_...",
"clientSecret": "cs_test_...",
"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"
}
}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.
app.post('/checkout', async (req, res) => {
const response = await fetch('https://api.miracle.com/v1/hpp/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="https://pay.example.com/checkout?cs=cs_test_...">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 | Customer completed payment successfully. paymentId and paymentStatus are populated. |
expired | Session reached expiresAt without completing. The customer can no longer pay. |
open ──→ completed
│
└────→ expiredOnce a session is completed or expired, it is immutable and cannot change status.
Checking session status
You can retrieve a session at any time:
curl https://api.miracle.com/v1/hpp/sessions/019560a1-b4c0-7d3f-8e2a-1a2b3c4d5e6f \
-H "Authorization: Bearer sk_test_your_secret_key"When the session is completed, the response includes paymentId and paymentStatus:
{
"data": {
"id": "019560a1-b4c0-7d3f-8e2a-1a2b3c4d5e6f",
"status": "completed",
"paymentId": "019560a2-d7e1-7a4b-9c3d-2e4f6a8b0c1d",
"paymentStatus": "succeeded",
"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"
}
}Handling the redirect
After payment, the customer is redirected to your successUrl with a session_id query parameter:
https://your-site.com/payment/success?session_id=019560a1-b4c0-7d3f-8e2a-1a2b3c4d5e6fOn your success page, fetch the session to confirm the payment status before showing a confirmation:
app.get('/payment/success', async (req, res) => {
const sessionId = req.query.session_id;
const response = await fetch(
`https://api.miracle.com/v1/hpp/sessions/${sessionId}`,
{
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 customer cancels, they are redirected to your cancelUrl. No session_id parameter is appended.
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 (excluding tax). Currency must match amount.currency. |
quantity | number | Yes | Quantity (>= 1). |
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 as a percentage (0--100). If provided, taxAmount is also required. |
taxAmount | MoneyAmount | No | Tax amount for this line item. If provided, taxRate is also required. |
metadata | object | No | Key-value pairs attached to this line item. |
Amount consistency: The session amount must equal the sum of all line items: sum(unitAmount * quantity + taxAmount) +/- modifiers. A mismatch returns a validation error.
Customization
Branding
Configure your logo, brand colors, and business name in the portal under Settings > Branding. These apply to all HPP sessions automatically.
The checkout page displays two levels of branding:
- Merchant brand (logo, 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.
The underlying platform is invisible to the customer -- HPP is fully white-label.
Locale
Set the locale parameter when creating a session to control the language of the checkout page. If omitted, the language is auto-detected from the customer's browser.
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 up to 50 key-value pairs to a session. Both keys and values must be strings. Metadata is stored with the session and copied to the resulting payment.
{
"metadata": {
"orderId": "order-5678",
"customerId": "cust-1234",
"source": "website"
}
}Error handling
Session creation errors
If the request is invalid, the API returns a 400 response with details:
{
"error": {
"type": "validation_error",
"code": "invalid_amount",
"message": "amount.valueMinor must be a positive integer",
"retryable": false
}
}Common validation errors:
| Error | Cause |
|---|---|
invalid_amount | valueMinor is not a positive integer, or currency code is invalid. |
line_items_mismatch | Sum of line items does not match amount. |
missing_field | A required field (amount, lineItems, merchantReference, successUrl, cancelUrl) is missing. |
invalid_url | successUrl or cancelUrl is not a valid HTTPS URL. |
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 globally in the portal under Settings > Webhooks. Use per-session webhook overrides only when you have a specific routing need.
- 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. Both
successUrlandcancelUrlmust be HTTPS in production.
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