A backend API for accepting cryptocurrency payments on your own apps — no Stripe, no Coinbase, no third-party custody. Funds go directly to your wallet.
The server derives unique deposit addresses from your extended public key (xpub) using HD wallet derivation. It monitors the blockchain for incoming payments, tracks confirmations, and notifies your app via webhooks. Your private keys never touch the server.
┌─────────────┐
│ Your App │
│ (Merchant) │
└──────┬──────┘
│
POST /invoices
(create payment request)
│
▼
┌──────────┐ ┌──────────────────────────────────────────────────────┐
│ │ │ Self-Custody Payment Server │
│ Customer │ │ │
│ │ │ ┌─────────┐ ┌──────────┐ ┌────────────────────┐ │
│ Sends │────▶│ │ Invoice │ │ Address │ │ Block Scanner │ │
│ crypto │ │ │ Service │ │ Deriver │ │ (per-chain poll) │ │
│ to addr │ │ └────┬────┘ └────┬─────┘ └────────┬───────────┘ │
│ │ │ │ │ │ │
└──────────┘ │ │ xpub ──┘ Detects tx │ │
│ ▼ ▼ │
│ ┌──────────┐ ┌──────────────────┐ │
│ │ Postgres │◀─────────────│ Webhook Service │ │
│ │ (DB) │ │ (HMAC + retries) │ │
│ └──────────┘ └────────┬─────────┘ │
│ │ │
└─────────────────────────────────────┼───────────────┘
│
POST webhook callback
(payment.confirmed)
│
▼
┌─────────────┐
│ Your App │
│ (notified!) │
└─────────────┘
- API: https://self-custody-production.up.railway.app/api/v1/health
- Swagger Docs: https://self-custody-production.up.railway.app/api/docs
- Metrics: https://self-custody-production.up.railway.app/metrics
| Layer | Technology |
|---|---|
| Runtime | Node.js + TypeScript |
| Framework | Express.js |
| Database | PostgreSQL + Prisma ORM |
| Blockchain | ethers.js v6 (EVM chains) |
| Validation | Zod |
| Logging | Winston |
| Testing | Jest + ts-jest |
| Deployment | Docker + docker-compose |
# Clone and configure
cp .env.example .env
# Edit .env with your xpub, API key, and RPC URLs
# Start everything
docker-compose up -d
# API is live at http://localhost:3100
curl http://localhost:3100/api/v1/health# Prerequisites: Node.js 22+, PostgreSQL 16+
# Install dependencies
npm install
# Configure environment
cp .env.example .env
# Edit .env with your database URL, xpub, etc.
# Run database migrations
npx prisma migrate dev
# Start the server
npm run devAll endpoints except health checks require the X-API-Key header.
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/v1/health |
Liveness check |
| GET | /api/v1/health/ready |
Readiness (DB check) |
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/v1/invoices |
Create invoice + deposit address |
| GET | /api/v1/invoices |
List invoices (with filters) |
| GET | /api/v1/invoices/:id |
Get invoice details + payments |
| POST | /api/v1/invoices/:id/cancel |
Cancel unpaid invoice |
Create Invoice:
curl -X POST http://localhost:3100/api/v1/invoices \
-H "Content-Type: application/json" \
-H "X-API-Key: your-api-key" \
-d '{
"chainId": "ethereum",
"amount": "0.05",
"currency": "ETH",
"expiresIn": 3600
}'Response:
{
"id": "uuid",
"status": "created",
"chainId": "ethereum",
"depositAddress": "0x...",
"amount": "0.05",
"currency": "ETH",
"expiresAt": "2024-01-01T01:00:00.000Z"
}| Method | Endpoint | Description |
|---|---|---|
| GET | /api/v1/payments/:id |
Get payment details |
| GET | /api/v1/payments/invoice/:invoiceId |
List payments for an invoice |
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/v1/webhooks |
Register endpoint |
| GET | /api/v1/webhooks |
List endpoints |
| GET | /api/v1/webhooks/:id |
Get endpoint details |
| PUT | /api/v1/webhooks/:id |
Update endpoint |
| DELETE | /api/v1/webhooks/:id |
Deactivate endpoint |
| GET | /api/v1/webhooks/:id/deliveries |
View delivery history |
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/v1/chains |
List supported chains |
| GET | /api/v1/chains/:id/status |
Scanner health status |
created ──▶ pending ──▶ confirming ──▶ confirmed
│ │
▼ ▼
expired overpaid / underpaid
| Status | Meaning |
|---|---|
created |
Invoice created, awaiting payment |
pending |
Transaction detected (0 confirmations) |
confirming |
Has confirmations but below chain threshold |
confirmed |
Payment reached required confirmations |
expired |
No payment received before expiry |
overpaid |
Received more than expected |
underpaid |
Received less than expected |
| Event | Fired when |
|---|---|
payment.detected |
Transaction seen on-chain (0 conf) |
payment.confirmed |
Reached confirmation threshold |
invoice.expired |
Invoice expired without payment |
Webhooks are signed with HMAC-SHA256. Verify using the shared secret:
const crypto = require('crypto');
function verifyWebhook(body, secret, signature) {
const expected = crypto
.createHmac('sha256', secret)
.update(body)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(signature, 'hex')
);
}| Chain | Chain ID | Confirmations | Block Time |
|---|---|---|---|
| Ethereum | 1 | 12 | ~12s |
| Polygon | 137 | 30 | ~2s |
| BSC | 56 | 15 | ~3s |
| Arbitrum | 42161 | 1 | ~250ms |
Chains are activated by providing CHAIN_{NAME}_RPC_URL in your environment. Only chains with an RPC URL configured will be scanned.
You generate xpub from your hardware wallet / seed phrase
│
▼
Server receives xpub (public key only)
│
▼
Derives deposit addresses: m/44'/60'/0'/0/{index}
│
▼
Customer sends crypto to derived address
│
▼
Funds arrive in YOUR wallet ── you control the keys
The server uses HDNodeVoidWallet from ethers.js — it can derive addresses but cannot sign transactions or move funds. Your private keys stay in your hardware wallet.
npm test # Run all tests
npm run test:watch # Watch mode
npm run lint # Type checkSee .env.example for all configuration options. Key variables:
| Variable | Required | Description |
|---|---|---|
DATABASE_URL |
Yes | PostgreSQL connection string |
API_KEY |
Yes | API authentication key (min 16 chars) |
XPUB |
Yes | Extended public key (must start with xpub) |
CHAIN_*_RPC_URL |
No | RPC endpoint to activate a chain |
MIT