Invoices
Base route: /integration/invoices
Auth: API key or request signature. See
Authentication. ownerAddress is required on every
call.
Client setup
The examples on this page share this client. It authenticates with an API key; for signature mode see Request signature.
- TypeScript
- Python
- cURL
import axios, { AxiosInstance } from "axios";
export type InvoiceStatus =
| "pending"
| "paid"
| "completed"
| "cancelled"
| "expired";
export interface Invoice {
id: string;
ownerAddress: string;
chainId: number;
invoiceId: string;
paymentAddress: string;
factoryVersion: number;
amount: string;
tokenAddress?: string; // absent for native token
description?: string;
metadata?: Record<string, unknown>;
expiresAt: number;
status: InvoiceStatus;
paid: boolean;
paidAt?: number;
withdrawn?: boolean;
withdrawnAt?: number;
requiredRaw?: string;
balanceRaw?: string;
balance?: number;
usdPrice?: number;
usdValue?: number;
lastCheckedAt?: number;
redirectUrl?: string;
createdAt: number;
updatedAt: number;
url: string;
}
export class SodaPopClient {
protected readonly http: AxiosInstance;
constructor(
protected readonly ownerAddress: string,
apiKey: string = process.env.SODAPOP_API_KEY ?? "",
baseUrl: string = process.env.SODAPOP_BASE_URL ?? "https://api.sodapopfi.com",
) {
this.http = axios.create({
baseURL: baseUrl.replace(/\/$/, ""),
headers: {
"Content-Type": "application/json",
"x-depay-api-key": apiKey,
},
});
}
/** Normalizes axios failures into a single readable error. */
protected toApiError(method: string, error: unknown): Error {
if (!axios.isAxiosError(error)) {
return error instanceof Error ? error : new Error(String(error));
}
const status = error.response?.status ?? "unknown";
const details = JSON.stringify(error.response?.data ?? error.message);
return new Error(`SodaPop API ${method} error ${status}: ${details}`);
}
}
import os
from typing import Any, Optional
import requests
class SodaPopError(Exception):
pass
class SodaPopClient:
def __init__(
self,
owner_address: str,
api_key: Optional[str] = None,
base_url: Optional[str] = None,
) -> None:
self.owner_address = owner_address
self.base_url = (
base_url or os.environ.get("SODAPOP_BASE_URL", "https://api.sodapopfi.com")
).rstrip("/")
self.session = requests.Session()
self.session.headers.update(
{
"Content-Type": "application/json",
"x-depay-api-key": api_key or os.environ.get("SODAPOP_API_KEY", ""),
}
)
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
response = self.session.request(
method, f"{self.base_url}{path}", timeout=30, **kwargs
)
if not response.ok:
raise SodaPopError(
f"SodaPop API {method} {path} error {response.status_code}: {response.text}"
)
return response.json()
export BASE_URL='https://api.sodapopfi.com'
export OWNER_ADDRESS='0x501BEF961A6f40E063efD6048768b0BC35ab1428'
export SODAPOP_API_KEY='dep_...'
Create an invoice
POST /integration/invoices
| Field | Type | Required | Description |
|---|---|---|---|
ownerAddress | address | Yes | Merchant wallet that receives the funds. |
chainId | number | Yes | A supported chain. |
amount | string | Yes | Amount in token units, e.g. "12.5". |
tokenAddress | string | No | Token address or symbol (USDC, USDT, EURC). Omit for the native token. |
invoiceId | string | No | Your reference, unique per owner. Generated from the owner's sequence if omitted. |
description | string | No | Shown on the payment page. |
duration | number | No | Lifetime in seconds. Takes precedence over expiresAt. |
expiresAt | number | No | Absolute deadline, Unix ms. Ignored if in the past. |
metadata | object | No | Any JSON. Returned as is. |
webhookUrl | URL | No | Receives a POST when the invoice is paid. See Webhooks. |
Without duration or expiresAt the invoice expires after 36 hours.
{
"ownerAddress": "0x501BEF961A6f40E063efD6048768b0BC35ab1428",
"chainId": 8453,
"invoiceId": "1000456",
"amount": "12.5",
"tokenAddress": "USDC",
"description": "Payment for order #1000456",
"duration": 3600,
"metadata": { "orderId": "1000456" },
"webhookUrl": "https://merchant.example.com/webhooks/invoice"
}
Returns the invoice object. Payment tracking starts right
away. usdPrice and usdValue may be absent in this response; the status
endpoint returns them when a price is available.
- TypeScript
- Python
- cURL
async createInvoice(params: {
chainId: number;
amount: string;
invoiceId?: string;
tokenAddress?: string;
description?: string;
duration?: number;
expiresAt?: number;
metadata?: Record<string, unknown>;
webhookUrl?: string;
}): Promise<Invoice> {
try {
const { data } = await this.http.post<Invoice>("/integration/invoices", {
ownerAddress: this.ownerAddress,
...params,
});
return data;
} catch (error) {
throw this.toApiError("createInvoice", error);
}
}
Usage:
const client = new SodaPopClient(process.env.OWNER_ADDRESS!);
const invoice = await client.createInvoice({
chainId: 8453,
invoiceId: "1000456",
amount: "12.5",
tokenAddress: "USDC",
webhookUrl: "https://merchant.example.com/webhooks/invoice",
});
// Redirect the payer and store paymentAddress with your order.
console.log(invoice.url, invoice.paymentAddress);
def create_invoice(self, chain_id: int, amount: str, **optional: Any) -> dict[str, Any]:
"""optional: invoiceId, tokenAddress, description, duration,
expiresAt, metadata, webhookUrl"""
payload = {"ownerAddress": self.owner_address, "chainId": chain_id, "amount": amount}
payload.update({k: v for k, v in optional.items() if v is not None})
return self._request("POST", "/integration/invoices", json=payload)
Usage:
client = SodaPopClient(os.environ["OWNER_ADDRESS"])
invoice = client.create_invoice(
8453,
"12.5",
invoiceId="1000456",
tokenAddress="USDC",
webhookUrl="https://merchant.example.com/webhooks/invoice",
)
print(invoice["url"], invoice["paymentAddress"])
curl -X POST "$BASE_URL/integration/invoices" \
-H 'Content-Type: application/json' \
-H "x-depay-api-key: $SODAPOP_API_KEY" \
-d '{
"ownerAddress": "'"$OWNER_ADDRESS"'",
"chainId": 8453,
"invoiceId": "1000456",
"amount": "12.5",
"tokenAddress": "USDC",
"webhookUrl": "https://merchant.example.com/webhooks/invoice"
}'
Get invoice status
GET /integration/invoices/status
| Query param | Required | Description |
|---|---|---|
ownerAddress | Yes | Merchant wallet. |
paymentAddress | Yes | paymentAddress from the create response. |
Re-reads the balance on-chain before answering, so this is the freshest and the
slowest invoice call. Returns { "invoice": { … } }.
Errors: 404 Invoice not found (also for cancelled invoices),
403 Invoice does not belong to ownerAddress.
:::info Look up by paymentAddress, not invoiceId
Status and delete calls are keyed by the payment address. Store paymentAddress
from the create response with your order.
:::
- TypeScript
- Python
- cURL
async getInvoiceStatus(paymentAddress: string): Promise<Invoice> {
try {
const { data } = await this.http.get<{ invoice: Invoice }>(
"/integration/invoices/status",
{ params: { ownerAddress: this.ownerAddress, paymentAddress } },
);
return data.invoice;
} catch (error) {
throw this.toApiError("getInvoiceStatus", error);
}
}
Same call in signature mode, with the sign() helper from
Authentication:
const { signature, timestamp } = await sign("invoice.status", { paymentAddress });
const { data } = await axios.get("https://api.sodapopfi.com/integration/invoices/status", {
params: { ownerAddress, paymentAddress },
headers: {
"x-depay-signature": signature,
"x-depay-timestamp": String(timestamp),
},
});
Waiting for a payment:
async function waitForPayment(
client: SodaPopClient,
paymentAddress: string,
{ intervalMs = 15_000, timeoutMs = 3_600_000 } = {},
): Promise<Invoice> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const invoice = await client.getInvoiceStatus(paymentAddress);
if (invoice.status !== "pending") return invoice;
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
throw new Error(`Invoice ${paymentAddress} is still pending`);
}
def get_invoice_status(self, payment_address: str) -> dict[str, Any]:
data = self._request(
"GET",
"/integration/invoices/status",
params={"ownerAddress": self.owner_address, "paymentAddress": payment_address},
)
return data["invoice"]
Waiting for a payment:
import time
def wait_for_payment(client: SodaPopClient, payment_address: str,
interval_s: int = 15, timeout_s: int = 3600) -> dict[str, Any]:
deadline = time.time() + timeout_s
while time.time() < deadline:
invoice = client.get_invoice_status(payment_address)
if invoice["status"] != "pending":
return invoice
time.sleep(interval_s)
raise TimeoutError(f"Invoice {payment_address} is still pending")
curl "$BASE_URL/integration/invoices/status?ownerAddress=$OWNER_ADDRESS&paymentAddress=$PAYMENT_ADDRESS" \
-H "x-depay-api-key: $SODAPOP_API_KEY"
List invoices
GET /integration/invoices
| Query param | Type | Description |
|---|---|---|
ownerAddress | address | Required. |
page | number | Default 1. |
limit | number | Default 20, max 100. |
chainId | number | Filter by chain. |
status | string | pending, paid, completed, cancelled or expired. Cancelled invoices appear only with status=cancelled. |
paid | boolean | Filter by the paid flag. |
tokenAddress | string | Address or symbol. Symbols need chainId. |
invoiceId | string | Exact match. |
fromCreatedAt | number | createdAt >=, Unix ms. |
toCreatedAt | number | createdAt <=, Unix ms. |
Sorted by createdAt, newest first. Returns { items, total, page, limit }.
In signature mode, sign all query params except ownerAddress, including
page and limit even if you rely on the defaults.
- TypeScript
- Python
- cURL
async listInvoices(filters: {
page?: number;
limit?: number;
chainId?: number;
status?: InvoiceStatus;
tokenAddress?: string;
invoiceId?: string;
fromCreatedAt?: number;
toCreatedAt?: number;
} = {}): Promise<{ items: Invoice[]; total: number; page: number; limit: number }> {
try {
const { data } = await this.http.get("/integration/invoices", {
params: { ownerAddress: this.ownerAddress, ...filters },
});
return data;
} catch (error) {
throw this.toApiError("listInvoices", error);
}
}
Iterating over every pending invoice:
async function* iteratePending(client: SodaPopClient, chainId: number) {
const limit = 100;
for (let page = 1; ; page++) {
const { items, total } = await client.listInvoices({
chainId,
status: "pending",
page,
limit,
});
yield* items;
if (page * limit >= total) return;
}
}
def list_invoices(self, **filters: Any) -> dict[str, Any]:
params = {"ownerAddress": self.owner_address}
params.update({k: v for k, v in filters.items() if v is not None})
return self._request("GET", "/integration/invoices", params=params)
curl "$BASE_URL/integration/invoices?ownerAddress=$OWNER_ADDRESS&status=pending&page=1&limit=20" \
-H "x-depay-api-key: $SODAPOP_API_KEY"
Cancel an invoice
DELETE /integration/invoices
| Query param | Required |
|---|---|
ownerAddress | Yes |
paymentAddress | Yes |
Sets status to cancelled and stops tracking the invoice. Funds that already
arrived are not moved or refunded.
{
"deleted": true,
"invoice": { "invoiceId": "1000456", "chainId": 8453, "status": "cancelled", "...": "..." }
}
- TypeScript
- Python
- cURL
async cancelInvoice(paymentAddress: string): Promise<{ deleted: boolean; invoice: Invoice }> {
try {
const { data } = await this.http.delete("/integration/invoices", {
params: { ownerAddress: this.ownerAddress, paymentAddress },
});
return data;
} catch (error) {
throw this.toApiError("cancelInvoice", error);
}
}
def cancel_invoice(self, payment_address: str) -> dict[str, Any]:
return self._request(
"DELETE",
"/integration/invoices",
params={"ownerAddress": self.owner_address, "paymentAddress": payment_address},
)
curl -X DELETE "$BASE_URL/integration/invoices?ownerAddress=$OWNER_ADDRESS&paymentAddress=$PAYMENT_ADDRESS" \
-H "x-depay-api-key: $SODAPOP_API_KEY"
Invoice object
{
"id": "662cc88b3450ca6b40f8fd90",
"ownerAddress": "0x501BEF961A6f40E063efD6048768b0BC35ab1428",
"chainId": 8453,
"invoiceId": "1000456",
"paymentAddress": "0x6c76fD5f0b5F7924A0b6b23223A8D2398fB00E72",
"factoryVersion": 2,
"amount": "12.5",
"tokenAddress": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"description": "Payment for order #1000456",
"metadata": { "orderId": "1000456" },
"expiresAt": 1735593600000,
"status": "paid",
"paid": true,
"paidAt": 1735591000000,
"withdrawn": false,
"requiredRaw": "12500000",
"balanceRaw": "12500000",
"balance": 12.5,
"usdPrice": 0.9998,
"usdValue": 12.4975,
"lastCheckedAt": 1735591001000,
"createdAt": 1735590000000,
"updatedAt": 1735591001000,
"url": "https://app.sodapopfi.com/pay/0x6c76fD5f0b5F7924A0b6b23223A8D2398fB00E72"
}
| Field | Description |
|---|---|
paymentAddress | Address the payer sends funds to. Deterministic per (ownerAddress, invoiceId). |
factoryVersion | Version of the PaymentFactory that controls paymentAddress. Currently 2. |
status | See Invoice lifecycle. |
tokenAddress | Resolved token address. Absent for the native token. |
requiredRaw / balanceRaw | Required and current amount in the token's smallest unit. |
balance | Current balance in token units. |
usdPrice / usdValue | USD price of the token and the value of balance (or amount before payment). Absent if no price is available. |
withdrawn / withdrawnAt | Set once the withdrawal to the payout address is confirmed. |
redirectUrl | Where the payment page sends the payer after paying, if configured. |
url | Hosted payment page. Safe to redirect payers to. |