# Create Invoice

> 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).

`POST /v1/invoices`

:::endpoint
POST /v1/invoices
:::

**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](/docs/supported-currencies.md) 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](/docs/supported-currencies.md)). 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](/docs/payment-flow.md#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.

```json
{
  "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.

```json
{
  "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.

```curl
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"
```

```javascript
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);
```

```python
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
<?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'];
```

```go
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 }
```

```csharp
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}");
```

```java
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());
```

```ruby
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}"
```

```rust
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)

```json
{
  "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](/docs/invoices/get.md#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](/docs/invoices/create.md#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](/docs/invoices/get.md) 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](/docs/payment-flow.md).

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.

:::tip
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](/docs/errors/codes.md) for the full catalogue. The `type` URI in every error response deep-links to the matching row.
