Skip to content
teleproto

Raw TL API

Every method in Telegram's TL schema is callable — as a typed client.api.* call or through client.invoke(new Api.*). When the friendly methods don't cover what you need, drop to raw.

Friendly first, raw when stuck#

The mental ladder is short. Reach for client.sendMessage, client.getMessages, client.iterDialogs first — they cover almost everything you'll want to do day to day. The moment you need a parameter the friendly method doesn't expose (silent delivery, a custom randomId, scheduling, a TTL period, an alternative entity reference), drop to client.invoke(new Api.messages.SendMessage(...)). The friendly methods are wrappers; raw is the floor underneath them.

friendly-vs-raw.ts
import bigInt from "big-integer";
import { Api } from "teleproto";

// Friendly: covers 90% of cases.
await client.sendMessage("me", { message: "hi" });

// Raw: same outcome, every TL field at your disposal.
await client.invoke(
  new Api.messages.SendMessage({
    peer: await client.getInputEntity("me"),
    message: "hi",
    noWebpage: true,
    silent: true,
    randomId: bigInt(Date.now()),
  }),
);

The client.api facade#

Since v1.227 there's a third rung between friendly and fully raw: client.api. It mirrors the entire TL schema as camelCase async methods — client.api.messages.sendMessage({...}), client.api.channels.joinChannel({...}) — with full types for both params and results, and the method docs from core.telegram.org attached as JSDoc so they pop up in your IDE.

facade.ts
// Every TL method, as a plain typed async call. Params and result
// are fully typed; JSDoc from core.telegram.org shows up in your IDE.
const config = await client.api.help.getConfig({});

await client.api.messages.sendMessage({
  peer: await client.getInputEntity("me"),
  message: "hi from the facade",
  silent: true,
});

// Identical wire behavior to client.invoke(new Api.messages.SendMessage(...)),
// minus the constructor ceremony.

Under the hood it's the same invoke path — same retries, same FloodWait handling, same errors. Prefer the facade for new code; keep new Api.* constructors for the cases below where you're building TL objects (peers, input types) rather than calling methods.

Plain objects instead of constructors#

Coming from @mtproto/core or mtcute and used to writing TL objects as literals with a _ tag? That works here too. Wherever a TL type is expected — facade params, new Api.* arguments, items inside vectors — a plain object is coerced to the right generated class at serialization time.

plain-objects.ts
// Nested TL objects don't need constructors either. Anywhere a TL
// type is expected, a plain object with a "_" tag coerces to it:
await client.api.payments.getPaymentForm({
  invoice: { _: "inputInvoiceSlug", slug: "AbCdEf012345" },
});

// Same thing through invoke — coercion happens at serialization:
await client.invoke(
  new Api.messages.SendMessage({
    peer: { _: "inputPeerSelf" },
    message: "hi",
  }),
);

// When the expected type has exactly ONE constructor, "_" is optional.
// When it's ambiguous, teleproto throws and lists the candidates:
//   Ambiguous type "InputPeer" — add "_" to pick a constructor
//   (one of: InputPeerEmpty, InputPeerSelf, InputPeerChat, ...)

The _ tag is the TL constructor name in its original casing (inputPeerSelf, inputInvoiceSlug). If the field's type has exactly one constructor, you can omit _entirely; if it's ambiguous, serialization throws with the list of candidates instead of sending a broken payload. Class instances and plain objects mix freely in the same request.

Anatomy of a request#

A raw call is three things: a constructor for the TL method, an invoke, and a return type that's usually a union you need to narrow. Take messages.getHistory:

get-history.ts
import bigInt from "big-integer";
import { Api } from "teleproto";

const req = new Api.messages.GetHistory({
  peer: await client.getInputEntity("@telegram"),
  limit: 20,
  offsetId: 0,
  offsetDate: 0,
  addOffset: 0,
  maxId: 0,
  minId: 0,
  hash: bigInt.zero,
});

const result = await client.invoke(req);
// result is Api.messages.TypeMessages — a union. Narrow by className:
if (result instanceof Api.messages.Messages
  || result instanceof Api.messages.MessagesSlice
  || result instanceof Api.messages.ChannelMessages) {
  for (const msg of result.messages) {
    if (msg instanceof Api.Message) {
      console.log(msg.id, msg.message);
    }
  }
}

The return is Api.messages.TypeMessages — a union of Messages, MessagesSlice, ChannelMessages, and MessagesNotModified. Narrow with instanceof or by checking the className discriminator and TypeScript will let you in.

The 5-step recipe#

