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
| Field | Type | Required | Description |
|---|---|---|---|
merchant_id | uuid | Yes | A merchant assigned to your partner. |
fiat_amount_int | int | Yes | Amount 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_code | string (ISO 4217) | No | Assertion, 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. |
memo | string (≤ 500 chars) | No | Short note shown to the customer on the pay page. |
idempotency_key | string | No | 32–64 chars, base64url or hex. See Idempotency below. |
metadata | object | No | String 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_mode | string | No | "pos" (default) or "invoice". Invoice mode requires customer_name (see below) and uses a longer default expiry (7 days). |
customer_name | string (≤ 200 chars) | Required in invoice mode | Named counterparty for invoice-mode requests. |
customer_email | string (≤ 254 chars) | No | Optional customer email for receipts. |
customer_phone | string (≤ 50 chars) | No | Optional customer phone for receipts. |
expiry_minutes | int | No | Override 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
| Param | Type | Description |
|---|---|---|
status | string | One of: requested, opened, expired, voided. |
proof_status | string | One of: none, attached, verified. |
merchant_id | uuid | Scope to one merchant. Must belong to this partner. |
since | ISO-8601 | created_at >= since. |
until | ISO-8601 | created_at < until. |
limit | int | Max 100. Default 50. |
offset | int | For 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.
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.
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.