ONPChat API
Publish to your own channels, groups, classes and santrals — from any app or server.
An OpenAPI 3.1 file: import it straight into Postman or Insomnia, or generate an SDK for your language from it.
curl https://api.onpchat.ir/v1/me \ -H "Authorization: Bearer $ONP_TOKEN"
const r = await fetch("https://api.onpchat.ir/v1/me", { headers: { "Authorization": `Bearer ${process.env.ONP_TOKEN}` }, }); const { ok, result } = await r.json();
import os, requests r = requests.get( "https://api.onpchat.ir/v1/me", headers={"Authorization": f"Bearer {os.environ['ONP_TOKEN']}"}, ) print(r.json())
$ch = curl_init("https://api.onpchat.ir/v1/me"); curl_setopt_array($ch, [ CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("ONP_TOKEN")], CURLOPT_RETURNTRANSFER => true, ]); $res = json_decode(curl_exec($ch), true);
Quick start
- Get a token. Sign in with an account that administers the space, then open:
The id includes its prefix and must be URL-encoded — channel
#1786551855736becomes%231786551855736. If no token exists yet, one is created right there. - Check the token.
- Send a message.
https://onpchat.ir/api/space/api-token?entity_id=<id>
curl -X POST https://api.onpchat.ir/v1/channels/%231786551855736/messages \ -H "Authorization: Bearer $ONP_TOKEN" \ -H "Content-Type: application/json" \ -d '{"text":"Hello from the API"}'
await fetch( "https://api.onpchat.ir/v1/channels/%231786551855736/messages", { method: "POST", headers: { "Authorization": `Bearer ${process.env.ONP_TOKEN}`, "Content-Type": "application/json", }, body: JSON.stringify({ text: "Hello from the API" }), }, );
import os, requests requests.post( "https://api.onpchat.ir/v1/channels/%231786551855736/messages", headers={"Authorization": f"Bearer {os.environ['ONP_TOKEN']}"}, json={"text": "Hello from the API"}, )
$url = "https://api.onpchat.ir/v1/channels/%231786551855736/messages"; $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . getenv("ONP_TOKEN"), "Content-Type: application/json", ], CURLOPT_POSTFIELDS => json_encode(["text" => "Hello from the API"]), CURLOPT_RETURNTRANSFER => true, ]); curl_exec($ch);
Authentication
Each space has its own token, valid only for that space. Send it in the header:
?token= are not accepted. This API is for servers, not browsers, and sends no CORS headers. Never put the token in client-side JavaScript — any visitor could take it and post as your space. If a token leaks, revoke it from the same panel; a new one replaces it immediately.Authorization: Bearer onp_2e09e8f0bb35088c795e5f75dc...
Response shape
Branch on error.code, not on message. The message is for humans and may change; the code will not.
{ "ok": true, "result": { ... } }
{ "ok": false, "error": {
"code": "invalid_token",
"message": "..."
} }
Spaces
There are four kinds of space, each with its own prefix. The prefix is part of the id and must be URL-encoded.
| Kind | In path | Prefix | Sender visible? |
|---|---|---|---|
| Channel | channels | # → %23 | No — posted as the channel |
| Group | groups | @ → %40 | Yes |
| Class | classes | % → %25 | Yes |
| Santral | santrals | & → %26 | Yes |
Endpoints
| Method | Path | What it does |
|---|---|---|
| GET | /v1/me | Which space this token belongs to |
| GET | /v1/{type}/{id} | Space details |
| GET | /v1/{type}/{id}/messages | Message history |
| POST | /v1/{type}/{id}/messages | Publish a message |
History
Newest first. Pagination uses seq, not time — a monotonic counter that gives a definite order even for two messages sent in the same millisecond. Pass next_cursor as the next page's before_seq; null means you have reached the end.
GET /v1/channels/%231786551855736/messages ?limit=30&before_seq=42
{
"ok": true,
"result": {
"messages": [
{
"id": "a1b2c3",
"text": "...",
"seq": 42,
"timestamp": 1788301362311
}
],
"next_cursor": 41
}
}
Cards (rich posts)
Instead of raw text you can send a card — the same thing the app builds under «New card»: cover, title, text and link in one clean frame.
Send card instead of text. They are mutually exclusive; text that accompanies the card goes in caption.
| Field | Required | Description |
|---|---|---|
url | yes | Where the card links to |
title | no | Card title |
description | no | Text inside the card |
image | no | Cover — must be a full http/https URL |
caption | no | Text outside the card, in your own voice |
The image must be hosted somewhere reachable from the internet; uploads are not in this version. Relative URLs are rejected, because the card renders on the reader's phone where such a path resolves to nothing.
curl -X POST https://api.onpchat.ir/v1/groups/%401786579451315/messages \ -H "Authorization: Bearer $ONP_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "card": { "url": "https://example.com/post", "title": "...", "description": "...", "image": "https://example.com/cover.jpg", "caption": "..." } }'
await fetch(url, { method: "POST", headers: { "Authorization": `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify({ card: { url: "https://example.com/post", title: "...", description: "...", image: "https://example.com/cover.jpg", caption: "...", }, }), });
requests.post(
url,
headers={"Authorization": f"Bearer {token}"},
json={
"card": {
"url": "https://example.com/post",
"title": "...",
"description": "...",
"image": "https://example.com/cover.jpg",
"caption": "...",
}
},
)
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([ "card" => [ "url" => "https://example.com/post", "title" => "...", "description" => "...", "image" => "https://example.com/cover.jpg", "caption" => "...", ], ]));
Don't send duplicates
If a request times out you cannot tell whether it landed. Send an Idempotency-Key so a retry does not create a second message:
Same key, same message. The key also becomes the message's id.
Idempotency-Key: order-2026-09-02-001
Rate limits
Per token, per minute:
| Operation | Limit |
|---|---|
| Publish | 20 |
| Read | 120 |
Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset. On 429 you also get Retry-After — wait that long, not less.
HTTP/2 429 X-RateLimit-Limit: 20 X-RateLimit-Remaining: 0 Retry-After: 37
Errors
| HTTP | error.code | Meaning |
|---|---|---|
| 400 | empty_message | text is empty |
| 400 | bad_request | Invalid body or parameter |
| 401 | missing_token | No Authorization header |
| 401 | invalid_token | Token not recognised |
| 401 | revoked_token | Token has been revoked |
| 403 | entity_mismatch | This token belongs to another space |
| 403 | wrong_entity_kind | Space kind in the path does not match the token |
| 403 | missing_scope | Token lacks this scope |
| 403 | sender_not_member | The token's creator is no longer a member |
| 403 | read_only | The channel is read-only |
| 404 | entity_not_found | Space not found |
| 413 | message_too_long | Text longer than 8000 characters |
| 429 | rate_limited | Rate limit reached |
sender_not_member? The token is bound to the admin who created it. If that person left the space, publishing stops. Fix: have a current admin regenerate the token from the panel.Not in this version
Sending images and files, deleting messages via the API, and webhooks (being notified of space events) are not here yet. Tokens already carry a scopes field, so when webhooks arrive your token will not be invalidated.