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. A crypto symbol needs at least one of its networks left on in the project's Currencies tab; if all of them are switched off, the request fails with 400 currency_not_enabled_for_project |
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. A network the project has switched off is refused with 400 field_invalid_enum on network |
external_order_id |
string | Yes | Your unique order identifier (max 128 chars), unique per project across Sandbox and Live. Makes invoice creation idempotent: the same request again returns the existing invoice with 200 OK — not a duplicate. The same id with another amount, currency, network or environment returns 409 (invoice_idempotency_conflict). Must not start with wgt_, which is reserved for Low-Code SDK invoices |
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. When the token and network are confirmed it moves forward to a full payment window from that moment, and it never moves earlier. Read it again after confirmation rather than keeping the value from creation |
completed_at |
integer? | Completion timestamp |
paid_late_at |
integer? | Late-payment mark (Unix timestamp, seconds): after the invoice had closed as expired or underpaid, money in its token reached the expected amount, within the underpayment tolerance saved on the invoice. status and is_final keep their terminal meaning — this mark travels beside them, and payment.paid still counts only the timely transfers. The field is absent until the mark is set; read a null the same way, as no mark |
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.
The retry has to be the same request. If the amount, currency, or network differs from the invoice already filed under that id, or the call is signed with a key from the other environment, the API answers 409 with invoice_idempotency_conflict and creates nothing. The id is unique per project across Sandbox and Live, so a live order can't reuse an id from your tests.
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. Reuse an ID by accident and you still never get a second invoice: an identical request returns the one already filed (200 OK), a different one gets 409. IDs starting with wgt_ are reserved for invoices the Low-Code SDK creates and are rejected with field_invalid_format.
Errors
See Error Codes for the full catalogue. The type URI in every error response deep-links to the matching row.