Skip to main content

Authentication

Every request is authenticated in one of two ways:

MethodHeader(s)When to use
API key (recommended)x-depay-api-key: dep_…Your backend calls the API on behalf of your wallet.
Request signaturex-depay-signature + x-depay-timestampYour backend holds the owner wallet's key and signs each request.

If x-depay-api-key is present, the signature headers are ignored.

:::info ownerAddress is always required Pass your wallet as ownerAddress in the body (POST) or the query string (GET, DELETE) with both auth methods. Requests without it fail with 400 ownerAddress is required. :::

API key​

Generate a key in the dashboard at app.sodapopfi.com while signed in with your wallet. The key is shown only once. Generating a new key replaces the old one.

curl "https://api.sodapopfi.com/integration/invoices?ownerAddress=$OWNER_ADDRESS" \
-H "x-depay-api-key: $SODAPOP_API_KEY"

The key must belong to ownerAddress, otherwise the request fails with 401 API key does not match ownerAddress. Keys do not expire and need no timestamp, so keep them server-side only.

Request signature​

Instead of a key, the owner wallet can sign each request with an EIP-191 (personal_sign) message. Smart-contract wallets (ERC-1271) are supported.

Headers​

HeaderValue
x-depay-timestampUnix time in milliseconds. Seconds are accepted and multiplied by 1000, but you must sign the millisecond value.
x-depay-signature0x… signature of the message below

Message​

DePay API Request Signature
<CANONICAL_JSON>

CANONICAL_JSON is JSON.stringify of this object:

{
"action": "invoice.create",
"ownerAddress": "0x501BEF961A6f40E063efD6048768b0BC35ab1428",
"payload": { "amount": "12.5", "chainId": 8453, "invoiceId": "1000456" },
"timestamp": 1714200000000
}

:::warning Get the canonical JSON exactly right The server rebuilds the string and compares signatures, so one differing byte fails with 401 Invalid signature.

  • Keys are sorted alphabetically at every level, including inside payload and nested metadata. Top-level order is therefore action, ownerAddress, payload, timestamp.
  • No whitespace between tokens.
  • Keys with undefined values are dropped. Omit optional fields you do not send; do not set them to null.
  • ownerAddress is EIP-55 checksummed, whatever casing you sent.
  • Values use their API types: chainId, page, limit, feeBps and timestamps are numbers; amount, nonce and deadline are strings.
  • List endpoints add defaults. The server fills in page: 1 and limit: 20 when you omit them, and signs the result. Always include page and limit in the signed payload of invoice.list and invoice.withdraw.transactions. :::

What to sign per endpoint​

EndpointactionSigned payload
POST /integration/invoicesinvoice.createBody without ownerAddress
GET /integration/invoices/statusinvoice.status{ paymentAddress }
DELETE /integration/invoicesinvoice.delete{ paymentAddress }
GET /integration/invoicesinvoice.listQuery without ownerAddress, plus page/limit defaults
GET /integration/invoices/withdraw/prepareinvoice.withdraw.prepare{}
POST /integration/invoices/withdrawinvoice.withdrawBody: { chainId }
GET /integration/invoices/withdraw/transactionsinvoice.withdraw.transactionsQuery without ownerAddress, plus page/limit defaults
POST /integration/fees/set-metafee.set-meta{ chainId, feeBps, nonce, deadline, signature }
POST /integration/fees/set-payout-metafee.set-payout-meta{ chainId, payoutAddress, nonce, deadline, signature }

Verification rules​

  1. Timestamp must be within ±5 minutes of server time (401 Request timestamp is expired).
  2. A timestamp can be used once per owner (401 Timestamp already used). Generate a fresh timestamp and signature for every request, including retries.
  3. The signature must belong to ownerAddress (an EOA signature, or a valid ERC-1271 signature for smart-contract wallets).

Signing helper​

sign.ts
import { getAddress } from "viem";
import { privateKeyToAccount } from "viem/accounts";

const account = privateKeyToAccount(
process.env.OWNER_PRIVATE_KEY as `0x${string}`,
);

/** Recursively sorts object keys and drops undefined values. */
function canonical(value: unknown): unknown {
if (Array.isArray(value)) return value.map(canonical);
if (value && typeof value === "object") {
const record = value as Record<string, unknown>;
const sorted: Record<string, unknown> = {};
for (const key of Object.keys(record).sort()) {
if (record[key] !== undefined) sorted[key] = canonical(record[key]);
}
return sorted;
}
return value;
}

export async function sign(
action: string,
payload: Record<string, unknown>,
): Promise<{ signature: string; timestamp: number }> {
const timestamp = Date.now();

const message = [
"DePay API Request Signature",
JSON.stringify(
canonical({
action,
ownerAddress: getAddress(account.address),
timestamp,
payload,
}),
),
].join("\n");

const signature = await account.signMessage({ message });
return { signature, timestamp };
}

Usage:

const payload = { chainId: 8453, amount: "12.5", tokenAddress: "USDC" };
const { signature, timestamp } = await sign("invoice.create", payload);

await fetch("https://api.sodapopfi.com/integration/invoices", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-depay-signature": signature,
"x-depay-timestamp": String(timestamp),
},
body: JSON.stringify({ ownerAddress: account.address, ...payload }),
});

With ethers v6, replace account.signMessage({ message }) with wallet.signMessage(message).

Signing a list request​

page and limit must be in the signed payload even if you do not send them:

const query = { status: "pending", chainId: 8453, page: 1, limit: 20 };
const { signature, timestamp } = await sign("invoice.list", query);

const url = new URL("https://api.sodapopfi.com/integration/invoices");
url.searchParams.set("ownerAddress", account.address);
for (const [key, value] of Object.entries(query)) {
url.searchParams.set(key, String(value));
}

Security checklist​

  • Keep API keys and owner private keys on the server only.
  • Never reuse a timestamp; retry with a new timestamp and signature.
  • Keep your server clock in sync (NTP). Clock drift over five minutes rejects every signed request.
  • Webhooks are authenticated differently from requests. See Webhooks.