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 crypto asset 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). Stored on the invoice and returned on reads and list items, so you can group invoices by your own customer. Paymos enforces no limit off this field |
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, VES |
| 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 |
|---|---|---|---|
token (USDT, USDC, …) |
a network (TRC20, ERC20, …) |
Direct crypto — amount is the token amount, on a fixed chain |
Nothing — token and network are locked |
token (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.
API_KEY_ID="pk_live_xxxxxxxxxxxx"
API_SECRET="sk_live_xxxxxxxxxxxx"
BODY='{"project_id":"prj_xxxxxxxxxxxx","amount":"100.00","currency":"USD","external_order_id":"order-123","client_id":"customer-456"}'
TS=$(date +%s)
BODY_HASH=$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | sed 's/^.* //')
SIGNATURE=$(printf '%s\n%s\n%s\n%s\n%s' "$TS" POST /v1/invoices '' "$BODY_HASH" \
| openssl dgst -sha256 -hmac "$API_SECRET" -binary | base64)
curl -sS https://api.paymos.io/v1/invoices \
-H "Authorization: HMAC-SHA256 $API_KEY_ID:$SIGNATURE" \
-H "X-Request-Timestamp: $TS" \
-H "Content-Type: application/json" \
-d "$BODY"
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 | Payment URL — a deep link into the Paymos bot on a Telegram-bot project, the hosted checkout page on any other |
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. A fiat invoice carries the currency's ISO 4217 scale — "100.00" for USD, "23" for JPY, "1.500" for KWD (see Fiat amount precision). A token has no ISO scale, so a crypto-denominated invoice drops trailing zeros: "10.00" USDT comes back as "10". Padding only, never rounding — invoice creation already rejects a fiat amount that does not fit its minor units. The amount on a list item follows the same rule |
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.