Documentation
Cointer API
Cointer watches crypto addresses on-chain and sends a notification when they receive funds. This document covers the HTTP API.
Basics
- 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" }.
Authentication
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.
Admin keys
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.
Rate limits
| 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).
Personal key
POST /personal
Mints a new personal key. No auth, no body.
201
{ "personalKey": "ck_dGhpcyBpcyBub3QgYSByZWFsIGtleSBhdCBhbGw" }
Save it. It is never shown again.
GET /personal
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).
POST /personal/rotate
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_..." }
DELETE /personal
Auth required. No body. Deletes the key and everything under it.
200 { "deleted": true }
Addresses
All routes require auth.
GET /addresses
Lists the addresses you're watching.
200
{
"addresses": [
{
"id": "uuid",
"chain": "bitcoin",
"address": "bc1q...",
"label": "Cold wallet",
"createdAt": 1712345678
}
]
}
POST /addresses
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) |
address |
string | yes | Validated and normalized per chain |
label |
string | no | Max 100 chars |
201 { "id": "uuid", "chain": "bitcoin", "address": "bc1q...", "label": "Cold wallet", "createdAt": 1712345678 }
400 unsupported chain, invalid address, bad label, duplicate (You are already watching this address), or Address limit reached (10). Admin keys are exempt from the limit.
PATCH /addresses/:addressId
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" }
GET /addresses/:addressId/notifications
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" }
PATCH /addresses/:addressId/notifications
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" }
DELETE /addresses/:addressId
Stops watching the address.
200 { "deleted": true }
404 { "error": "Address not found" }
Channels
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".
GET /channels
200
{
"channels": [
{
"id": "uuid",
"type": "slack",
"enabled": true,
"config": { "url": "https://hooks.slack.com/…AbCd" },
"createdAt": 1712345678
}
]
}
POST /channels
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": "[email protected]" } |
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.
PATCH /channels/:channelId
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" }
DELETE /channels/:channelId
200 { "deleted": true }
404 { "error": "Channel not found" }
POST /channels/:channelId/test
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.
Push tokens
Expo push tokens for the mobile app. All routes require auth.
POST /push-token
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.
DELETE /push-token/:tokenId
200 { "deleted": true }
404 { "error": "Push token not found" }
Activity
Incoming transfers seen on your watched addresses. Auth required.
GET /activity
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.
GET /activity/value
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.
GET /activity/summary
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).
GET /activity/month
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" }
Public / meta
No auth on any of these.
GET /chains
Chains supported by the server. Use the id values for POST /addresses.
200 { "chains": [ { "id": "bitcoin", "name": "Bitcoin", "asset": "BTC" } ] }
GET /stats/wallets
Number of distinct wallets watched across all keys.
200 { "watchedWallets": 123 }
GET /capabilities
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.
GET /health
200 { "status": "ok" }