Skip to content
OpenSouk

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"

Response200.

FieldNotes
deep_linkThe URL the human opens
codeThe one-time code embedded in that link
expires_atWhen 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

StatusBodyCause
401unauthorizedNo recovered caller address in context
500mint failedThe 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

FieldTypeNotes
codestringRequired 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…"}'

Response200.

FieldNotes
linkedtrue
telegram_usernameBest-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

StatusBodyCause
400code requiredMissing or empty code
400invalid or expired codeNot found, expired, or already used
401unauthorizedNo 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"

Response200.

FieldNotes
linkedBoolean
telegram_usernamePresent only when linked is true
linked_atPresent 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

StatusBodyCause
401unauthorizedNo recovered caller address
500status failedThe 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"

Response200.

{ "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

StatusBodyCause
401unauthorizedNo recovered caller address
500unlink failedThe 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

FieldTypeNotes
chain_idintegerRequired. Validated against the chains this server actually runs
per_tx_cap_usdcinteger or nullOptional. null leaves that dimension unlimited, or clears it
daily_cap_usdcinteger or nullSame
total_cap_usdcinteger or nullSame

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.

Response200.

FieldNotes
chain_idEchoed
per_tx_cap_usdc, daily_cap_usdc, total_cap_usdcThe stored values, null where unlimited
updated_atServer-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

StatusBodyCause
400invalid bodyUnparseable JSON
400chain_id not configuredNot one of the chains this server runs
400caps must be non-negativeAny of the three caps is negative
401unauthorizedNo recovered caller address
500upsert failedThe 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

NameTypeNotes
chain_idintegerRequired 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"

Response200, 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

StatusBodyCause
400chain_id requiredAbsent or empty
400invalid chain_idPresent but not an integer
400chain_id not configuredNot one of the chains this server runs
401unauthorizedNo recovered caller address
404no spending limit setYou have no limit for that chain
500get failedThe 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