Mintcash
Guides

Accept a one-time payment (HPP)

The complete server-side flow for a hosted payment page integration. No PCI scope on your side.

This is the simplest way to accept a card payment. Your code never touches the card — the customer enters it on the provider's hosted page. PCI scope stays out of your stack.

The flow

Rendering diagram…

Implementation

Create the payment

Send POST /payments from your server. Include the customer's identity (so the same customer is reused across orders) and a returnUrl so the provider knows where to send the browser after.

const r = await fetch("https://sandbox.mintcash.me/payments", {
  method: "POST",
  headers: {
    Authorization: `Basic ${btoa(`${publicKey}:${secretKey}`)}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    externalId: orderId,            // your unique order ID — used for idempotency
    amount: 4999,                   // in cents
    currency: "USD",
    returnUrl: `${baseUrl}/order/${orderId}/complete`,
    customer: {
      externalId: customerId,
      email: customer.email,
      name: customer.name,
    },
  }),
});

const { payment, redirectUrl } = await r.json();

The response includes the payment record (status created) and a redirectUrl — the URL of the hosted page on the provider.

Redirect the customer

Send the browser to redirectUrl. From your server-rendered checkout page, this is a 302. From a SPA, it's a window.location.assign(redirectUrl).

return Response.redirect(redirectUrl, 302);

Handle the return

The provider redirects back to your returnUrl after the customer finishes. Do not fulfil yet. The redirect tells you the customer's browser is done; the webhook tells you the money moved.

On the return page, show a "thanks, we're processing your order" state. Look up the payment by externalId and poll or wait for the webhook:

// GET /order/[orderId]/complete
const payment = await fetch(
  `https://sandbox.mintcash.me/payments/${stored.paymentId}`,
  { headers: { Authorization: ... } }
).then((r) => r.json());

if (payment.status === "succeeded") {
  return renderThankYou();
} else if (payment.status === "failed") {
  return renderFailure(payment.failureMessage);
} else {
  return renderProcessing();   // pending — webhook is on its way
}

Fulfil on the webhook

When payment.succeeded arrives, that's your trigger to ship the order, grant the credits, send the email.

// POST /webhooks/mintcash
import { verifyWebhook } from "@/lib/mintcash";

export async function POST(req: Request) {
  const body = await req.text();
  const signature = req.headers.get("x-signature");
  if (!verifyWebhook(body, signature, process.env.MINTCASH_SIGNING_SECRET)) {
    return new Response("invalid signature", { status: 401 });
  }

  const event = JSON.parse(body);
  if (event.environment !== process.env.MINTCASH_ENV) {
    return new Response("wrong env", { status: 400 });
  }

  // Dedupe by eventId
  if (await alreadyProcessed(event.eventId)) {
    return new Response("ok", { status: 200 });
  }

  if (event.event === "payment.succeeded") {
    await fulfillOrder(event.data.externalId);
  }

  await markProcessed(event.eventId);
  return new Response("ok", { status: 200 });
}

See signature verification for the full reference implementation.

What to test

Exercise these scenarios in sandbox using the cards from the test cards reference:

  • Happy path with the success card — should hit payment.succeeded
  • Failure with a failing card — should hit payment.failed
  • 3DS challenge with the 3DS-enrolled success card — customer sees the challenge, then bounces back
  • Duplicate externalId — second request returns the same payment record
  • Tampered webhook — your endpoint returns 401 and doesn't fulfil

Subscriptions use the same shape

When you need recurring billing, see Set up a subscription — the API and webhook shape are the same; you also pick a billing interval and we handle renewals.