Integration patterns
Recipes for e-commerce checkout, invoicing, recurring billing, and reconciliation on the Partner API v1.
Overview
Four patterns cover almost every partner integration. The underlying API is the same — what changes is the lifecycle (expiry, sharing mechanism, update cadence) and what you do on the webhook side.
- E-commerce checkout — short expiry, browser redirect
- Invoicing — long expiry, email delivery
- Recurring billing — one plan, many charges, same webhook shape
- Reconciliation — scheduled sync against your own ledger
fetch and show only the integration-specific code — error handling, retries, and signature verification are in the dedicated pages.1. E-commerce checkout
Customer hits checkout → you create a request → redirect to the pay page → JoPay webhooks you on verified payment → you fulfill the order.
Flow
- User clicks "Checkout". Your server computes the total in minor units (cents).
POST /api/v1/requests/createwithpayment_mode: "pos"(default), your order id inmetadata, and an idempotency key derived from the order.- Redirect the browser to the returned
pay_page_url. - Handle
payment.proof_verifiedon your webhook endpoint. Correlate bymetadata.order_idand mark the order paid. - Redirect the user back to your site. Either JoPay's receipt page handles the final UX, or you render your own thank-you page once the webhook arrives.
Code
// 1. On checkout submit (your server)
async function createCheckout(order: Order): Promise<string> {
// Derive a deterministic idempotency key from the order. Retries of the
// same create under the same key return the original request, so a
// double-click or network retry doesn't produce duplicate pay links.
const idempotencyKey = sha256Hex(`checkout:${order.id}`);
const res = await fetch(
`https://${SLUG}.jopay.app/api/v1/requests/create`,
{
method: "POST",
headers: {
"Authorization": `Bearer ${JOPAY_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
merchant_id: MERCHANT_ID,
fiat_amount_int: order.totalCents,
memo: `Order #${order.id}`,
idempotency_key: idempotencyKey,
metadata: {
order_id: order.id,
customer_id: order.customerId,
},
}),
},
);
const { pay_page_url } = await res.json();
return pay_page_url;
}
// 2. On webhook (your server)
export async function POST(req: Request) {
const body = await req.text();
if (!verifyWebhook(body, req.headers.get("x-jopay-signature") ?? "", SECRET)) {
return new Response("bad signature", { status: 400 });
}
const payload = JSON.parse(body);
if (payload.event === "payment.proof_verified") {
const orderId = payload.metadata?.order_id;
if (orderId) await markOrderPaid(orderId, payload.tx_hash);
}
return new Response("ok", { status: 200 });
}2. Invoicing
You send an invoice email with a pay link. Customer pays days later. Same API, longer expiry, invoice-mode required fields.
Flow
- Your system generates an invoice. You call
POST /api/v1/requests/createwithpayment_mode: "invoice"and supplycustomer_name(required in invoice mode per Requests API). - Email the
pay_page_urlto the customer. - Customer opens the link hours or days later. The default invoice-mode expiry is 7 days; pass
expiry_minutesto override. payment.proof_verifiedfires when the payment lands.
Code
await fetch(`https://${SLUG}.jopay.app/api/v1/requests/create`, {
method: "POST",
headers: {
"Authorization": `Bearer ${JOPAY_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
merchant_id: MERCHANT_ID,
fiat_amount_int: invoice.totalCents,
payment_mode: "invoice", // long-expiry branch
customer_name: invoice.billTo, // required for invoice mode
customer_email: invoice.email, // optional, enables receipt email
memo: `Invoice #${invoice.number}`,
idempotency_key: sha256Hex(`invoice:${invoice.number}`),
metadata: {
invoice_id: invoice.id,
invoice_number: invoice.number,
},
}),
});customer_name. Omitting it returns 400 customer_name_required. The field is a named-counterparty hint — matchers use it to resolve ambiguous inbound transfers. It's not a privacy risk because the customer supplied it to you first (you're billing them by name).3. Recurring billing
Recurring plans are created by the merchant in their own dashboard, not via the Partner API v1 (which has no recurring-plan endpoint). From a developer integration perspective, each charge looks identical to a one-off payment — you get payment.proof_verified webhooks on a cadence.
Flow
- Merchant configures a plan in their JoPay dashboard (weekly, biweekly, monthly). Merchant-side setup, not partner-side.
- JoPay's cron generates a payment request per charge date on the merchant's behalf.
- Each charge webhook you receive has the same shape as a one-off payment. If the merchant tagged the plan with metadata, that metadata rides along on every charge.
Telling recurring charges apart
If you need to know which webhook deliveries are recurring-plan charges (vs standalone requests), ask the merchant to include a fixed plan_id or subscription=true flag in the plan's metadata. Every charge the plan spawns echoes that metadata back to you:
// Merchant's plan metadata (set once, when the plan is created):
// { "subscription_id": "SUB-8821", "billing_cycle": "monthly" }
// Every webhook you receive for that plan's charges:
{
"event": "payment.proof_verified",
"request_id": "...",
"metadata": {
"subscription_id": "SUB-8821",
"billing_cycle": "monthly"
},
...
}
// Handler:
if (payload.metadata?.subscription_id) {
await recordSubscriptionCharge(
payload.metadata.subscription_id,
payload.request_id,
payload.tx_hash,
);
} else {
await recordOneOffPayment(payload.metadata?.order_id, payload.tx_hash);
}proof_verified. For proactive failed-charge handling, poll GET /api/v1/requests?since=... daily for requests whose status is expired or voided.4. Reconciliation
Periodic sync against your own ledger. Pull verified payments for a window, match against your internal records, flag any drift.
Flow
- Run a scheduled job (nightly, weekly — whatever your finance cadence requires).
GET /api/v1/requests?proof_status=verified&since=...&until=...with a movingsincecursor.- Paginate while
has_more: true. - For each verified request, look up your own order/invoice by
metadata.order_id(or whatever key you tagged). Flag anything that doesn't match.
Code
async function reconcileSince(cursor: string): Promise<string> {
let since = cursor;
const until = new Date().toISOString();
let newestSeen = since;
while (true) {
const url = new URL(`https://${SLUG}.jopay.app/api/v1/requests`);
url.searchParams.set("proof_status", "verified");
url.searchParams.set("since", since);
url.searchParams.set("until", until);
url.searchParams.set("limit", "100");
const res = await fetch(url, {
headers: { "Authorization": `Bearer ${JOPAY_KEY}` },
});
const { requests, has_more } = await res.json();
for (const r of requests) {
const orderId = r.metadata?.order_id;
const internal = orderId ? await lookupOrder(orderId) : null;
if (!internal) {
await flagUnmatchedPayment(r);
} else if (internal.paidAmount !== r.amount.fiat_int) {
await flagAmountDrift(r, internal);
}
newestSeen = r.created_at;
}
if (!has_more) break;
since = newestSeen;
}
return newestSeen; // store as next run's cursor
}since cursor, not by steppingoffset. If new requests land mid-iteration, offset-based pagination can skip rows. A since cursor tied to each batch's last created_at stays correct under concurrent writes.Shared helpers
Deterministic idempotency key
A SHA-256 hex digest of an opaque string is always 64 chars and always matches the hex charset — perfect for idempotency keys, which require 32–64 chars of base64url or hex. Seed it with an internal identifier that won't be reused:
import { createHash } from "crypto";
function sha256Hex(input: string): string {
return createHash("sha256").update(input).digest("hex");
}
// Usage:
const key = sha256Hex(`checkout:${order.id}`);
// -> 64-char hex string, deterministic, collision-resistantWebhook verifier
Full implementation in Webhooks → Node.js example. Drop it in once and reuse across every pattern above.