Miracle Docs

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:

  1. Your server creates a Checkout Session by calling POST /v1/checkout-sessions with a secret key that holds payment-experience:checkout:create.
  2. You redirect the customer's browser to the url returned in the response.
  3. The customer lands on the hosted checkout page, selects a payment method, enters card details (or other method), and completes 3D Secure if required.
  4. The platform processes the payment and sends a webhook to your server with the result.
  5. The customer is redirected to your successUrl after a successful payment. After a failed payment or an expired session, the customer can return to your cancelUrl, 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

ParameterTypeDescription
amountMoneyAmountTotal order amount. currency (ISO 4217, 3 letters) and valueMinor (integer minor units — cents for USD, yen for JPY).
lineItemsLineItem[]Order line items displayed on the checkout page. Send an empty array if you have none. See Line items below.
merchantReferencestringYour order ID or reference. Used for reconciliation.
successUrlstringURL to redirect the customer to after a successful payment.

Optional

ParameterTypeDefaultDescription
cancelUrlstring—URL the customer can return to after a failed payment or an expired session.
returnUrlstringsuccessUrlURL used for the 3D Secure redirect.
localestringcustomer's browser languageLanguage of the checkout page: en, ru or kk. See Locale.
captureMethodstring"automatic""automatic" captures immediately. "manual" authorizes only — you call capture later. See Capture.
customerInfoCustomerInfo—Pre-fill customer information (name, email, phone) on checkout.
billingAddressBillingAddress—Pre-fill billing address fields.
shippingAddressShippingAddress—Pre-fill shipping address fields.
paymentMethodTypesstring[]all enabledInclude filter: only show these payment method types (e.g., ["card", "qr"]).
brandsstring[]all enabledInclude filter: only show these brands (e.g., ["visa", "mastercard"]).
excludePaymentMethodTypesstring[]—Exclude filter: hide these payment method types. Exclude takes priority over include.
excludeBrandsstring[]—Exclude filter: hide these brands.
modifiersPaymentModifier[]—Discounts, coupons, or promotions applied to the order.
metadataobject—Key-value pairs (string values) stored with the session.
idempotencyKeystring—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 here

Response

{
  "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:

StatusDescription
openSession created, waiting for the customer to complete payment.
completedThe payment succeeded or was captured. paymentId holds the id of the payment.
expiredSession 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-5678

On 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.

FieldTypeRequiredDescription
labelstringYesProduct name or description.
unitAmountMoneyAmountYesPrice per unit. Use the same currency as amount.
quantityintegerYesQuantity, 1 or more.
descriptionstringNoAdditional detail shown below the label.
imageUrlstringNoProduct image URL displayed on checkout.
productIdstringNoYour internal product identifier.
taxRatenumberNoTax rate for this line item.
taxAmountMoneyAmountNoTax amount for this line item.
metadataobjectNoKey-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"]
}
  • paymentMethodTypes and brands are include filters (AND with your merchant configuration).
  • excludePaymentMethodTypes and excludeBrands are 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 codeCause
400 REQUEST_INVALIDA 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_INVALIDamount.valueMinor is zero, or the line items do not add up to amount. detail says which.
400 REQUEST_INVALIDThe Idempotency-Key header is missing.
403 FORBIDDENThe key lacks payment-experience:checkout:create, your merchant is not active, or the hosted payment page is not enabled for it.
409 DUPLICATE_TRANSACTIONThe same Idempotency-Key was already used with a different request body.
422 UNSUPPORTED_CURRENCYThe 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 merchantReference with your order ID. This is your primary key for reconciliation and support lookups.
  • Use metadata to link sessions to your internal records (customer ID, cart ID, campaign source).
  • Set the Idempotency-Key header 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:// for successUrl and cancelUrl, which is only fit for local testing.

On this page