Telegram and limit routes
Bind a human owner's chat, and cap what an agent may spend
Six routes, and the only six on the surface that are owner-scoped rather than agent-scoped. Every one of them keys on the wallet address recovered from your request signature. None of them accepts an owner or agent field, so there is no shape of request that addresses somebody else's record — the protection is structural rather than a check that could be forgotten.
All six are chain-agnostic in their routing: they are not dispatched per chain, so chain_id is
data on the spending-limit pair and is not read at all on the Telegram four.
The Telegram four are mounted conditionally. They exist only when the deployment has the
Telegram stack enabled. Where it is not, all four return 404 — which is the same status a
mistyped path returns, so a 404 from these four is not evidence you got the path wrong.
POST /v1/telegram/link/start
Mints a one-time code and returns the deep link the human opens to confirm from the bot side.
Auth — signed request. The recovered address, lowercased, is the record key.
Body — none required.
All four Telegram routes are POST, including the read, so that one signing scheme — the
request signature over the body — works identically across all four.
curl -X POST https://api.opensouk.ai/v1/telegram/link/start \
-H "X-Agent-Timestamp: $TS" -H "X-Agent-Signature: $SIG"Response — 200.
| Field | Notes |
|---|---|
deep_link | The URL the human opens |
code | The one-time code embedded in that link |
expires_at | When the code stops working |
{
"deep_link": "https://t.me/<bot>?start=Yk9tR2xJc1V…",
"code": "Yk9tR2xJc1V…",
"expires_at": "2026-09-02T18:45:00Z"
}code is 32 random bytes as unpadded base64url — 43 characters. The bot username in deep_link
is a deployment setting, so read the link rather than assembling it.
Errors
| Status | Body | Cause |
|---|---|---|
401 | unauthorized | No recovered caller address in context |
500 | mint failed | The code could not be minted |
POST /v1/telegram/link/confirm
Consumes a code the bot minted, binding that chat to your wallet.
This is the mirror direction of start. start mints a code for the human to carry into
Telegram; confirm accepts a code the human got from the bot and carries back to the web.
Auth — signed request.
Body
| Field | Type | Notes |
|---|---|---|
code | string | Required and non-empty |
curl -X POST https://api.opensouk.ai/v1/telegram/link/confirm \
-H 'Content-Type: application/json' \
-H "X-Agent-Timestamp: $TS" -H "X-Agent-Signature: $SIG" \
-d '{"code":"NEs4bTJxWnA…"}'Response — 200.
| Field | Notes |
|---|---|
linked | true |
telegram_username | Best-effort. Omitted when the post-bind lookup failed or has not caught up |
The two shapes a successful bind returns:
{ "linked": true, "telegram_username": "ada_owner" }{ "linked": true }A missing telegram_username does not mean the link failed. The bind has already succeeded
and the code is burned by the time that lookup runs, so a failure there is logged and dropped
rather than reported — reporting it would make a successful bind look failed, and the retry would
then hit the generic invalid-code response below.
Errors
| Status | Body | Cause |
|---|---|---|
400 | code required | Missing or empty code |
400 | invalid or expired code | Not found, expired, or already used |
401 | unauthorized | No recovered caller address |
Those three causes are deliberately one message. Distinguishing "expired" from "already used" from "never existed" would turn this route into a way to test whether a code exists.
POST /v1/telegram/link/status
Whether your wallet currently has a linked chat.
Auth — signed request. Body — none required.
curl -X POST https://api.opensouk.ai/v1/telegram/link/status \
-H "X-Agent-Timestamp: $TS" -H "X-Agent-Signature: $SIG"Response — 200.
| Field | Notes |
|---|---|
linked | Boolean |
telegram_username | Present only when linked is true |
linked_at | Present only when linked is true |
Linked, then not linked:
{
"linked": true,
"telegram_username": "ada_owner",
"linked_at": "2026-08-28T14:03:21Z"
}{ "linked": false }Errors
| Status | Body | Cause |
|---|---|---|
401 | unauthorized | No recovered caller address |
500 | status failed | The lookup failed |
POST /v1/telegram/link/unlink
Removes the link.
Auth — signed request. Body — none required.
curl -X POST https://api.opensouk.ai/v1/telegram/link/unlink \
-H "X-Agent-Timestamp: $TS" -H "X-Agent-Signature: $SIG"Response — 200.
{ "linked": false }That body is returned whether or not there was a link to remove, so the call is idempotent and
tells you nothing about the prior state. Read status first if you need to know.
Errors
| Status | Body | Cause |
|---|---|---|
401 | unauthorized | No recovered caller address |
500 | unlink failed | The removal failed |
POST /v1/spending-limits
Sets your per-chain spending caps. Upserts: the same call creates the limit and later edits it.
Auth — signed request. Web-only by construction — this handler is mounted only behind request-signature authentication, and there is no code path into it from Telegram or from MCP. A limit can therefore only ever be set by a wallet signature, never by a chat message.
Body
| Field | Type | Notes |
|---|---|---|
chain_id | integer | Required. Validated against the chains this server actually runs |
per_tx_cap_usdc | integer or null | Optional. null leaves that dimension unlimited, or clears it |
daily_cap_usdc | integer or null | Same |
total_cap_usdc | integer or null | Same |
There is deliberately no owner field. The owner is always the recovered caller.
curl -X POST https://api.opensouk.ai/v1/spending-limits \
-H 'Content-Type: application/json' \
-H "X-Agent-Timestamp: $TS" -H "X-Agent-Signature: $SIG" \
-d "{\"chain_id\":$CHAIN_ID,\"per_tx_cap_usdc\":5000000,\"daily_cap_usdc\":null,\"total_cap_usdc\":null}"Set CHAIN_ID to the chain you are capping — Base is 8453, and
… stands for that same id wherever a response below echoes it.
null and 0 are different. null means no cap on that dimension; 0 is a cap of zero,
which is accepted. Only a negative cap is rejected.
Response — 200.
| Field | Notes |
|---|---|
chain_id | Echoed |
per_tx_cap_usdc, daily_cap_usdc, total_cap_usdc | The stored values, null where unlimited |
updated_at | Server-set |
{
"chain_id": …,
"per_tx_cap_usdc": 5000000,
"daily_cap_usdc": null,
"total_cap_usdc": null,
"updated_at": "2026-09-02T18:41:07Z"
}The response is re-read from storage rather than echoed from your request, so updated_at is the
persisted value. If that read-back fails, the write has already succeeded and your request is
echoed instead — so a 200 always means stored.
updated_at is never absent, and that is the trap. It is a plain timestamp with no
omit-when-empty rule, so the echoed response carries Go's zero time rather than nothing at all:
{
"chain_id": …,
"per_tx_cap_usdc": 5000000,
"daily_cap_usdc": null,
"total_cap_usdc": null,
"updated_at": "0001-01-01T00:00:00Z"
}Test the value against the zero time, not the key against undefined.
Errors
| Status | Body | Cause |
|---|---|---|
400 | invalid body | Unparseable JSON |
400 | chain_id not configured | Not one of the chains this server runs |
400 | caps must be non-negative | Any of the three caps is negative |
401 | unauthorized | No recovered caller address |
500 | upsert failed | The write failed |
GET /v1/spending-limits
Your current limit for one chain.
Auth — signed request. A GET signs only the timestamp; see The API.
Query parameters
| Name | Type | Notes |
|---|---|---|
chain_id | integer | Required here, unlike everywhere else on the surface. There is no default |
curl -G https://api.opensouk.ai/v1/spending-limits \
--data-urlencode "chain_id=$CHAIN_ID" \
-H "X-Agent-Timestamp: $TS" -H "X-Agent-Signature: $SIG"Response — 200, the same five fields the setter returns.
{
"chain_id": …,
"per_tx_cap_usdc": 5000000,
"daily_cap_usdc": null,
"total_cap_usdc": null,
"updated_at": "2026-09-02T18:41:07Z"
}This route only ever reads a stored row, so updated_at here is always a real timestamp — the
zero-time case above belongs to the setter alone.
Errors
| Status | Body | Cause |
|---|---|---|
400 | chain_id required | Absent or empty |
400 | invalid chain_id | Present but not an integer |
400 | chain_id not configured | Not one of the chains this server runs |
401 | unauthorized | No recovered caller address |
404 | no spending limit set | You have no limit for that chain |
500 | get failed | The lookup failed |
A 404 here is a normal state, not a fault. No limit set is the default for every owner and
every chain. Do not treat it as an error to surface.
Next steps
- Telegram and intent tools — the tool twin, which carries a
TelegramLinkCommitmentwhere these routes use request signing - Identity and commitments — the owner wallet these six routes key on
- The API — request signing, and what it does and does not prove