HTTP API
Pounty's frontend talks to its own JSON API and so can you. Everything below is on the live deployment; responses are real, captured from it.
Basics
| Item | Value |
|---|---|
| Base URL | https://pounty.co |
| Format | JSON in, JSON out. Send Content-Type: application/json on writes. |
| Auth | A session cookie, pounty_session, set by the auth endpoints. There are no API keys and no bearer tokens for user endpoints. |
| Errors | A non-2xx status with { "error": "..." }. Validation failures are 400, a missing session is 401, an admin-only route without admin rights is 403. |
| Chain | 4663 |
Public reads
| Route | Method | Returns |
|---|---|---|
/api/tokens | GET | The whole reward-token registry with live USD prices. Cached 30 seconds. |
/api/dares | GET | Every dare that is live, under review, or expired with at least one submission. Includes the reward view and the creator. |
/api/dares/[id] | GET | One dare, with explorer links and, while it is pending payment, the payment block. |
/api/dares/[id]/submissions | GET | Submissions on a dare with their files and statuses. |
/api/dares/completed | GET | Completed dares joined to the winning submission and its proof. |
/api/stats | GET | Home-page aggregates: paid out, completed, live bounties, latest payouts. Cached 30 seconds. |
/api/eth-price | GET | The ETH/USD price with its source and whether it is cached or stale. |
/api/upload | GET | Whether file uploads are configured on this deployment. |
/api/health | GET | Database and Redis connectivity. 200 when both are up, 503 otherwise. |
/api/user/[userId]/stats | GET | Public counters for one user. |
GET /api/tokens, trimmed to three of the 56 entries:
$ curl -s https://pounty.co/api/tokens
{
"chainId": 4663,
"explorer": "https://robinhoodchain.blockscout.com",
"tokens": [
{
"id": "ETH",
"address": null,
"symbol": "ETH",
"name": "Ether",
"decimals": 18,
"kind": "native",
"priceUsd": 2484.03,
"imageUrl": "https://dd.dexscreener.com/ds-data/tokens/robinhood/0x0Bd7...d168.png?size=lg"
},
{
"id": "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168",
"address": "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168",
"symbol": "USDG",
"name": "Global Dollar",
"decimals": 6,
"kind": "stablecoin",
"priceUsd": 1.0012
},
{
"id": "0xaF3D76f1834A1d425780943C99Ea8A608f8a93f9",
"address": "0xaF3D76f1834A1d425780943C99Ea8A608f8a93f9",
"symbol": "AAPL",
"name": "Apple • Robinhood Token",
"decimals": 18,
"kind": "stock",
"priceUsd": 320.44
}
]
}$ curl -s https://pounty.co/api/eth-price
{"price":2484.03,"cached":false,"source":"coingecko","stale":false}
$ curl -s https://pounty.co/api/upload
{"enabled":false}
$ curl -s https://pounty.co/api/health
{"status":"ok","timestamp":"2026-09-07T23:27:31.341Z","service":"pounty-api",
"services":{"database":{"status":"connected","error":null},
"redis":{"status":"connected","error":null}}}
$ curl -s https://pounty.co/api/stats
{"totalPaidUsd":0,"daresCompleted":0,"activeDares":0,"payouts":[],"dares":[],
"updatedAt":"2026-09-07T23:27:32.089Z"}The reward view
Every endpoint that returns a dare serialises the reward the same way, so a client never has to guess which field is authoritative.
| Field | Meaning |
|---|---|
rewardToken | The token id: ETH or a checksummed contract address. |
rewardSymbol | Ticker, for display. |
rewardDecimals | Decimals of that token. |
rewardAmount | Human amount as a string, trailing zeros trimmed. |
rewardAmountRaw | The same amount in base units, as a string. |
rewardUsd | The USD value recorded when the dare was created. |
rewardUsdCurrent | The USD value at the current price, or null if unknown. |
rewardLabel | Ready-made string such as 2 NVDA. |
bountyEth, bountyUsd | Legacy fields. bountyEth is 0 for any non-ETH reward. |
Creating and funding a dare
POST /api/dares/create requires a session. Five calls per hour per account.
| Parameter | Type | Required | Notes |
|---|---|---|---|
title | string | Yes | Up to 200 characters. |
description | string | No | Up to 2000 characters. |
difficulty | string | Yes | Easy, Medium, Hard or Extreme. |
rewardToken | string | No | ETH or a registry address. Defaults to ETH. Anything unknown is rejected. |
rewardAmount | string | number | Yes | Token units. bountyEth is accepted as the legacy alias. |
photoRequired | boolean | One of the three | At least one proof requirement must be true. |
videoRequired | boolean | One of the three | |
linkRequired | boolean | One of the three | The proof is a link to a post; submissions without proofUrl are refused. |
durationHours | number | Yes | Between 2 and 43800. |
refundAddress | string | For ETH | EVM address. Required for ETH rewards unless the account has a linked wallet. |
auctionType | string | No | dutch or english. Omitted means a fixed reward. See the auctions page. |
auctionHours | number | With auctionType | Dutch: hours for the reward to climb from the opening amount to the maximum. English: bidding window in hours. |
auctionStartAmount | string | number | Dutch | Opening reward in token units, below rewardAmount. |
auctionMinStepBps | integer | No | English: minimum undercut per bid in basis points, default 500. |
Every dare object carries an auction block, null for fixed rewards:
"auction": {
"type": "dutch",
"startAmount": "4",
"maxAmount": "10",
"hours": 1,
"endsAt": "<funding + hours>",
"closedAt": null,
"minStepBps": null,
"phase": "rising", // pending | rising | at_max (Dutch) | bidding | open (English) | locked
"currentReward": "7", // Dutch price now, English lowest bid (or max), or the locked reward
"currentRewardRaw": "7000000",
"locked": null, // { reward, userId, username, at } once a hunter holds it
"bidCount": 0,
"lowestBid": null,
"maxNextBid": null, // English while bidding: the most a new bid may be
"surplusRefundTxHash": null // set after payout, when the unearned part went back to the creator
}| Route | Method | Body | What it does |
|---|---|---|---|
/api/dares/[id]/bid | POST | { amount } | English only, session required, not the creator, while bidding is open. Must not exceed the lowest bid minus the step (or the maximum for the first bid). Replaces your previous bid. 60 per hour. |
/api/dares/[id]/bids | GET | - | The bid book, lowest first, with usernames and won / lost after the close. |
/api/dares/[id]/accept | POST | - | Dutch: locks the reward at the current price for you (response carries acceptance.lockedReward). English: refused with code bidding_open while bidding runs, locked_by_other once someone else holds it. |
/api/dares/[id]/status | GET | - | Adds auction: { type, biddingOpen, lockedByMe, lockedByOther, lockedReward, myBid }. |
The response carries the dare and, unless it was created free in development, a payment block with everything needed to fund it.
{
"success": true,
"dare": {
"id": "<32 hex chars>",
"title": "...",
"rewardToken": "0xd0601CE157Db5bdC3162BbaC2a2C8aF5320D9EEC",
"rewardSymbol": "NVDA",
"rewardAmount": "2",
"rewardAmountRaw": "2000000000000000000",
"rewardUsd": 372.5,
"status": "pending_payment",
"escrowWalletAddress": "0x...",
"refundAddress": "0x...",
"expiresAt": "...",
"payment": {
"chainId": 4663,
"address": "0x<escrow>",
"token": "0xd0601CE157Db5bdC3162BbaC2a2C8aF5320D9EEC",
"symbol": "NVDA",
"decimals": 18,
"amount": "2",
"amountRaw": "2000000000000000000",
"uri": "ethereum:0xd060...9EEC@4663/transfer?address=0x<escrow>&uint256=2000000000000000000",
"explorerUrl": "https://robinhoodchain.blockscout.com/address/0x<escrow>",
"deadline": "<created_at + 1 hour>"
}
},
"message": "Dare created, payment required"
}| Route | Method | Body | What it does |
|---|---|---|---|
/api/dares/[id]/check-payment | GET | - | Reads the escrow balance on-chain and activates the dare if it is covered. Returns paid, status, received, expected, symbol. |
/api/dares/verify-payment | POST | { dareId, txHash } | Creator only. Verifies that transaction paid the escrow at least the reward, then activates the dare. |
/api/dares/[id]/cancel | POST | - | Creator only, and only while the dare is still pending payment. |
Accepting and submitting
| Route | Method | Body | Notes |
|---|---|---|---|
/api/dares/[id]/accept | POST | - | Session required. Not your own dare, not already submitted, dare live. 10 per hour. |
/api/dares/[id]/status | GET | - | Session required. Your acceptance state on that dare: accepted, secondsRemaining, canSubmit, canAccept. |
/api/dares/[id]/submit | POST | { proofUrl?, proofType?, files?, payoutWalletAddress? } | Session required. Needs a link or at least one file. 20 per day. |
/api/dares/[id]/request-extension | GET, POST | { reason? } | Only while the dare is active and in its last 25% of time, once per hunter, once per dare. |
/api/dares/[id]/review-extension | POST | { extensionRequestId, action } | Creator only. Approve or reject. |
/api/dares/extension-requests | GET | - | Extension requests on your own dares. |
/api/upload | POST | multipart form data | Session required. Returns 501 when uploads are not configured. |
Uploads are not configured on the live deployment: GET /api/upload answers { "enabled": false } and POST returns 501 with UPLOADS_DISABLED. Submit a proofUrl instead.
Auth
| Route | Method | Purpose |
|---|---|---|
/api/auth/register | POST | Create an account from a username and a device fingerprint. |
/api/auth/login | POST | Sign in. |
/api/auth/me | GET | The current session user, or null. |
/api/auth/logout | POST | Clear the session. |
/api/auth/recover | POST | Recover an account with the recovery code. |
/api/auth/recognize | POST | Look up an account by device fingerprint. |
/api/auth/link-phantom | POST | Attach a wallet to the account, proved by a signature. |
/api/auth/phantom-register | POST | Register with a wallet address. The address is not signature-checked here. |
/api/auth/phantom-login | POST | Sign in with a wallet. |
$ curl -s https://pounty.co/api/auth/me
{"user":null}
$ curl -s -X POST -H 'content-type: application/json' -d '{}' \
https://pounty.co/api/dares/create
{"error":"Not authenticated"}Admin
Every route here needs a session belonging to an admin account and answers 403 otherwise.
| Route | Method | Purpose |
|---|---|---|
/api/admin/check | GET | Whether the current session is an admin, and through which rule. |
/api/admin/dares | GET | Every dare with escrow, payment, refund state and totals. |
/api/admin/submissions | GET | Submissions queued for review. |
/api/admin/review | POST | Approve or reject a submission. Approval starts the payout in the background. |
/api/admin/retry-payment | POST | Retry a payout for an approved submission with no payment hash. |
/api/admin/refunds | GET, POST | List refunds still owed; POST records a refund made by hand. |
/api/admin/dares/[id]/refund | POST | Force the refund step for one dare, ignoring the retry cap. |
/api/admin/dares/[id]/remove | POST | Remove a dare, with a reason. Refunds it if it was funded. |
Scheduler routes
These are not session-authenticated. They take a bearer token equal to ADMIN_CRON_KEY or CRON_SECRET, and return 401 without it.
| Route | Method | Purpose |
|---|---|---|
/api/admin/process-expired | GET, POST | One scheduler tick: payments, acceptance timers, expiry, reopening, refunds. |
/api/cron/process-expired-dares | GET, POST | An alias of the same handler, kept for existing cron configurations. |
/api/cron/monitor-payments | GET | Payment activation only. |
/api/admin/process-payments | POST | Payment activation for dares created in the last 24 hours. |
$ curl -s -X POST https://pounty.co/api/admin/process-expired
{"error":"Unauthorized"}
$ curl -s -X POST -H 'Authorization: Bearer <ADMIN_CRON_KEY>' \
https://pounty.co/api/admin/process-expired
{
"success": true,
"skipped": false,
"message": "Processed 0 payments, 0 expired dares, 0 expired acceptances, 0 refunds (0 failed, 0 need manual refund)",
"processed": { "payments": [], "expiredDares": [], "reopenedDares": [],
"refunds": [], "staleLocksReleased": [], "staleProcessingSubmissions": 0 },
"processedPayments": 0,
"processedDares": 0,
"expiredAcceptances": 0,
"refunded": 0,
"refundsFailed": 0,
"daresNeedingRefund": 0,
"timestamp": "..."
}The tick response above is the documented shape from the handler, with the counters at zero. It is the one example on this page not captured from a live call, because running it requires the deployment secret.
X bot routes
The bot that turns a mention on X into a dare lives inside the app (src/lib/xbot). These routes drive it. The first two take the same bearer as the scheduler; the events list needs an admin session.
| Route | Method | Body | Purpose |
|---|---|---|---|
/api/x/poll | POST | - | Fetch new mentions from X, create the dares, post the replies. Called by the cron worker. Answers skipped: true, reason: not_configured until the X keys are set; the first real poll only primes the cursor. |
/api/x/poll | GET | - | Status: configured, cursor, counts of handled tweets in the last 24 hours. |
/api/x/mention | POST | { tweetId, authorId, handle, text } | Handle one tweet by hand without touching X. Returns the decision and the reply the bot would post. Never posts. |
/api/x/events | GET | - | Admin session. The last 100 tweets handled, with status, reply, and the dare they opened. |
$ curl -s -X POST -H 'Authorization: Bearer <ADMIN_CRON_KEY>' -H 'content-type: application/json' \
-d '{"tweetId":"1965000000000000001","authorId":"12345","handle":"someone",
"text":"@pountybot 2 NVDA for the best meme of $CHUMP in 24h"}' \
https://pounty.co/api/x/mention
{
"success": true,
"tweetId": "1965000000000000001",
"status": "created",
"code": null,
"detail": null,
"dareId": "<32 hex chars>",
"replyText": "@someone Bounty created: 2 NVDA for \"the best meme of $CHUMP\".\nFund it: send exactly 2 NVDA on Robinhood Chain (id 4663) to\n0x<escrow>\nOpen 1 day once funded.\nhttps://pounty.co/platform?dare=<id>",
"dare": { "id": "<id>", "title": "the best meme of $CHUMP", "amount": "2", "symbol": "NVDA",
"escrowAddress": "0x<escrow>", "url": "https://pounty.co/platform?dare=<id>", "durationHours": 24 }
}status is one of created, rejected (with a code such as unknown_token, refund_required, below_minimum, rate_limited and a reply), ignored (own tweet, retweet, no attempt at a bounty: no reply), failed (server error, no reply) or duplicate (that tweet was handled before, nothing done).
Rate limits
Limits are counted per account, falling back to client IP, and are enforced in production only. Exceeding one returns 429 with retryAfter and the usual X-RateLimit-* headers.
| Action | Limit |
|---|---|
| Create a dare | 5 per hour |
| Request an extension | Shares the same 5 per hour bucket as creating a dare |
| Accept a dare | 10 per hour |
| Submit proof | 20 per day |