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.

  1. E-commerce checkout — short expiry, browser redirect
  2. Invoicing — long expiry, email delivery
  3. Recurring billing — one plan, many charges, same webhook shape
  4. Reconciliation — scheduled sync against your own ledger
Every pattern below assumes you've set up a webhook endpoint per Webhooks. The code snippets use 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

  1. User clicks "Checkout". Your server computes the total in minor units (cents).
  2. POST /api/v1/requests/create with payment_mode: "pos" (default), your order id in metadata, and an idempotency key derived from the order.
  3. Redirect the browser to the returned pay_page_url.
  4. Handle payment.proof_verified on your webhook endpoint. Correlate by metadata.order_id and mark the order paid.
  5. 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 });
}
Set the order to a "pending payment" state on create, not "paid". Flip it to paid only when the verified webhook arrives. If the customer closes the browser mid-pay, the order stays pending; the webhook lands later and closes the loop without any user intervention.

2. Invoicing

You send an invoice email with a pay link. Customer pays days later. Same API, longer expiry, invoice-mode required fields.

Flow

  1. Your system generates an invoice. You call POST /api/v1/requests/create with payment_mode: "invoice" and supply customer_name (required in invoice mode per Requests API).
  2. Email the pay_page_url to the customer.
  3. Customer opens the link hours or days later. The default invoice-mode expiry is 7 days; pass expiry_minutes to override.
  4. payment.proof_verified fires 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,
    },
  }),
});
Invoice mode requires 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

  1. Merchant configures a plan in their JoPay dashboard (weekly, biweekly, monthly). Merchant-side setup, not partner-side.
  2. JoPay's cron generates a payment request per charge date on the merchant's behalf.
  3. 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);
}
Failed charges do not emit a separate webhook event type today — they just don't emit 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

  1. Run a scheduled job (nightly, weekly — whatever your finance cadence requires).
  2. GET /api/v1/requests?proof_status=verified&since=...&until=... with a moving since cursor.
  3. Paginate while has_more: true.
  4. 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
}
Paginate by moving 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-resistant

Webhook verifier

Full implementation in Webhooks → Node.js example. Drop it in once and reuse across every pattern above.

What next?