What it means
Every peer in Telegram's MTProto API has two parts. There is an ID, which is a plain number that identifies a user, a basic group, or a channel. And there is an access hash, a 64-bit integer that Telegram hands you when you legitimately discover that peer.
Almost every method that takes a peer takes both. The input constructors make this explicit: inputPeerUser(user_id, access_hash), inputPeerChannel(channel_id, access_hash), inputUser(user_id, access_hash), inputChannel(channel_id, access_hash). The same pattern shows up outside peers too: photos, documents and file locations all carry an access hash of their own, because Telegram uses the same mechanism to gate every referenceable object on the platform.
The reason it exists is straightforward. User IDs are small, sequential-ish numbers. If IDs alone were enough to address a person, anyone could enumerate the ID space and message the entire user base. The access hash turns an ID into a capability: you can only talk to peers you have actually encountered, through a channel you are in, a username you resolved, a group whose members you can list, or a message someone sent you.
The rule that catches everybody: hashes are per account
This is the single most important operational fact about access hashes, and it is the one that breaks multi-account systems.
An access hash is derived from the authorization that fetched it. Account A resolves @example_user and gets hash 7734928472113049182. Account B resolves the same username five seconds later and gets a completely different number. Both are valid. Neither works from the other account.
The consequence for a CRM that rotates across a pool of Telegram accounts is concrete: your contact table cannot have a single access_hash column. It needs a separate mapping table keyed by (account_id, peer_id). The moment you store one hash per contact and let any account in the pool send to it, roughly (n-1)/n of your sends start failing with PEER_ID_INVALID, and the failure looks exactly like a deleted account, so the usual first reaction is to delete perfectly good contacts.
The same rule explains a behaviour people find surprising: a contact list scraped by account A is not immediately usable by account B. Account B has to resolve those peers itself, which costs API calls and, at volume, triggers flood waits. Scraped IDs transfer between accounts. Scraped hashes do not.
Min constructors: the hash that only works in one room
There is a second class of access hash that behaves differently, and almost every self-built Telegram integration gets bitten by it once.
When Telegram sends you a user or channel object for a peer you have not properly discovered, it sets the min flag on the object. You see min constructors constantly: the author of a message in a large group you just joined, the original sender of a forward, the admin who pinned a post. Telegram gives you enough to render a name and an avatar, but the access hash inside a min object is scoped to the chat that delivered it.
Cache that hash as though it were a normal one and a DM attempt later fails, usually with PEER_ID_INVALID, sometimes with nothing more useful. The correct reference for a min peer is inputPeerUserFromMessage(peer, msg_id, user_id) (and inputPeerChannelFromMessage for channels), which tells the server "this is the person from that message, in that chat", so the server can look up a valid hash on your behalf.
A production-grade peer cache therefore stores a flag: was this entity full or min? Only full entities may be used for cold outbound. Min entities are display data.
Where valid hashes come from
You never construct an access hash. You only ever receive one. The practical sources, in rough order of how much they cost you in rate limit budget, are listed in the table further down this page. The general principle is that any response containing a users or chats array is free cache: a client that parses only the part of the response it asked for and discards the peer arrays will re-resolve the same people over and over, and pay for it in flood waits.
Why it matters for a CRM
Access hash handling is invisible when it works and catastrophic when it does not, which is a bad combination for anyone running outbound at volume.
Consider a campaign of 5,000 messages spread across six Telegram accounts. Every send needs a valid inputPeerUser for that specific sending account. If the peer cache is keyed correctly, the sender picks the right hash and the message goes out. If the cache is keyed by contact only, five of the six accounts fail on every attempt, the retry loop re-resolves the username to recover, the re-resolution burns through the resolve limit, and the account earns a flood wait for behaviour that was really just a schema bug.
The second-order effect is worse. Cascading PEER_ID_INVALID failures that get auto-retried look, to Telegram's abuse systems, a lot like an account probing IDs it does not have. That is the fastest route from a working account to PEER_FLOOD.
Common mistakes
- One access hash column on the contact row. The schema has to be per account. This is the mistake, and every other item on this list is downstream of it.
- Storing hashes from min constructors. Flag them, or reference those peers through
inputPeerUserFromMessageinstead of caching at all. - Retrying a PEER_ID_INVALID with the same hash. It will never succeed. The retry path for an invalid peer is re-resolve, not repeat.
- Storing the hash in a signed 32-bit column. An access hash is a full 64-bit value and routinely negative. Use
BIGINT, and be careful with JSON, where an int64 that round-trips through a double loses precision silently. - Discarding the users array from update payloads. Those entries are free, already-authorised peers. Ignoring them means paying resolve calls for contacts you were handed.
- Assuming a hash proves the peer still exists. A valid cached hash for a deleted account still returns an error on send. Handle
INPUT_USER_DEACTIVATEDas a contact status, not as a transient failure to retry.
Related concepts
- Peer ID: the other half of a peer reference, and the part that is portable between accounts.
- MTProto: the protocol that defines the input constructors described here.
- Session string: the authorization an access hash is bound to. New session, new account context, new hashes.
- Telegram Bot API: the wrapper that hides access hashes entirely, at the cost of never being able to message a stranger.
- Flood wait: what you get when broken hash handling turns into a re-resolve loop.
- Supergroup: the richest source of full user constructors, and therefore of usable hashes.
How Pinlyx handles it
Pinlyx keeps a per-account peer cache keyed on (account_id, peer_id) rather than on the contact, so a campaign that rotates across a pool always sends with the hash that belongs to the account doing the sending. Min entities are stored with a flag and are never used as cold-outbound targets. When a send returns PEER_ID_INVALID the engine re-resolves the peer once for that account, records the result, and only then marks the contact unreachable, so a schema-shaped failure never gets mistaken for a dead contact. The whole path runs on WTelegramClient over MTProto, which is why the Telegram CRM can DM people who never wrote to us first.