Telethon's classic five-step ladder, ported to teleproto:

  1. Open core.telegram.org/methods and find your method.
  2. Look at the params and what they expect (a Peer? an InputUser? an int? a Vector<int>?).
  3. Build the inputs with client.getInputEntity(...), client.getEntity(...), or by instantiating an Api.Input* constructor directly.
  4. Call await client.invoke(new Api.<namespace>.<Method>(params)).
  5. Handle the result. It's a TL union — narrow it with instanceof before reading fields.

Recipes: common raw calls#

The calls people actually drop to raw for — copy, adjust, ship. All of these run through the facade; the new Api.* spelling works identically.

recipes.ts
// Resolve a public username without the entity helpers
const res = await client.api.contacts.resolveUsername({
  username: "telegram",
});

// Full user info — bio, common chats count, settings
const full = await client.api.users.getFullUser({
  id: await client.getInputEntity("@durov"),
});

// Join a public channel
await client.api.channels.joinChannel({
  channel: await client.getInputEntity("@somechannel"),
});

// Preview an invite link without joining
const invite = await client.api.messages.checkChatInvite({
  hash: "AAAAAAAAAAAAAAAAAA", // the part after t.me/+
});

// Update your own profile
await client.api.account.updateProfile({
  about: "away until monday",
});

// randomId fields are auto-generated when omitted — sendMessage,
// forwardMessages etc. don't need you to roll them by hand.

Two conveniences to know. Fields named randomId— Telegram's client-side dedup nonces — are generated automatically when you omit them, as single values or as vectors matching your id array. And request resolution goes through the same entity machinery as the friendly methods, so anything getInputEntity accepts is usable where the schema wants a peer.

Constructors you'll instantiate by hand#

Not every Api.* entry is a method. Half the namespace is TL types — peer references, input objects, update shapes — that you pass intorequests or match on inside event handlers. A few you'll see constantly:

constructors.ts
import bigInt from "big-integer";
import { Api } from "teleproto";

// Peer references — what messages and updates point at.
new Api.PeerUser({ userId: bigInt("12345") });
new Api.PeerChat({ chatId: bigInt("67890") });
new Api.PeerChannel({ channelId: bigInt("100123456") });

// Input peers — what TL methods accept as the "peer" param.
new Api.InputPeerSelf();
new Api.InputPeerUser({
  userId: bigInt("12345"),
  accessHash: bigInt.zero,
});

// Update shapes you'll match on inside event handlers.
// (Don't construct these yourself — Telegram sends them to you.)
//   Api.UpdateBotCallbackQuery
//   Api.UpdateNewMessage
//   Api.UpdateEditMessage

Rule of thumb: capitalised names with no namespace (Api.PeerUser, Api.InputPeerSelf) are types. Namespaced ones with verb-ish names (Api.messages.SendMessage, Api.channels.JoinChannel) are methods you pass to invoke.

BigInts#

Telegram IDs and access hashes don't fit in JavaScript's number, so teleproto uses the npm big-integer package — not the native BigInt. IDs come back as bigInt instances. Pass them straight to Api constructors. Call .toString() when you need to print or log them. Never hand a raw bigInt to JSON.stringify — it serialises as {} with no warning.

bigints.ts
import bigInt from "big-integer";
import { Api } from "teleproto";

const me = await client.getMe();
console.log("My ID:", me.id.toString());     // print: ".toString()"
console.log("Type:", typeof me.id);          // "object" — it's a bigInt instance

// Construct one when a TL field needs it.
const userId = bigInt("123456789");
new Api.PeerUser({ userId });

// Don't do this — JSON has no idea what bigInt is.
JSON.stringify({ id: me.id });               // -> '{"id":{}}'  (silently empty)
JSON.stringify({ id: me.id.toString() });    // -> '{"id":"123456789"}'  (good)

Wrap an Api call in a friendly helper#

When you use the same raw call from more than one place, lift it into a function with sensible defaults. That's how the friendly methods on TelegramClient were built in the first place — and how you grow your own surface on top of teleproto without forking it.

get-last-messages.ts
import bigInt from "big-integer";
import { Api } from "teleproto";
import type { TelegramClient } from "teleproto";

// Your own friendly wrapper around messages.getHistory.
export async function getLastMessages(
  client: TelegramClient,
  username: string,
  n = 20,
) {
  const peer = await client.getInputEntity(username);
  const res = await client.invoke(
    new Api.messages.GetHistory({
      peer,
      limit: n,
      offsetId: 0,
      offsetDate: 0,
      addOffset: 0,
      maxId: 0,
      minId: 0,
      hash: bigInt.zero,
    }),
  );
  return "messages" in res ? res.messages : [];
}