Skip to main content

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.

sodapop-client.ts
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}`,
);
}
}

POST /integration/invoices

Create invoice for owner wallet.

Request body
{
"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.

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

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

  • ownerAddress
  • paymentAddress

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

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

:::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, invoiceId
  • page, limit, dateFrom, dateTo

Returns { items, total, page, limit }.

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;
}
}

limit defaults to 20 and is capped at 100.

DELETE /integration/invoices

Query params:

  • ownerAddress
  • chainId
  • invoiceId

Soft-delete invoice and remove it from active monitor/queue. Returns { "deleted": true, "invoiceId": "1000456", "chainId": 8453 }.

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);
}
}

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 check
  • withdrawntrue once the on-chain batch withdrawal is confirmed
  • url — hosted payment page, safe to redirect end users to