Authentication
Every request is authenticated in one of two ways:
| Method | Header(s) | When to use |
|---|---|---|
| API key (recommended) | x-depay-api-key: dep_… | Your backend calls the API on behalf of your wallet. |
| Request signature | x-depay-signature + x-depay-timestamp | Your 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
| Header | Value |
|---|---|
x-depay-timestamp | Unix time in milliseconds. Seconds are accepted and multiplied by 1000, but you must sign the millisecond value. |
x-depay-signature | 0x… 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
payloadand nestedmetadata. Top-level order is thereforeaction,ownerAddress,payload,timestamp. - No whitespace between tokens.
- Keys with
undefinedvalues are dropped. Omit optional fields you do not send; do not set them tonull. ownerAddressis EIP-55 checksummed, whatever casing you sent.- Values use their API types:
chainId,page,limit,feeBpsand timestamps are numbers;amount,nonceanddeadlineare strings. - List endpoints add defaults. The server fills in
page: 1andlimit: 20when you omit them, and signs the result. Always includepageandlimitin the signed payload ofinvoice.listandinvoice.withdraw.transactions. :::
What to sign per endpoint
| Endpoint | action | Signed payload |
|---|---|---|
POST /integration/invoices | invoice.create | Body without ownerAddress |
GET /integration/invoices/status | invoice.status | { paymentAddress } |
DELETE /integration/invoices | invoice.delete | { paymentAddress } |
GET /integration/invoices | invoice.list | Query without ownerAddress, plus page/limit defaults |
GET /integration/invoices/withdraw/prepare | invoice.withdraw.prepare | {} |
POST /integration/invoices/withdraw | invoice.withdraw | Body: { chainId } |
GET /integration/invoices/withdraw/transactions | invoice.withdraw.transactions | Query without ownerAddress, plus page/limit defaults |
POST /integration/fees/set-meta | fee.set-meta | { chainId, feeBps, nonce, deadline, signature } |
POST /integration/fees/set-payout-meta | fee.set-payout-meta | { chainId, payoutAddress, nonce, deadline, signature } |
Verification rules
- Timestamp must be within ±5 minutes of server time
(
401 Request timestamp is expired). - A timestamp can be used once per owner (
401 Timestamp already used). Generate a fresh timestamp and signature for every request, including retries. - The signature must belong to
ownerAddress(an EOA signature, or a valid ERC-1271 signature for smart-contract wallets).
Signing helper
- TypeScript
- Python
- cURL
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).
import json
import os
import time
from typing import Any
from eth_account import Account
from eth_account.messages import encode_defunct
account = Account.from_key(os.environ["OWNER_PRIVATE_KEY"])
def sign(action: str, payload: dict[str, Any]) -> tuple[str, int]:
"""Return (signature, timestamp_ms) for a signed SodaPop request."""
timestamp = int(time.time() * 1000)
canonical = json.dumps(
{
"action": action,
"ownerAddress": account.address, # already checksummed
"timestamp": timestamp,
# Drop optional fields you are not sending instead of passing None.
"payload": {k: v for k, v in payload.items() if v is not None},
},
separators=(",", ":"),
sort_keys=True, # sorts nested objects too
ensure_ascii=False, # match JSON.stringify for non-ASCII text
)
message = encode_defunct(text=f"DePay API Request Signature\n{canonical}")
signed = account.sign_message(message)
return "0x" + signed.signature.hex().removeprefix("0x"), timestamp
Install with pip install eth-account.
Send integers as integers. Python serializes 12.0 as 12.0, while the server
sees 12, so a float in the payload breaks the signature.
Signing from a shell needs a signing tool. cast from
Foundry works:
TIMESTAMP=$(($(date +%s) * 1000))
OWNER=$(cast wallet address --private-key "$OWNER_PRIVATE_KEY")
# Keys sorted alphabetically, no whitespace.
PAYLOAD='{"amount":"12.5","chainId":8453,"invoiceId":"1000456"}'
CANONICAL="{\"action\":\"invoice.create\",\"ownerAddress\":\"$OWNER\",\"payload\":$PAYLOAD,\"timestamp\":$TIMESTAMP}"
SIGNATURE=$(cast wallet sign --private-key "$OWNER_PRIVATE_KEY" \
"DePay API Request Signature
$CANONICAL")
curl -X POST "$BASE_URL/integration/invoices" \
-H 'Content-Type: application/json' \
-H "x-depay-timestamp: $TIMESTAMP" \
-H "x-depay-signature: $SIGNATURE" \
-d "{\"ownerAddress\":\"$OWNER\",\"amount\":\"12.5\",\"chainId\":8453,\"invoiceId\":\"1000456\"}"
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.