πŸ› οΈ For developers

The MENTA API

Your bot, your game, your website, your Zapier flow β€” pointed at the same candy jar your members already use. Read balances, send tips, run drops and rains, and get a signed webhook when anything happens. Free, like everything else here.

Base URL https://menta.tips/v1 Β· Auth Bearer key Β· Format JSON

Spec, client and examples on GitHub β€” menta-tip/menta

Get a key in ten seconds

In the server you want to build on, type:

$apikey new my-bot

MENTA sends the key to your DMs. It is shown once. Store it the way you would store a password β€” in an environment variable, never in front-end code and never in a repo.

curl https://menta.tips/v1/me \
  -H "Authorization: Bearer menta_live_..."

Keys are for the server owner and anyone with Manage Server. Manage them with $apikey, see what yours has been doing with $apikey calls, and kill one instantly with $apikey revoke menta_live_ab12cd.

What a key can and cannot do

A key can never withdraw. There is no endpoint.

Money leaves MENTA through exactly one path: a Discord command, run by the person who owns the balance, with a confirmation button. No API call reaches it β€” not with your key, not with a stolen one. This is not a permission you can grant; the route does not exist.

The server has to qualify too

A key only works in a server that may run MENTA at all: 50+ members, not switched off by review, and no adult or abusive content. Writes from a server that does not qualify return 403; reads keep working, so you can always see your own history. The server rules are the same ones the bot applies β€” a key is not a way around them.

Three more limits, all enforced before any handler runs:

Rate limit: 120 requests a minute, burstable to 40 at once, per key. Every response carries x-ratelimit-remaining.

Endpoints

Reading

GET/v1/meWho the key is, what it may do, what it has spent today
GET/v1/balancesYour balances, every token
GET/v1/assets?q=chadSearch the 9,000+ listed tokens
GET/v1/transactions?limit=50Your history, newest first, cursor-paged
GET/v1/guildYour server: settings, active members, payouts delivered
GET/v1/leaderboard?asset=CHADWho has received the most in your server
GET/v1/drops/{id}A drop's status and claim count
GET/v1/healthNo key needed β€” for your status page

Writing

POST/v1/tipsSend to one member or a hundred
POST/v1/dropsPost a claimable drop in a channel
POST/v1/rainSplit an amount between whoever is talking
POST/v1/linksLink one of your own user ids to a Discord account
GET/v1/linksWho has connected
POST/v1/webhooksSubscribe an endpoint to events
DELETE/v1/webhooks/{id}Unsubscribe

Tip someone for doing something

curl -X POST https://menta.tips/v1/tips \
  -H "Authorization: Bearer $MENTA_KEY" \
  -H "Idempotency-Key: level-up-8412" \
  -H "Content-Type: application/json" \
  -d '{"to": "685908488203010088", "asset": "CHAD", "amount": "500"}'
{
  "ok": true,
  "transfer_id": "48213",
  "asset": { "symbol": "CHAD", "chain": "base", "decimals": 18 },
  "recipients": ["685908488203010088"],
  "per_recipient": { "amount": "500", "amount_base": "500000000000000000000" },
  "total": { "amount": "500", "amount_base": "500000000000000000000" }
}

to also takes an array β€” one call, up to 100 members, each getting amount.

Drop a pot into a channel

curl -X POST https://menta.tips/v1/drops \
  -H "Authorization: Bearer $MENTA_KEY" \
  -H "Idempotency-Key: raid-win-2211" \
  -H "Content-Type: application/json" \
  -d '{"channel_id": "1543978711479488523", "asset": "CHAD",
       "amount": "25000", "minutes": 10, "note": "You cleared the raid."}'

MENTA posts the piΓ±ata with a claim button and splits the pot between everyone who hits it before the timer. Nobody claims? It comes back to you automatically.

Try it right now

Every tip is addressed to a Discord user id. Connect below and this page fills the example in with yours β€” that is the exact value your code puts in to.

What's my Discord id?

One click, identify scope only. You land on your own MENTA account page, which shows your id, your live balance, and the exact call to pay you.

Open my MENTA account

Telegram, your website, anywhere

MENTA pays Discord accounts. Your Telegram bot knows Telegram ids; your website knows its own user ids. Account links join the two, once, per person.

1Your app asks MENTA for a link for telegram:99887
2You send that person the URL
3They click Connect Discord once
4From then on you pay telegram:99887 by name
POST/v1/linksMint (or look up) a link for one of your users
GET/v1/links?external_id=…Has this person connected yet?
DELETE/v1/links/{external_id}Forget the mapping
curl -X POST https://menta.tips/v1/links \
  -H "Authorization: Bearer $MENTA_KEY" \
  -H "Idempotency-Key: link-99887" \
  -d '{"external_id": "telegram:99887", "label": "@ada"}'
{ "status": "pending",
  "link_url": "https://menta.tips/link/8Ff2xQ...",
  "expires_in_hours": 24 }

