On this page
Create
Create an invoice to accept a crypto payment. Two flows: direct crypto (token and network fixed) and fiat (customer picks the token on the hosted checkout).
API key: Payment
Request body
| Parameter | Type | Required | Description |
|---|---|---|---|
project_id |
string | Yes | Prefixed project identifier (prj_...). API key is merchant-scoped; specify which project receives the invoice |
amount |
string | Yes | Payment amount as a decimal string (e.g. "50.00") |
currency |
string | Yes | Fiat currency code (USD, EUR, etc.) or stablecoin symbol. See Supported Currencies for the full list |
network |
string | Conditional | Blockchain network code — TRC20, ERC20, BEP20, POLYGON, ARBITRUM, OPTIMISM, BASE, TON, AVAX, SOL, NEAR, SUI, or PLASMA (see Supported Currencies). Required when you want to lock a crypto invoice to a specific network |
external_order_id |
string | Yes | Your unique order identifier (max 128 chars), unique per project. Makes invoice creation idempotent: if an invoice with this external_order_id already exists in the project, you get that existing invoice back with 200 OK — not a duplicate, not an error |
client_id |
string | No | Your customer identifier (max 200 chars). Tags the invoice to a customer of yours, which lets the per-customer limit on simultaneously open invoices apply |
allow_multiple_payments |
boolean | No | Whether the invoice accepts multiple partial payments. Default: true |
customer_fee_percent |
integer | No | Customer-facing fee percentage (0-100). Overrides the project-level setting for this invoice |
Fiat amount precision
For fiat-flow invoices, amount must fit the minor unit of currency.
| Minor units | Currencies |
|---|---|
| 0 decimals | JPY, KRW, VND, CLP |
| 2 decimals | USD, EUR, GBP, CHF, CAD, AUD, NZD, PLN, CZK, DKK, SEK, NOK, HUF, RUB, UAH, GEL, CNY, HKD, INR, IDR, MYR, PHP, THB, PKR, SGD, BRL, MXN, ARS, TRY, AED, ILS, NGN, ZAR, BDT, BMD, LKR, MMK, SAR, TWD, VEF |
| 3 decimals | BHD, KWD |
Examples: 23.34 is valid for USD; 23.345 is rejected for USD; 23 is valid for JPY; 23.01 is rejected for JPY.
Flow determination
The flow is determined by which fields you provide. currency decides whether the amount is priced in a token or in fiat; network decides whether the chain is fixed up front or chosen by the customer.
currency |
network |
Flow | What the customer still chooses |
|---|---|---|---|
stablecoin (USDT, USDC, …) |
a network (TRC20, ERC20, …) |
Direct crypto — amount is the token amount, on a fixed chain |
Nothing — token and network are locked |
stablecoin (USDT, USDC, …) |
— | Crypto flow — amount is the token amount, chain open |
The network to pay on |
fiat (USD, EUR, …) |
— | Fiat flow — amount is a fiat amount |
The token and the network |
Every invoice is created in awaiting_client — no payment address exists yet. The customer's choice on the hosted checkout is what assigns the address and moves the invoice to awaiting_payment. In the direct-crypto case there is nothing left to choose, so the checkout confirms the pre-set token and network and assigns the address in one step. For the fiat flow, the fiat-to-crypto rate is locked at that moment and returned as payment.exchange_rate.
See Payment flow → Two creation flows for the full lifecycle, initial statuses, and FX-rate locking semantics.
Example request — fiat flow
Amount is in fiat; the customer picks a token on the hosted page and the rate locks at selection time.
{
"project_id": "prj_xFukZuAJZR06pLVBh3uwzv",
"amount": "50.00",
"currency": "USD",
"external_order_id": "order-12345",
"client_id": "customer-67890"
}
Example request — direct crypto
Token + network are fixed at creation; an address is assigned immediately.
{
"project_id": "prj_xFukZuAJZR06pLVBh3uwzv",
"amount": "50.00",
"currency": "USDT",
"network": "TRC20",
"external_order_id": "order-12345",
"client_id": "customer-67890"
}
Code examples
Each tab uses the official SDK for that language. Signing, serialization, safe retries, and typed errors are handled by the SDK.
import { Paymos, externalOrderId } from '@paymos/sdk';
const paymos = new Paymos({
apiKey: process.env.PAYMOS_API_KEY,
apiSecret: process.env.PAYMOS_API_SECRET,
});
const invoice = await paymos.invoices.create({
projectId: 'prj_xxxxxxxxxxxx',
amount: '100.00',
currency: 'USD',
externalOrderId: externalOrderId('order'),
clientId: 'customer-456',
});
console.log(invoice.invoiceId, invoice.paymentUrl);
import os
from paymos import Paymos
paymos = Paymos(
api_key=os.environ["PAYMOS_API_KEY"],
api_secret=os.environ["PAYMOS_API_SECRET"],
)
invoice = paymos.invoices.create(
project_id="prj_xxxxxxxxxxxx",
amount="100.00",
currency="USD",
external_order_id="order-123",
client_id="customer-456",
)
print(invoice["invoice_id"], invoice["payment_url"])
<?php
use Paymos\Client;
use Paymos\ClientConfig;
use Paymos\IdempotencyKey;
$paymos = new Client(new ClientConfig(
getenv('PAYMOS_API_KEY'),
getenv('PAYMOS_API_SECRET')
));
$invoice = $paymos->invoices()->create(array(
'project_id' => 'prj_xxxxxxxxxxxx',
'amount' => '100.00',
'currency' => 'USD',
'external_order_id' => IdempotencyKey::externalOrderId('order'),
'client_id' => 'customer-456',
));
echo $invoice['invoice_id'] . ' ' . $invoice['payment_url'];
package main
import (
"context"
"fmt"
"os"
paymos "github.com/Paymos-labs/go-sdk/v2"
)
func main() {
client, err := paymos.NewClient(os.Getenv("PAYMOS_API_KEY"), os.Getenv("PAYMOS_API_SECRET"))
if err != nil {
panic(err)
}
invoice, err := client.Invoices.Create(context.Background(), paymos.CreateInvoiceParams{
ProjectID: "prj_xxxxxxxxxxxx",
Amount: "100.00",
Currency: "USD",
ExternalOrderID: "order-123",
ClientID: stringPointer("customer-456"),
})
if err != nil {
panic(err)
}
fmt.Println(invoice.InvoiceID, invoice.PaymentURL)
}
func stringPointer(value string) *string { return &value }
using Paymos;
using var paymos = new PaymosClient(
Environment.GetEnvironmentVariable("PAYMOS_API_KEY")!,
Environment.GetEnvironmentVariable("PAYMOS_API_SECRET")!);
var invoice = await paymos.Invoices.CreateAsync(new CreateInvoiceRequest(
ProjectId: "prj_xxxxxxxxxxxx",
Amount: "100.00",
Currency: "USD",
ExternalOrderId: "order-123",
ClientId: "customer-456"));
Console.WriteLine($"{invoice.InvoiceId} {invoice.PaymentUrl}");
import io.paymos.CreateInvoiceRequest;
import io.paymos.Invoice;
import io.paymos.PaymosClient;
PaymosClient paymos = new PaymosClient(
System.getenv("PAYMOS_API_KEY"),
System.getenv("PAYMOS_API_SECRET"));
Invoice invoice = paymos.invoices.create(
CreateInvoiceRequest.builder()
.projectId("prj_xxxxxxxxxxxx")
.amount("100.00")
.currency("USD")
.externalOrderId("order-123")
.clientId("customer-456")
.build());
System.out.println(invoice.invoiceId() + " " + invoice.paymentUrl());
require 'paymos'
paymos = Paymos::Client.new(
api_key: ENV.fetch('PAYMOS_API_KEY'),
api_secret: ENV.fetch('PAYMOS_API_SECRET')
)
invoice = paymos.invoices.create(
project_id: 'prj_xxxxxxxxxxxx',
amount: '100.00',
currency: 'USD',
external_order_id: 'order-123',
client_id: 'customer-456'
)
puts "#{invoice.invoice_id} #{invoice.payment_url}"
use paymos::{CreateInvoiceRequest, PaymosClient};
let paymos = PaymosClient::new(
std::env::var("PAYMOS_API_KEY")?,
std::env::var("PAYMOS_API_SECRET")?,
)?;
let invoice = paymos
.invoices()
.create(&CreateInvoiceRequest {
project_id: "prj_xxxxxxxxxxxx".to_owned(),
amount: "100.00".to_owned(),
currency: "USD".to_owned(),
external_order_id: "order-123".to_owned(),
network: None,
allow_multiple_payments: None,
customer_fee_percent: None,
client_id: Some("customer-456".to_owned()),
})
.await?;
println!("{} {}", invoice.invoice_id, invoice.payment_url);
Response (201 Created / 200 if idempotent match)
{
"invoice_id": "inv_5CcyDYmMUGtzYL10q0Iimr",
"project_id": "prj_xFukZuAJZR06pLVBh3uwzv",
"status": "awaiting_client",
"is_final": false,
"is_test": true,
"payment_url": "https://checkout.paymos.io/invoice/inv_5CcyDYmMUGtzYL10q0Iimr",
"order": {
"external_id": "order-12345",
"client_id": "customer-67890",
"amount": "50.00",
"currency": "USD"
},
"created_at": 1743594000,
"updated_at": 1743594000,
"expires_at": 1743597600
}
Response fields
| Field | Type | Description |
|---|---|---|
invoice_id |
string | Prefixed invoice identifier (inv_...) |
project_id |
string | Prefixed project identifier (prj_...) |
status |
string | Invoice status (snake_case — see Invoice Statuses) |
is_final |
boolean | Whether the invoice is in a terminal state |
is_test |
boolean | true for sandbox invoices |
payment_url |
string | Hosted checkout page URL |
order |
object | Merchant-facing order data |
order.external_id |
string | Merchant order ID |
order.client_id |
string? | Merchant customer ID |
order.amount |
string | Requested amount |
order.currency |
string | Fiat currency code or crypto asset symbol requested by the merchant |
order.network |
string? | Requested network for direct crypto invoices |
payment |
object? | Payment details — present once the token and network are selected. Holds the deposit address, the expected crypto amount, the locked rate, and per-transfer progress. See Get Invoice → Payment fields for the full sub-field reference |
created_at |
integer | Unix timestamp, seconds |
updated_at |
integer | Unix timestamp, seconds |
expires_at |
integer | Unix timestamp, seconds. Set at creation, so it is present on every invoice in every status |
completed_at |
integer? | Completion timestamp |
Invoice statuses
Every invoice reports a status string. The same value drives the merchant API response, the SSE checkout stream, and the webhook payload — there is one source of truth. The table below is the per-status reference: when the value appears, whether it is terminal, and the precise condition behind it. For the transition diagram and the two creation flows, see Payment flow.
A terminal status is final: the invoice settles there and never moves again. Five statuses are terminal — paid, paid_over, underpaid, expired, cancelled.
| Status | Terminal | When it applies |
|---|---|---|
awaiting_client |
No | The opening status for every invoice. No token or network is chosen yet, so no deposit address exists. Cancellation is allowed only here |
awaiting_payment |
No | A token, network, and deposit address are locked in. Paymos is watching the address for an incoming transfer |
confirming |
No | A transfer landed on-chain and is accruing the confirmations required for its amount tier |
underpaid_waiting |
No | Less than the expected amount has cleared and the invoice stays open for the rest. Reached only when allow_multiple_payments is true |
paid |
Yes | The expected amount cleared in full (or within the project's underpayment tolerance) |
paid_over |
Yes | More than the expected amount cleared; the whole transfer is credited |
underpaid |
Yes | The invoice closed short — either it expired below the expected amount, or a single payment fell short with allow_multiple_payments set to false |
expired |
Yes | The timer ran out with nothing received, or the fiat-flow token-selection window lapsed before a token was picked |
cancelled |
Yes | The merchant cancelled the invoice while it was still in awaiting_client |
Idempotency
Always send external_order_id on invoice creation. A retry of the same call with the same external_order_id returns the existing invoice — never a duplicate. The response status is 200 OK for an idempotent match, 201 Created for a fresh invoice.
This is your safety net against flaky networks, crashed workers, and retry loops. Use the same id for the same business event; pick a new id for a new event.
Use a UUID v4 or your own order ID — anything stable and unique per business event. If you reuse an ID by accident, you get that ID's existing invoice back (the 200 OK idempotent match above), not a new one.
Errors
See Error Codes for the full catalogue. The type URI in every error response deep-links to the matching row.