Mintcash
Guides

Accept a one-time payment

The complete server-side flow for MintCash 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: 49.99,                  // major units — dollars, not 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.

Embed the payment form

If you'd rather keep customers on your own site instead of sending them away to the full hosted page, you can render the exact same hosted payment page inside an iframe. The customer fills in their card without ever leaving your checkout, and PCI scope still stays out of your stack — the card is entered on our page, not yours.

The integration is identical to the flow above: you still call POST /payments, get back a redirectUrl, and fulfil on the webhook. The only difference is that instead of a 302, you point an iframe at that URL.

<iframe
  src="{{redirectUrl from POST /payments}}"
  width="100%"
  height="600"
  frameborder="0"
  allow="payment"
></iframe>

Let us know before you go live

The hosted page defaults to standalone behavior — full-page redirects on completion, 3DS challenges that take over the window, and similar actions that break when run inside an iframe. Add the iframe on your side and tell us which merchant account it's for, and we'll configure the page to run in embedded mode: redirects are suppressed in favor of in-frame transitions, and any action that isn't iframe-compatible is adapted (for example, 3DS challenges render inside the frame and completion is signaled back to your page rather than navigating away). Until we flip that switch the page will still try to redirect, so coordinate with us before shipping.

Everything else — the returnUrl, the return-page polling, and webhook-driven fulfilment — works exactly as described above.

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.