Then pay them by your id β€” no Discord ids anywhere in your code:

curl -X POST https://menta.tips/v1/tips \
  -H "Authorization: Bearer $MENTA_KEY" \
  -H "Idempotency-Key: quest-4417" \
  -d '{"to_external": "telegram:99887", "asset": "CHAD", "amount": "500"}'

Not linked yet? Nothing is sent.

Tipping someone who has not connected returns 409 not_linked with their link URL in the message, and no money moves β€” not even for the people in the same batch who had connected. Half-paying a list is worse than either outcome: you cannot tell which half went through without diffing balances. Send the links, call again.

A link is a forwarding address, not a login

Connecting lets you pay that person. It gives you nothing else: no reading their balance, no spending it, no acting as them. There is no operation in MENTA where a key takes from an account that linked to it, which is why the consent screen can promise that plainly.

Working examples

Your key belongs on your server

It is a bearer credential. Put it in an environment variable and call MENTA from your backend β€” never from a browser, a mobile app, or anything a user can view source on. A key in front-end code is a published key. It still cannot withdraw, and $apikey revoke kills it instantly, but it can spend your balance up to its cap until you do.

Amounts are strings. Always.

A JSON number cannot hold 3592961775.5232 without quietly rounding it, and a payments API that rounds a balance is a payments API that is wrong. So every amount travels as a string, in both directions, and comes back twice:

amount is the human one β€” "1,250.5". amount_base is the exact integer in the token's smallest unit. Do arithmetic on the second one.

Sending works the same way: pass "amount": "1250.5" or, if you already hold base units, "amount_base": "1250500000".

Retries cannot double-send

Every write needs an Idempotency-Key header β€” any string unique to that attempt. Retry with the same one and you get the original response back, marked "replayed": true. Nothing sends twice.

This is not optional and it is not decoration: a request that times out has not necessarily failed, and "did my tip go through?" is a question your code should never have to guess at. Use something meaningful β€” an event id, a message id, a job id.

Webhooks

Point an https endpoint at MENTA and hear about things as they happen β€” in your server, and on your own account.

curl -X POST https://menta.tips/v1/webhooks \
  -H "Authorization: Bearer $MENTA_KEY" \
  -H "Idempotency-Key: hook-1" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://yourapp.com/menta", "events": ["tip.created", "drop.claimed"]}'

Leave events out to get everything. The response carries a secret β€” shown once, like a key.

EventFires when
tip.createdA tip is sent in your server
drop.createdA drop is funded
drop.claimedSomeone grabs a piece
drop.settledA drop closes and the remainder returns
rain.createdA rain lands
deposit.creditedYour deposit is confirmed on chain
withdrawal.sentYour withdrawal broadcasts
trade.filledA peer-to-peer offer is taken

Verify the signature

Every delivery carries x-menta-signature: t=1788193268,v1=<hex>, an HMAC-SHA256 over timestamp.body using your webhook secret. Check it before you trust the body, and reject a timestamp older than five minutes β€” that is what stops someone replaying a delivery they captured.

// Node
import crypto from "node:crypto";

function verify(secret, header, rawBody) {
  const p = Object.fromEntries(header.split(",").map(s => s.split("=")));
  if (Math.abs(Date.now() / 1000 - Number(p.t)) > 300) return false;
  const expected = crypto.createHmac("sha256", secret)
    .update(`${p.t}.${rawBody}`).digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(p.v1));
}

Use the raw body, not a re-serialised object β€” re-encoding changes the bytes and the signature will not match.

A delivery that fails is retried after 30s, 2m, 10m, 1h and 6h. Answer with any 2xx as soon as you have the payload; do your work afterwards.

Errors

Always the same shape, always a sentence you can show a human.

{ "error": { "code": "insufficient_funds",
            "message": "You don't have enough CHAD for that." } }
StatusMeans
400Something in the request is wrong β€” the message says what
401Missing, malformed or revoked key
402Not enough balance
403The key is read-only
404No such route, asset, drop or webhook
429Rate limited, or the key's daily cap is reached
500Ours. Nothing was charged.

One rule worth repeating

Keep your key on a server you control. It is a bearer credential: anyone holding it can spend your balance up to its daily cap, in that one server. It still cannot withdraw, and $apikey revoke kills it the moment you need it gone.