Payment Service API Invoices
Base route: /integration/invoices
Requests require authentication via x-depay-api-key or x-depay-signature. See Auth Model for details.
Client Setup
All examples on this page share the client below. It authenticates with an API key; for signature mode see Request Signing.
- 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;
amount: string;
tokenAddress: string | null;
description?: string;
metadata?: Record<string, unknown>;
expiresAt: number;
status: InvoiceStatus;
paid: boolean;
paidAt: number | null;
withdrawn: boolean;
withdrawnAt: number | null;
requiredRaw: string;
balanceRaw: string;
metaBalance: string;
lastCheckedAt: number;
createdAt: number;
updatedAt: number;
url: string;
}
export class SodaPopClient {
protected readonly http: AxiosInstance;
protected readonly baseUrl: string;
constructor(
protected readonly ownerAddress: string,
apiKey: string = process.env.SODAPOP_API_KEY ?? "",
baseUrl: string = process.env.SODAPOP_BASE_URL ??
"https://api.sodapopfi.com",
) {
this.baseUrl = baseUrl.replace(/\/$/, "");
this.http = axios.create({
baseURL: this.baseUrl,
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;
const details =
typeof error.response?.data === "string"
? error.response.data
: JSON.stringify(error.response?.data ?? error.message);
return new Error(
`SodaPop API ${method} error ${String(status ?? "unknown")}: ${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='<YOUR_API_KEY>'
POST /integration/invoices
Create invoice for owner wallet.
{
"ownerAddress": "0x501BEF961A6f40E063efD6048768b0BC35ab1428",
"chainId": 8453,
"invoiceId": "1000456",
"amount": "12.5",
"tokenAddress": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"webhookUrl": "https://merchant.example.com/webhooks/invoice",
"description": "Payment for order #1000456",
"expiresAt": 1735689600000,
"metadata": {
"orderId": "1000456"
}
}
Only ownerAddress, chainId and amount are required. Omit tokenAddress to
invoice in the chain's native token, and omit invoiceId to let the service
generate one from the owner's incrementing sequence.
- TypeScript
- Python
- cURL
async createInvoice(params: {
chainId: number;
invoiceId?: string;
amount: string;
tokenAddress?: string;
webhookUrl?: string;
description?: string;
expiresAt?: number;
metadata?: Record<string, unknown>;
}): Promise<Invoice> {
const payload: Record<string, unknown> = {
chainId: params.chainId,
amount: params.amount,
};
if (params.invoiceId) payload.invoiceId = params.invoiceId;
if (params.tokenAddress) payload.tokenAddress = params.tokenAddress;
if (params.webhookUrl) payload.webhookUrl = params.webhookUrl;
if (params.description) payload.description = params.description;
if (params.expiresAt) payload.expiresAt = params.expiresAt;
if (params.metadata) payload.metadata = params.metadata;
try {
const response = await this.http.post<Invoice>("/integration/invoices", {
ownerAddress: this.ownerAddress,
...payload,
});
return response.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: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
webhookUrl: "https://merchant.example.com/webhooks/invoice",
metadata: { orderId: "1000456" },
});
// Redirect the customer to the hosted payment page.
console.log(invoice.url, invoice.paymentAddress);
def create_invoice(
self,
chain_id: int,
amount: str,
invoice_id: Optional[str] = None,
token_address: Optional[str] = None,
webhook_url: Optional[str] = None,
description: Optional[str] = None,
expires_at: Optional[int] = None,
metadata: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"ownerAddress": self.owner_address,
"chainId": chain_id,
"amount": amount,
}
optional = {
"invoiceId": invoice_id,
"tokenAddress": token_address,
"webhookUrl": webhook_url,
"description": description,
"expiresAt": expires_at,
"metadata": metadata,
}
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(
chain_id=8453,
invoice_id="1000456",
amount="12.5",
token_address="0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
webhook_url="https://merchant.example.com/webhooks/invoice",
metadata={"orderId": "1000456"},
)
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": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"webhookUrl": "https://merchant.example.com/webhooks/invoice"
}'
Returns the invoice object. After creation the invoice is added to the background payment monitor, which polls every 20 seconds.
GET /integration/invoices/status
Query params (both required):
ownerAddresspaymentAddress
Triggers live on-chain re-check and returns latest state. Response shape:
{ "invoice": { ... } }.
:::info Look up by paymentAddress, not invoiceId
Status lookups are keyed by the escrow address the service generated for the
invoice. Persist paymentAddress from the
create response alongside your own order record —
you need it for every status check.
:::
- TypeScript
- Python
- cURL
async getInvoiceStatus(paymentAddress: string): Promise<Invoice> {
try {
const response = await this.http.get<{ invoice: Invoice }>(
"/integration/invoices/status",
{
params: {
ownerAddress: this.ownerAddress,
paymentAddress,
},
},
);
return response.data.invoice;
} catch (error) {
throw this.toApiError("getInvoiceStatus", error);
}
}
Same call in signature mode, using the sign() helper from
Request Signing:
async getInvoiceStatusSigned(paymentAddress: string): Promise<Invoice> {
const { signature, timestamp } = await sign("invoice.status", {
paymentAddress,
});
const url = new URL(`${this.baseUrl}/integration/invoices/status`);
url.searchParams.set("ownerAddress", this.ownerAddress);
url.searchParams.set("paymentAddress", paymentAddress);
try {
const response = await axios.get<{ invoice: Invoice }>(url.toString(), {
headers: {
"x-depay-signature": signature,
"x-depay-timestamp": String(timestamp),
},
});
return response.data.invoice;
} catch (error) {
throw this.toApiError("getInvoiceStatus", error);
}
}
Polling until the invoice settles:
async function waitForPayment(
client: SodaPopClient,
paymentAddress: string,
{ intervalMs = 20_000, timeoutMs = 3_600_000 } = {},
): Promise<Invoice> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const invoice = await client.getInvoiceStatus(paymentAddress);
if (["completed", "cancelled", "expired"].includes(invoice.status)) {
return invoice;
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
throw new Error(`Invoice ${paymentAddress} did not settle in time`);
}
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"]
Same call in signature mode, using the sign() helper from
Request Signing:
def get_invoice_status_signed(self, payment_address: str) -> dict[str, Any]:
signature, timestamp = sign("invoice.status", {
"paymentAddress": payment_address,
})
response = requests.get(
f"{self.base_url}/integration/invoices/status",
params={
"ownerAddress": self.owner_address,
"paymentAddress": payment_address,
},
headers={
"x-depay-signature": signature,
"x-depay-timestamp": str(timestamp),
},
timeout=30,
)
response.raise_for_status()
return response.json()["invoice"]
Polling until the invoice settles:
import time
FINAL_STATUSES = {"completed", "cancelled", "expired"}
def wait_for_payment(
client: SodaPopClient,
payment_address: str,
interval_s: int = 20,
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"] in FINAL_STATUSES:
return invoice
time.sleep(interval_s)
raise TimeoutError(f"Invoice {payment_address} did not settle in time")
API key:
curl "$BASE_URL/integration/invoices/status?ownerAddress=$OWNER_ADDRESS&paymentAddress=$PAYMENT_ADDRESS" \
-H "x-depay-api-key: $SODAPOP_API_KEY"
Request signature:
curl "$BASE_URL/integration/invoices/status?ownerAddress=$OWNER_ADDRESS&paymentAddress=$PAYMENT_ADDRESS" \
-H 'x-depay-timestamp: <TIMESTAMP_MS>' \
-H 'x-depay-signature: <REQUEST_SIGNATURE>'
:::tip Prefer webhooks over polling
If you pass webhookUrl at creation time, the service pushes a pending -> paid
notification instead of making you poll. See Webhooks.
Use status polling as a reconciliation fallback.
:::
GET /integration/invoices
Required:
ownerAddress
Optional filters:
chainId,status,paid,tokenAddress,invoiceIdpage,limit,dateFrom,dateTo
Returns { items, total, page, limit }.
- TypeScript
- Python
- cURL
async listInvoices(filters: {
chainId?: number;
status?: InvoiceStatus;
paid?: boolean;
tokenAddress?: string;
invoiceId?: string;
page?: number;
limit?: number;
dateFrom?: number;
dateTo?: number;
} = {}): Promise<{
items: Invoice[];
total: number;
page: number;
limit: number;
}> {
try {
const response = await this.http.get("/integration/invoices", {
params: { ownerAddress: this.ownerAddress, ...filters },
});
return response.data;
} catch (error) {
throw this.toApiError("listInvoices", error);
}
}
Paging through 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)
Paging through every pending invoice:
from typing import Iterator
def iterate_pending(client: SodaPopClient, chain_id: int) -> Iterator[dict[str, Any]]:
limit, page = 100, 1
while True:
result = client.list_invoices(
chainId=chain_id, status="pending", page=page, limit=limit
)
yield from result["items"]
if page * limit >= result["total"]:
return
page += 1
curl "$BASE_URL/integration/invoices?ownerAddress=$OWNER_ADDRESS&page=1&limit=20&status=pending" \
-H "x-depay-api-key: $SODAPOP_API_KEY"
Request signature:
curl "$BASE_URL/integration/invoices?ownerAddress=$OWNER_ADDRESS&page=1&limit=20&status=pending" \
-H 'x-depay-timestamp: <TIMESTAMP_MS>' \
-H 'x-depay-signature: <REQUEST_SIGNATURE>'
limit defaults to 20 and is capped at 100.
DELETE /integration/invoices
Query params:
ownerAddresschainIdinvoiceId
Soft-delete invoice and remove it from active monitor/queue. Returns
{ "deleted": true, "invoiceId": "1000456", "chainId": 8453 }.
- TypeScript
- Python
- cURL
async deleteInvoice(
chainId: number,
invoiceId: string,
): Promise<{ deleted: boolean; invoiceId: string; chainId: number }> {
try {
const response = await this.http.delete("/integration/invoices", {
params: {
ownerAddress: this.ownerAddress,
chainId,
invoiceId,
},
});
return response.data;
} catch (error) {
throw this.toApiError("deleteInvoice", error);
}
}
def delete_invoice(self, chain_id: int, invoice_id: str) -> dict[str, Any]:
return self._request(
"DELETE",
"/integration/invoices",
params={
"ownerAddress": self.owner_address,
"chainId": chain_id,
"invoiceId": invoice_id,
},
)
curl -X DELETE "$BASE_URL/integration/invoices?ownerAddress=$OWNER_ADDRESS&chainId=8453&invoiceId=1000456" \
-H "x-depay-api-key: $SODAPOP_API_KEY"
Request signature:
curl -X DELETE "$BASE_URL/integration/invoices?ownerAddress=$OWNER_ADDRESS&chainId=8453&invoiceId=1000456" \
-H 'x-depay-timestamp: <TIMESTAMP_MS>' \
-H 'x-depay-signature: <REQUEST_SIGNATURE>'
Deleting an invoice that has already been paid does not reverse the withdrawal — it only stops monitoring.
Invoice Object
Every invoice endpoint returns the same shape.
{
"id": "662cc88b3450ca6b40f8fd90",
"ownerAddress": "0x501BEF961A6f40E063efD6048768b0BC35ab1428",
"chainId": 8453,
"invoiceId": "1000456",
"paymentAddress": "0x6c76fD5f0b5F7924A0b6b23223A8D2398fB00E72",
"amount": "12.5",
"tokenAddress": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"description": "Payment for order #1000456",
"metadata": {},
"expiresAt": 1735689600000,
"status": "paid",
"paid": true,
"paidAt": 1735600000000,
"withdrawn": false,
"withdrawnAt": null,
"requiredRaw": "12500000",
"balanceRaw": "12500000",
"metaBalance": "12.5",
"lastCheckedAt": 1735600001000,
"createdAt": 1735590000000,
"updatedAt": 1735600001000,
"url": "https://gasleeesvm.netlify.app/pay/0x6c76fD5f0b5F7924A0b6b23223A8D2398fB00E72"
}
requiredRaw/balanceRaw— raw token units (wei for native, smallest unit for ERC-20)metaBalance— human-readable balance at last checkwithdrawn—trueonce the on-chain batch withdrawal is confirmedurl— hosted payment page, safe to redirect end users to