Skip to content
JuicePay

JuicePay documentation

Create account

Documentation

Accept a payment

Create a payment intent, present checkout, and confirm settlement.

This guide covers a complete inbound flow: creating a payment intent, presenting checkout, and confirming settlement in a way that survives retries and duplicate webhooks.

Create the intent#

Create the intent server-side, never from the browser, so your secret key never leaves your infrastructure.

ts
import { JuicePay } from "@juicepay/node";

const juicepay = new JuicePay(process.env.JUICEPAY_KEY!);

export async function createInvoice(order: Order) {
  const intent = await juicepay.paymentIntents.create(
    {
      amount: order.total.toFixed(2),
      asset: "USDC",
      reference: order.id,
      description: `Order ${order.reference}`,
      accepted_chains: ["base", "solana", "arbitrum"],
      settle_to: { asset: "USDC" },
      expires_in: 3600,
      metadata: { customer_id: order.customerId },
    },
    { idempotencyKey: `order:${order.id}:intent` },
  );

  return { id: intent.id, url: intent.hosted_url };
}

Store intent.id against the order before you redirect. That single column is what makes the rest of this flow safe.

Present checkout#

Send the customer to hosted_url, or mount the embedded component.

tsx
import { JuicePayCheckout } from "@juicepay/react";

export function Checkout({ clientSecret }: { clientSecret: string }) {
  return (
    <JuicePayCheckout
      clientSecret={clientSecret}
      onComplete={() => router.replace("/orders/complete")}
      appearance={{ theme: "juice" }}
    />
  );
}

The hosted page shows the amount, the accepted chains, a live rate if the settlement asset differs, and a countdown to expiry. It handles chain switching, wallet connection, and fee disclosure on your behalf.

Treat redirect as a hint, not a fact#

Customers close tabs. Networks drop. Never fulfil an order on the basis of a redirect.

ts
// Good: the redirect only starts the wait.
export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const intentId = searchParams.get("payment_intent");
  if (!intentId) return new Response("Missing intent", { status: 400 });

  const intent = await juicepay.paymentIntents.retrieve(intentId);
  if (intent.status !== "succeeded") {
    return renderPending(intent);
  }
  await fulfil(await findOrderByIntent(intentId), intent);
  return renderSuccess();
}

The webhook is the authoritative signal. The redirect exists so the customer sees something pleasant.

Fulfil from the webhook#

ts
export async function POST(request: Request) {
  const event = juicepay.webhooks.constructEvent(
    await request.text(),
    request.headers.get("juicepay-signature")!,
    process.env.JUICEPAY_WEBHOOK_SECRET!,
  );

  switch (event.type) {
    case "payment_intent.succeeded": {
      const { id, reference, settled_amount, asset } = event.data.object;
      await fulfilIdempotently({ intentId: id, reference, settled_amount, asset });
      break;
    }
    case "payment_intent.expired":
      await releaseReservation(event.data.object.reference);
      break;
    case "payment_intent.reverted":
      await reverseFulfilment(event.data.object.id);
      break;
  }

  return Response.json({ received: true });
}

fulfilIdempotently must be a no-op the second time it sees an intent ID. Use a unique constraint on your own table rather than a read-then-write check, which races under concurrent deliveries.

sql
INSERT INTO fulfilments (intent_id, order_id, amount, asset)
VALUES ($1, $2, $3, $4)
ON CONFLICT (intent_id) DO NOTHING;

Handle the awkward cases#

  • Underpayment. The customer sent less than requested. The intent moves to partially_paid with amount_received populated. Decide explicitly whether to accept a small tolerance; a 1% tolerance is common for retail, and zero is common for invoicing.
  • Overpayment. You receive amount_received greater than the intent amount. The surplus is credited to your balance; refund it or carry it, but make it a decision rather than an accident.
  • Late payment. Funds arriving after expiry move the intent to succeeded_late. Your handler sees the same event type as an on-time payment, with late: true set.
  • Wrong chain. A customer sending on an unaccepted chain produces payment_intent.unsupported_chain and no credit. Send them back to checkout with a clear message rather than manual recovery instructions.

Reconcile#

Attach your own reference to every intent and use it as the join key in your ledger. Do not join on the intent ID alone — if you ever recreate an intent for the same order, the reference is what ties the two attempts together.

Next steps#