Cointer watches crypto addresses on-chain and sends a notification when they receive funds. This document covers the HTTP API.
- Base URL:
http://localhost:3000(or wherever the server is hosted). - All requests and responses are JSON. Request bodies are capped at 100 KB.
- Errors are always
{ "error": "<message>" }. A malformed JSON body returns400 { "error": "Invalid JSON body" }. - Timestamps (
createdAt,priceAsOf, activity times) are unix seconds. - Amounts are decimal strings (e.g.
"0.00123456"). Fiat values are numbers rounded to 2 decimals. - Unknown paths return
404 { "error": "Not found" }. Unhandled errors return500 { "error": "Internal server error" }.
There are no accounts. Mint a personal key with POST /personal and send it on every authed request:
Authorization: Bearer ck_<43 chars>
The key is shown once. Only its hash is stored, so it can't be recovered. If you lose it, mint a new one. All your data (addresses, channels, push tokens, activity) is tied to the key.
Auth failures return 401 with Missing bearer token (no or bad header) or Invalid personal key (unknown or malformed key).
Public routes (no key needed): POST /personal, GET /chains, GET /stats/wallets, GET /capabilities, GET /health. Everything else needs the bearer key.
The server operator can grant a key admin status by adding its SHA-256 hash to the ADMIN_KEY_HASHES env var (generate the hash with bun run hash-key ck_<key>). Admin keys have no address/channel/push-token limits and their activity is never pruned. GET /personal reports the status via isAdmin and limits (null = unlimited). There is no API to grant or revoke admin status. Rotating an admin key produces a new hash, so it loses admin status until ADMIN_KEY_HASHES is updated with the new hash.
| Scope | Limit |
|---|---|
| All requests, per IP | 120 / minute |
POST /personal, per IP |
5 / hour |
| Authed requests, per key | 60 / minute |
POST /channels/:id/test, per key |
10 / hour |
Going over any of these returns 429 { "error": "Too many requests" } with a Retry-After header (seconds).
Mints a new personal key. No auth, no body.
201
{ "personalKey": "ck_dGhpcyBpcyBub3QgYSByZWFsIGtleSBhdCBhbGw" }Save it. It is never shown again.
Auth required. Returns the full account config, plus the key's admin status and effective limits. Never includes the key itself.
200
{
"createdAt": 1712345678,
"isAdmin": false,
"limits": {
"maxAddresses": 10,
"maxChannels": 10,
"maxPushTokens": 10,
"activityRetentionDays": 90
},
"addresses": [
{
"id": "uuid",
"chain": "bitcoin",
"address": "bc1q...",
"label": "Cold wallet",
"createdAt": 1712345678,
"notifications": { "pushMuted": false, "mutedChannelIds": [] }
}
],
"channels": [
{
"id": "uuid",
"type": "discord",
"enabled": true,
"config": { "url": "https://discord.com/…AbCd" },
"createdAt": 1712345678
}
],
"pushTokens": [
{ "id": "uuid", "token": "ExponentPushToken[...]", "platform": "ios", "createdAt": 1712345678 }
]
}For admin keys isAdmin is true and every value in limits is null (unlimited / kept forever).
Auth required. No body. Issues a replacement key for the same account. The old key stops working immediately. An admin key that is rotated loses admin status until the server's ADMIN_KEY_HASHES is updated (see Admin keys).
200 { "personalKey": "ck_..." }
Auth required. No body. Deletes the key and everything under it.
200 { "deleted": true }
All routes require auth.
Lists the addresses you're watching.
200
{
"addresses": [
{
"id": "uuid",
"chain": "bitcoin",
"address": "bc1q...",
"label": "Cold wallet",
"createdAt": 1712345678
}
]
}Starts watching an address. Existing on-chain history is seeded in the background so you don't get notified for old transactions.
Body:
| Field | Type | Required | Notes |
|---|---|---|---|
chain |
string | yes | A chain id from GET /chains (e.g. bitcoin, ethereum, base, solana, litecoin, bitcoin-cash, monero) |
address |
string | yes | Validated and normalized per chain |
label |
string | no | Max 100 chars |
viewKey |
string | only for chain: "monero" |
The address's private view key (64-char hex). Never send a spend key. |
Monero deposits can't be detected from the address alone — a private view key is required to scan for incoming transfers (see the Backend README for why). The server never needs and should never be given a spend key.
201 { "id": "uuid", "chain": "bitcoin", "address": "bc1q...", "label": "Cold wallet", "createdAt": 1712345678 }
400 unsupported chain, invalid address, bad label, missing/malformed viewKey for Monero, duplicate (You are already watching this address), or Address limit reached (10). Admin keys are exempt from the limit.
Renames a watched address.
Body:
| Field | Type | Required | Notes |
|---|---|---|---|
label |
string | no | Max 100 chars. Omit or null to clear it |
200 { "id": "uuid", "chain": "bitcoin", "address": "bc1q...", "label": "New name", "createdAt": 1712345678 }
400 bad label.
404 { "error": "Address not found" }
Notification settings for one address: push plus every channel you have, each flagged with whether it's enabled for this address.
200
{
"push": { "enabled": true },
"channels": [
{
"id": "uuid",
"type": "ntfy",
"enabled": true,
"config": { "server": "https://ntfy.sh", "topic": "my-topic" },
"createdAt": 1712345678,
"enabledForWallet": false
}
]
}enabled is the channel's global switch. enabledForWallet is the per-address mute.
404 { "error": "Address not found" }
Updates the per-address settings. false mutes, true unmutes.
Body (both fields optional):
| Field | Type | Notes |
|---|---|---|
push |
boolean | Mute/unmute push for this address |
channels |
object | Map of channel id to boolean |
Example: { "push": false, "channels": { "some-channel-id": true } }
200 updated settings, same shape as GET.
400 non-boolean values or Unknown channel id: <id>.
404 { "error": "Address not found" }
Stops watching the address.
200 { "deleted": true }
404 { "error": "Address not found" }
Notification channels besides push. All routes require auth. Secret config values (webhook URLs) are only ever returned as previews, e.g. "https://discord.com/…AbCd".
200
{
"channels": [
{
"id": "uuid",
"type": "slack",
"enabled": true,
"config": { "url": "https://hooks.slack.com/…AbCd" },
"createdAt": 1712345678
}
]
}Creates a channel. Body: type (string) and config (object, validated per type):
type |
config |
Notes |
|---|---|---|
ntfy |
{ "topic": "...", "server": "https://..." } |
Topic: letters/digits/-/_, max 64. server is optional and defaults to https://ntfy.sh |
discord |
{ "url": "https://discord.com/api/webhooks/..." } |
Must be a Discord webhook URL |
slack |
{ "url": "https://hooks.slack.com/..." } |
Must be a Slack webhook URL |
email |
{ "to": "you@example.com" } |
Only available if the server has email configured (check GET /capabilities) |
201 the created channel (preview shape, as in GET).
400 bad type/config, private/local URLs rejected, or Channel limit reached (10). Admin keys are exempt from the limit.
Body (both optional): enabled (boolean) and/or config (object, full replacement, validated against the channel's type; you can't change the type).
200 the updated channel.
400 enabled must be a boolean or config validation errors.
404 { "error": "Channel not found" }
200 { "deleted": true }
404 { "error": "Channel not found" }
Sends a sample notification through the channel. No body.
200 { "sent": true }, or { "sent": false, "error": "<why>" } if delivery failed. Send failures are 200 on purpose so proxies don't replace the error body.
404 { "error": "Channel not found" }
429 test-send rate limit exceeded.
Expo push tokens for the mobile app. All routes require auth.
Body:
| Field | Type | Required | Notes |
|---|---|---|---|
token |
string | yes | ExponentPushToken[...] or ExpoPushToken[...] |
platform |
string | yes | ios or android |
201 { "id": "uuid", "token": "ExponentPushToken[...]", "platform": "ios" }. Registering the same token twice returns the existing record.
400 invalid token format, bad platform, or Push token limit reached (10). Admin keys are exempt from the limit.
200 { "deleted": true }
404 { "error": "Push token not found" }
Incoming transfers seen on your watched addresses. Auth required.
Query params (both optional):
| Param | Notes |
|---|---|
limit |
Page size, default 25, max 100 |
cursor |
nextCursor from the previous page |
200 newest first:
{
"items": [
{
"id": 42,
"chain": "bitcoin",
"address": "bc1q...",
"txHash": "abc...",
"amount": "0.00123456",
"asset": "BTC",
"createdAt": 1712345678
}
],
"nextCursor": 41
}nextCursor is null on the last page.
Same as GET /activity (same limit/cursor params) but each item includes a fiat value, plus a total across all retained activity for the key. The total is the same on every page.
200
{
"currency": "usd",
"priceAsOf": 1712345678,
"total": 1234.56,
"unpricedCount": 0,
"items": [
{
"id": 42,
"chain": "bitcoin",
"address": "bc1q...",
"txHash": "abc...",
"amount": "0.00123456",
"asset": "BTC",
"createdAt": 1712345678,
"fiatValue": 12.34
}
],
"nextCursor": null
}Items with no fresh cached price get fiatValue: null, are left out of total, and are counted in unpricedCount. priceAsOf is null when no price has been fetched yet.
Aggregates your retained activity (retention period is in GET /capabilities). No query params.
200
{
"currency": "usd",
"priceAsOf": 1712345678,
"windows": {
"24h": { "count": 3, "fiatTotal": 123.45, "unpricedCount": 0 },
"7d": { "count": 9, "fiatTotal": 512.0, "unpricedCount": 1 },
"30d": { "count": 20, "fiatTotal": 1490.9, "unpricedCount": 2 },
"1y": { "count": 84, "fiatTotal": 6021.4, "unpricedCount": 2 }
},
"assets": [
{ "chain": "bitcoin", "asset": "BTC", "count": 5, "amount": "0.523", "fiatValue": 52300.0 }
]
}windows buckets deposits by age. Items with no fresh price are left out of fiatTotal and counted in unpricedCount. assets totals every retained row per asset, sorted by fiat value descending (fiatValue: null when unpriced).
Totals your incoming transfers for one calendar month (UTC). Useful for tracking a monthly goal.
Query params:
| Param | Notes |
|---|---|
month |
YYYY-MM, optional. Defaults to the current UTC month |
200
{
"month": "2026-07",
"currency": "usd",
"priceAsOf": 1712345678,
"count": 12,
"fiatTotal": 843.21,
"unpricedCount": 0,
"assets": [
{ "chain": "bitcoin", "asset": "BTC", "count": 8, "amount": "0.0123", "fiatValue": 743.21 }
]
}Deposits are priced at current cached prices, not the price at time of receipt. Items with no fresh price are left out of fiatTotal and counted in unpricedCount (fiatValue: null for fully unpriced assets). Only retained activity is counted (see activityRetentionDays in GET /capabilities), so months older than the retention window come back empty or partial. Admin keys are exempt from retention, so all their months stay complete.
400 { "error": "month must be in YYYY-MM format" }
No auth on any of these.
Chains supported by the server (depends on the server's ENABLED_CHAINS config). Use the id values for POST /addresses.
200
{
"chains": [
{ "id": "bitcoin", "name": "Bitcoin", "asset": "BTC" },
{ "id": "ethereum", "name": "Ethereum", "asset": "ETH" },
{ "id": "base", "name": "Base", "asset": "ETH" },
{ "id": "solana", "name": "Solana", "asset": "SOL" },
{ "id": "litecoin", "name": "Litecoin", "asset": "LTC" },
{ "id": "bitcoin-cash", "name": "Bitcoin Cash", "asset": "BCH" },
{ "id": "monero", "name": "Monero", "asset": "XMR" }
]
}Chains that support multiple assets (Ethereum, Base, and Solana can watch native-coin deposits plus configured tokens like USDC/USDT/DAI/EURC) share one chain id — the specific asset received shows up in the asset field of each activity item, not in GET /chains.
Number of distinct wallets watched across all keys.
200 { "watchedWallets": 123 }
Server configuration: whether the email channel type is available, the fiat currency, and the per-key limits.
200
{
"email": false,
"currency": "usd",
"limits": {
"maxAddressesPerKey": 10,
"maxChannelsPerKey": 10,
"maxPushTokensPerKey": 10,
"activityRetentionDays": 90
}
}These are the server-wide defaults. The effective limits for a specific key (admin keys are unlimited) are in GET /personal.
200 { "status": "ok" }