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.
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.
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.
// 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#
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.
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_paidwithamount_receivedpopulated. 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_receivedgreater 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, withlate: trueset. - Wrong chain. A customer sending on an unaccepted chain produces
payment_intent.unsupported_chainand 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#
- Handle webhooks reliably — the consumer contract in detail.
- Payment intents reference — every field and status.