POUNTY
Login

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

ItemValue
Base URLhttps://pounty.co
FormatJSON in, JSON out. Send Content-Type: application/json on writes.
AuthA session cookie, pounty_session, set by the auth endpoints. There are no API keys and no bearer tokens for user endpoints.
ErrorsA non-2xx status with { "error": "..." }. Validation failures are 400, a missing session is 401, an admin-only route without admin rights is 403.
Chain4663

Public reads

RouteMethodReturns
/api/tokensGETThe whole reward-token registry with live USD prices. Cached 30 seconds.
/api/daresGETEvery dare that is live, under review, or expired with at least one submission. Includes the reward view and the creator.
/api/dares/[id]GETOne dare, with explorer links and, while it is pending payment, the payment block.
/api/dares/[id]/submissionsGETSubmissions on a dare with their files and statuses.
/api/dares/completedGETCompleted dares joined to the winning submission and its proof.
/api/statsGETHome-page aggregates: paid out, completed, live bounties, latest payouts. Cached 30 seconds.
/api/eth-priceGETThe ETH/USD price with its source and whether it is cached or stale.
/api/uploadGETWhether file uploads are configured on this deployment.
/api/healthGETDatabase and Redis connectivity. 200 when both are up, 503 otherwise.
/api/user/[userId]/statsGETPublic 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.

FieldMeaning
rewardTokenThe token id: ETH or a checksummed contract address.
rewardSymbolTicker, for display.
rewardDecimalsDecimals of that token.
rewardAmountHuman amount as a string, trailing zeros trimmed.
rewardAmountRawThe same amount in base units, as a string.
rewardUsdThe USD value recorded when the dare was created.
rewardUsdCurrentThe USD value at the current price, or null if unknown.
rewardLabelReady-made string such as 2 NVDA.
bountyEth, bountyUsdLegacy 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.

ParameterTypeRequiredNotes
titlestringYesUp to 200 characters.
descriptionstringNoUp to 2000 characters.
difficultystringYesEasy, Medium, Hard or Extreme.
rewardTokenstringNoETH or a registry address. Defaults to ETH. Anything unknown is rejected.
rewardAmountstring | numberYesToken units. bountyEth is accepted as the legacy alias.
photoRequiredbooleanOne of the threeAt least one proof requirement must be true.
videoRequiredbooleanOne of the three
linkRequiredbooleanOne of the threeThe proof is a link to a post; submissions without proofUrl are refused.
durationHoursnumberYesBetween 2 and 43800.
refundAddressstringFor ETHEVM address. Required for ETH rewards unless the account has a linked wallet.
auctionTypestringNodutch or english. Omitted means a fixed reward. See the auctions page.
auctionHoursnumberWith auctionTypeDutch: hours for the reward to climb from the opening amount to the maximum. English: bidding window in hours.
auctionStartAmountstring | numberDutchOpening reward in token units, below rewardAmount.
auctionMinStepBpsintegerNoEnglish: 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
}
RouteMethodBodyWhat it does
/api/dares/[id]/bidPOST{ 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]/bidsGET-The bid book, lowest first, with usernames and won / lost after the close.
/api/dares/[id]/acceptPOST-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]/statusGET-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"
}
RouteMethodBodyWhat it does
/api/dares/[id]/check-paymentGET-Reads the escrow balance on-chain and activates the dare if it is covered. Returns paid, status, received, expected, symbol.
/api/dares/verify-paymentPOST{ dareId, txHash }Creator only. Verifies that transaction paid the escrow at least the reward, then activates the dare.
/api/dares/[id]/cancelPOST-Creator only, and only while the dare is still pending payment.

Accepting and submitting

RouteMethodBodyNotes
/api/dares/[id]/acceptPOST-Session required. Not your own dare, not already submitted, dare live. 10 per hour.
/api/dares/[id]/statusGET-Session required. Your acceptance state on that dare: accepted, secondsRemaining, canSubmit, canAccept.
/api/dares/[id]/submitPOST{ proofUrl?, proofType?, files?, payoutWalletAddress? }Session required. Needs a link or at least one file. 20 per day.
/api/dares/[id]/request-extensionGET, 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-extensionPOST{ extensionRequestId, action }Creator only. Approve or reject.
/api/dares/extension-requestsGET-Extension requests on your own dares.
/api/uploadPOSTmultipart form dataSession 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

RouteMethodPurpose
/api/auth/registerPOSTCreate an account from a username and a device fingerprint.
/api/auth/loginPOSTSign in.
/api/auth/meGETThe current session user, or null.
/api/auth/logoutPOSTClear the session.
/api/auth/recoverPOSTRecover an account with the recovery code.
/api/auth/recognizePOSTLook up an account by device fingerprint.
/api/auth/link-phantomPOSTAttach a wallet to the account, proved by a signature.
/api/auth/phantom-registerPOSTRegister with a wallet address. The address is not signature-checked here.
/api/auth/phantom-loginPOSTSign 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.

RouteMethodPurpose
/api/admin/checkGETWhether the current session is an admin, and through which rule.
/api/admin/daresGETEvery dare with escrow, payment, refund state and totals.
/api/admin/submissionsGETSubmissions queued for review.
/api/admin/reviewPOSTApprove or reject a submission. Approval starts the payout in the background.
/api/admin/retry-paymentPOSTRetry a payout for an approved submission with no payment hash.
/api/admin/refundsGET, POSTList refunds still owed; POST records a refund made by hand.
/api/admin/dares/[id]/refundPOSTForce the refund step for one dare, ignoring the retry cap.
/api/admin/dares/[id]/removePOSTRemove 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.

RouteMethodPurpose
/api/admin/process-expiredGET, POSTOne scheduler tick: payments, acceptance timers, expiry, reopening, refunds.
/api/cron/process-expired-daresGET, POSTAn alias of the same handler, kept for existing cron configurations.
/api/cron/monitor-paymentsGETPayment activation only.
/api/admin/process-paymentsPOSTPayment 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.

RouteMethodBodyPurpose
/api/x/pollPOST-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/pollGET-Status: configured, cursor, counts of handled tweets in the last 24 hours.
/api/x/mentionPOST{ 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/eventsGET-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.

ActionLimit
Create a dare5 per hour
Request an extensionShares the same 5 per hour bucket as creating a dare
Accept a dare10 per hour
Submit proof20 per day