Requests API

Create, get, list, and cancel JoPay payment requests. Idempotency and field reference.

Overview

The Requests API is the core of a JoPay integration. You create a payment request, share the returned pay_page_url with your customer, and JoPay notifies you (via webhook) when the payment is verified on-chain. Every endpoint is authenticated with a partner API key. See Authentication for scopes and key modes.

Base URL: https://<partner-slug>.jopay.app or https://admin.jopay.app. Both host the same routes.

Create a request

POST /api/v1/requests/create

Required scope: requests:write.

curl -X POST https://<partner-slug>.jopay.app/api/v1/requests/create \
  -H "Authorization: Bearer jo_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "merchant_id": "b4d5e6f7-8910-1112-1314-151617181920",
    "fiat_amount_int": 2500,
    "memo": "Invoice #INV-2026-0042",
    "idempotency_key": "a1b2c3d4e5f67890abcdef1234567890",
    "metadata": {
      "order_id": "ORD-12345",
      "customer_id": "CUST-456"
    }
  }'

Request body

FieldTypeRequiredDescription
merchant_iduuidYesA merchant assigned to your partner.
fiat_amount_intintYesAmount in the partner's configured currency, as minor units (cents for EUR/USD/GBP, paise for INR, whole yens for JPY). 2500 = €25.00 for a partner in EUR. Must be positive, ≤ 2,147,483,647.
fiat_currency_codestring (ISO 4217)NoAssertion, not an override. If supplied and doesn't match the merchant's effective currency, the server returns 400 currency_mismatch with expected_fiat_code in the body. Use to catch integration bugs ("I thought this merchant was in EUR but they're in USD"). Omit if you don't care.
memostring (≤ 500 chars)NoShort note shown to the customer on the pay page.
idempotency_keystringNo32–64 chars, base64url or hex. See Idempotency below.
metadataobjectNoString key/value pairs (max 20 keys, 40 chars per key, 500 chars per value). Round-trips through the API response and the webhook payload — correlate by metadata.order_id instead of maintaining your own id mapping.
payment_modestringNo"pos" (default) or "invoice". Invoice mode requires customer_name (see below) and uses a longer default expiry (7 days).
customer_namestring (≤ 200 chars)Required in invoice modeNamed counterparty for invoice-mode requests.
customer_emailstring (≤ 254 chars)NoOptional customer email for receipts.
customer_phonestring (≤ 50 chars)NoOptional customer phone for receipts.
expiry_minutesintNoOverride default request expiry. Must be one of the partner-allowed values. Defaults to partner config for POS mode, 10080 (7 days) for invoice mode.

Response

{
  "ok": true,
  "request_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "status": "requested",
  "proof_status": "none",
  "pay_page_url": "https://pay.jopay.app/a1b2c3d4-...",
  "expires_at": "2026-04-17T18:00:00.000Z",
  "created_at": "2026-04-17T17:00:00.000Z",
  "proof_verified_at": null,
  "tx_hash": null,
  "amount": {
    "fiat_int": 2500,
    "fiat_code": "EUR",
    "usdc_micro": "2750000"
  },
  "merchant_id": "b4d5e6f7-...",
  "metadata": { "order_id": "ORD-12345", "customer_id": "CUST-456" }
}

If the request is a replay (same idempotency key, same merchant), the response additionally includes idempotent_replay: true and returns the existing request unchanged — same request_id, same pay_page_url, same amounts.

Get a request

GET /api/v1/requests/:id

Required scope: requests:read.

Fetch the current state of one request by its id. Returns the same shape as Create response — useful for polling proof_status if you don't have a webhook endpoint yet.

curl https://<partner-slug>.jopay.app/api/v1/requests/a1b2c3d4-... \
  -H "Authorization: Bearer jo_live_xxx"

Returns 404 not_found if the id doesn't exist or belongs to a different partner. JoPay never discloses cross-tenant existence — a 404 from another partner's id looks identical to a 404 from a typo.

List requests

GET /api/v1/requests

Required scope: requests:read.

Paginated list of your partner's requests. Useful for reconciliation ("all verified payments since last night's batch") and health dashboards.

Query parameters

ParamTypeDescription
statusstringOne of: requested, opened, expired, voided.
proof_statusstringOne of: none, attached, verified.
merchant_iduuidScope to one merchant. Must belong to this partner.
sinceISO-8601created_at >= since.
untilISO-8601created_at < until.
limitintMax 100. Default 50.
offsetintFor pagination. Default 0.

Response

{
  "ok": true,
  "requests": [ /* array of request objects, same shape as get */ ],
  "has_more": true,
  "limit": 50,
  "offset": 0
}

Use has_more to drive pagination rather than the length of requests: the response always returns up to limit items, and has_more tells you whether another page exists.

For reconciliation jobs, paginate with a moving since cursor rather than stepping through offset. This stays correct even if new requests land mid-iteration.

Cancel a request

POST /api/v1/requests/:id/cancel

Required scope: requests:write.

Void a pending request. Allowed only while status === "requested" AND proof_status === "none". Once a customer has opened the pay page (status becomes opened) or submitted proof (proof_status becomes attached or verified), cancel is rejected with 409 cannot_cancel. The response body includes the current request object so you know why.

curl -X POST https://<partner-slug>.jopay.app/api/v1/requests/a1b2c3d4-.../cancel \
  -H "Authorization: Bearer jo_live_xxx"

On success returns the updated request with status: "voided".

Idempotency

Pass idempotency_key on create to make retries safe. If you send the same key twice for the same merchant, the second call returns the existing request unchanged — no duplicate is created. The response includes idempotent_replay: true so you can distinguish a replay from a first-time create if you need to.

Format rules

  • Length: 32–64 characters
  • Charset: base64url (A–Z, a–z, 0–9, _, -) OR hex (0–9, a–f)
  • Invalid format returns 400 invalid_idempotency_key

Good choices: a UUID v4 (36 chars, matches base64url), a SHA-256 hex digest of your internal order id (64 chars), or a random 32-char token. Don't use short human-readable strings — they will fail the length check.

Scope

Idempotency is scoped to (partner, merchant, key):

  • Same key on the same merchant → returns the existing request (replay).
  • Same key on a different merchant of the same partner → creates a new request. Use this if you want to share a key across multiple merchants.
  • Same key across different partners is impossible — your API key is already partner-scoped.

Replays return the full request object, not a 409. The response shape is identical to a first-time create (plus the idempotent_replay: true flag), so you don't have to branch on it.

Replays return the original amounts and metadata, not the new ones you submitted. If you retry with a different fiat_amount_int or different metadata under the same key, your new values are silently ignored. Use a fresh idempotency key whenever the payment details actually change.

If you don't supply a key

The server generates a random UUID as the internal idempotency key, so every request is stored with some key internally — but there is no replay protection on your side. A retry without an idempotency key creates a duplicate. Always supply one for production writes.

What next?