Server-side Java SDK for GetChat. It does two things:
- Builds signed chat URLs so you can drop the chat UI into an iframe or WebView.
- Wraps the GetChat REST API, authenticating with a
Bearertoken.
Java 17 or newer. Its one runtime dependency, Jackson, is used only inside the
SDK: chat and message reads come back as typed objects
(ChatDetails, Message, Page<T>, …) and
everything else as the SDK's own JsonValue, so no
com.fasterxml.jackson type ever appears in the public API. (JSpecify ships
alongside it but is annotations only — no runtime code.)
Status: early. URL signing is stable and covered by a conformance test suite. The REST layer covers the common chat, message, user and participant endpoints; for anything else, call the API directly (see Calling endpoints the SDK does not wrap).
Not published to Maven Central yet. Build and install it into your local Maven repository:
./gradlew publishToMavenLocaldependencies {
implementation("dev.getchat:getchat-java-sdk:1.0.0")
}On the classpath the SDK is a plain library and needs nothing more. If your
application is a Java module, the SDK is the module dev.getchat.sdk — add
requires dev.getchat.sdk; to your module-info.java. Only the public package
dev.getchat.sdk is exported; the signing and transport internals stay private
to the module.
The SDK has two entry points, one for each job. Build each one, then reuse it — both are safe to share across threads.
// Signs embed URLs. Needs a client id, a client secret and a base URL.
GetChatUrlSigner signer = GetChatUrlSigner.builder()
.clientId("your-client-id")
.secret("your-client-secret")
.baseUrl("https://chat.example.com/embed")
.build();
// Calls the REST API. Needs the API URL and an API token.
GetChatClient client = GetChatClient.builder()
.apiUrl("https://chat.example.com")
.apiToken("your-api-token")
.build();- Build only the one you need: a program that just signs URLs never creates a
GetChatClient, and the URL signer opens no network resources. - Each builder checks at
build()that everything it needs is set. A missing or blank value throwsGetChatExceptionright there, so you can never end up with a half-configured object that fails later — no URL that comes out asnull?nonce=..., no REST call fired without a token. build()also checks the URL:baseUrl/apiUrlmust be an absolutehttp/httpsURL, or it throwsGetChatException. A relative path, aftp:scheme, or a malformed string is caught up front.- Both
baseUrl(...)andapiUrl(...)accept ajava.net.URIas well as aString— pass whichever form you have; they behave identically. - The two are independent. The API often lives on a different host than the
embed URL, which is why the client takes its own
apiUrl.
url(...) builds the current, recommended URL. It takes a
UrlOptions carrying a User (required) and, usually,
the Chat to open; Rights says what the user may do and
Recipient adds other participants:
String url = signer.url(UrlOptions.builder()
.chat(Chat.builder().id("support-42").title("Support").create(true).build())
.user(User.builder()
.id("u-1")
.name("Alice")
.rights(Rights.builder()
.sendMessages(true)
.editMessages(Rights.Scope.MY)
.pinMessages(Rights.Pin.FOR_EVERYONE)
.build())
.build())
.participant(Recipient.of("u-2", "Bob"))
.extra("theme", "dark")
.build());- A user with no
idis treated as anonymous and gets a random 40-character session token, so each browser stays distinguishable across page loads. extravalues are added after the signature and are not signed — treat them as display hints, never as permissions.
urlByChatId(...) builds an older URL that the backend checks with a different,
separate scheme, so the two are not interchangeable — prefer url(...) for new
work. It always needs a chat (its id is what the older scheme signs by). There
are three forms:
// Just a chat id and a user:
String a = signer.urlByChatId("support-42", User.of("u-1"));
// A Chat object and a user:
String b = signer.urlByChatId(Chat.builder().id("support-42").title("Support").build(), User.of("u-1"));
// Full options, when you also need participants or extra query params:
String c = signer.urlByChatId(UrlOptions.builder()
.chat("support-42")
.user(User.builder().id("u-1").name("Alice").build())
.participant(Recipient.of("u-2", "Bob"))
.build());Read methods return typed objects — ChatDetails,
Message, UserDetails, Participant,
and a Page<T> for lists. Create and edit methods return a typed
result too. Simple delete and typing calls return a boolean that is true
when the call succeeded — any error throws instead, so false never comes back.
One rule worth knowing up front: createChat, updateChat, createUser and
updateUser return an empty object — their getters fall back to defaults —
because the server does not send the object back by default. Pass the matching
options with returnResource(true) to get a filled-in object, or read it back
afterwards with getChat / getUser.
| Method | What it does | Returns |
|---|---|---|
listChats(ChatsQuery) |
List chats matching the filters | Page<ChatDetails> |
listChats(ChatsQuery, RequestControl) |
Same, with per-call timeout/retry overrides | Page<ChatDetails> |
getChat(String chatId) |
Fetch one chat by id | ChatDetails |
createChat(Chat) |
Create a chat | ChatDetails |
createChat(Chat, List<Recipient>) |
Create a chat with starting participants | ChatDetails |
createChat(Chat, List<Recipient>, CreateChatOptions) |
Create a chat; options can ask for the new chat back | ChatDetails |
updateChat(String chatId, Chat) |
Change a chat's title or metadata | ChatDetails |
updateChat(String chatId, Chat, UpdateChatOptions) |
Same; options can ask for the updated chat back | ChatDetails |
deleteChat(String chatId) |
Delete a chat | boolean |
Takes: ChatsQuery, Chat, Recipient,
CreateChatOptions, UpdateChatOptions,
RequestControl.
Page<ChatDetails> chats = client.listChats(ChatsQuery.builder()
.type(Chat.Type.GROUP)
.withOwners(true)
.page(1).limit(20)
.build());
for (ChatDetails c : chats) { // a Page is iterable — loop over it directly
System.out.println(c.id() + " " + c.title() + " (" + c.type() + ")");
}
// Ask for the created chat back so its getters are filled in:
ChatDetails created = client.createChat(
Chat.builder().id("support-42").title("Support").type(Chat.Type.GROUP).build(),
List.of(Recipient.of("u-1", "Alice")),
CreateChatOptions.builder().returnResource(true).build());
System.out.println(created.id() + " " + created.title());Notes:
- Always pass a
limit. Without one,listChatsreturns just one chat. - The date filters (
createdFrom,createdTo,lastMessageFrom,lastMessageTo) accept ajava.time.LocalDateTimeas well as the wire string. It is formatted to the strict formyyyy-MM-dd'T'HH:mm:ss— no timezone, seconds precision (any nanoseconds are dropped). The backend rejects anything else, which is why the type isLocalDateTimeand notInstant/OffsetDateTime. pageandlimitare checked when youbuild()the query:pagemust be at least 1 andlimitmust be in1..1000, otherwisebuild()throwsGetChatException. (MessagesQueryandPageQuerycheck the same ranges.)- A private chat needs its participants at creation time.
updateChatchanges only a chat's title and metadata; send only the fields you want to change.deleteChatreturns once the delete is accepted; the server finishes the removal in the background, so the chat may disappear a moment later.
| Method | What it does | Returns |
|---|---|---|
listMessages(String chatId) |
First page of a chat's messages (up to 50) | Page<Message> |
listMessages(String chatId, MessagesQuery) |
Messages with filters and paging | Page<Message> |
sendMessage(Chat, User, String text) |
Post a message | SentMessages |
sendMessage(Chat, User, String text, SendMessageOptions) |
Post a message with participants, extra fields or buttons | SentMessages |
updateMessage(String chatId, String messageId, String text) |
Edit a message's text | UpdatedMessage |
updateMessage(String chatId, String messageId, String text, UpdateMessageOptions) |
Edit text, extra fields and buttons; can ask for the message back | UpdatedMessage |
deleteMessage(String chatId, String messageId) |
Delete a message | boolean |
sendTyping(String chatId, String userId) |
Show a typing indicator | boolean |
sendTyping(String chatId, String userId, Duration duration) |
Typing indicator for a set time (1–60 whole seconds) | boolean |
Takes: MessagesQuery, Chat, User,
SendMessageOptions,
UpdateMessageOptions.
SentMessages sent = client.sendMessage(
Chat.of("support-42"),
User.builder().id("u-1").name("Alice").build(),
"Pick one",
SendMessageOptions.builder()
.buttons(Button.of(Button.Type.URL, "Open"),
Button.builder().type(Button.Type.LOCAL).label("Dismiss")
.style(Button.Style.NEGATIVE).build())
.build());
System.out.println("sent " + sent.messageIds());
Page<Message> messages = client.listMessages("support-42",
MessagesQuery.builder().withUsers(true).page(1).limit(50).build());
for (Message m : messages.items()) {
System.out.println(m.userId() + ": " + m.text()); // text() is null for a deleted message
}
client.updateMessage("support-42", "m-1", "Edited text");Notes:
sendMessagereturns only the ids of the messages it created (sent.messageIds()), not the stored messages — the server does not send those back.- For
sendMessage, the chat only needs its id; any other chat fields you set create or update the chat. The user is required and the text must be non-empty. - In
updateMessage, anullor emptytextleaves the text unchanged. - The text of a deleted message is
null. sendTyping(chatId, userId, Duration)takes ajava.time.Durationof 1 to 60 whole seconds. A duration with a sub-second part, or one outside that range, throwsGetChatException(it is not silently truncated); anullduration throwsNullPointerException. Use the two-argument overload to send no duration and let the client default apply.
| Method | What it does | Returns |
|---|---|---|
createUser(User) |
Create a user | UserDetails |
createUser(User, CreateUserOptions) |
Create a user; options can ask for the new user back | UserDetails |
getUser(String userId) |
Fetch a user | UserDetails |
updateUser(String userId, User) |
Change a user's fields | UserDetails |
updateUser(String userId, User, UpdateUserOptions) |
Same; options can ask for the updated user back | UserDetails |
deleteUser(String userId) |
Delete a user | boolean |
listUserChats(String userId) |
First page of chats a user belongs to (up to 50) | Page<ChatDetails> |
listUserChats(String userId, PageQuery) |
Same, with paging | Page<ChatDetails> |
Takes: User, CreateUserOptions,
UpdateUserOptions, PageQuery.
UserDetails created = client.createUser(
User.builder().id("u-3").name("Carol").email("carol@example.com").build(),
CreateUserOptions.builder().returnResource(true).build());
System.out.println(created.id() + " " + created.name());
UserDetails user = client.getUser("u-3");
System.out.println(user.name() + " joined " + user.createdAt()); // createdAt() is an Instant
Page<ChatDetails> userChats = client.listUserChats("u-3", PageQuery.builder().page(1).limit(20).build());Notes:
createUserneeds a user with at least one field set.updateUserchanges a user's name, email, link, picture and metadata; send only the fields you want to change.
| Method | What it does | Returns |
|---|---|---|
listParticipants(String chatId) |
First page of a chat's participants (up to 50) | Page<Participant> |
listParticipants(String chatId, PageQuery) |
Same, with paging | Page<Participant> |
addParticipants(String chatId, List<Recipient>) |
Add participants to a chat | boolean |
removeParticipant(String chatId, String userId) |
Remove one participant | boolean |
client.addParticipants("support-42", List.of(
Recipient.of("u-2", "Bob"),
Recipient.builder().id("u-3").name("Carol")
.rights(Rights.builder().sendMessages(true).build())
.build()));
Page<Participant> participants = client.listParticipants("support-42");
for (Participant p : participants.items()) {
System.out.println(p.id() + " " + p.name());
}
client.removeParticipant("support-42", "u-2");Notes:
addParticipantsandremoveParticipantdo not send the participants back; read them withlistParticipants.- A
Participantcarries a person's identity fields but no metadata, and the participant list does not include a person's per-chat rights.
Everything you hand to the SDK is an immutable value object built through a
static builder(); the common ones also have a one-line factory (Chat.of,
User.of, Recipient.of, Button.of). They all implement equals, hashCode
and toString.
Most carry a set(key, value) escape hatch for a field this SDK version has no
typed setter for, so you can send something new without waiting for a release:
Chat, User, Recipient, Rights, Button, ChatsQuery, MessagesQuery
and UpdateMessageOptions have one.
| Type | What it is | Where it goes |
|---|---|---|
UrlOptions |
Everything a signed embed URL needs | url, urlByChatId |
Chat |
A chat to open, create or update | url, createChat, updateChat, sendMessage |
User |
The person a URL, a message or a user call is about | url, createUser, updateUser, sendMessage |
Recipient |
Somebody else taking part in a chat | createChat, addParticipants, SendMessageOptions |
Rights |
What a person may do in a chat | User, Recipient |
Button |
An inline message button | SendMessageOptions, UpdateMessageOptions |
ChatsQuery |
Filters and paging for the chat list | listChats |
MessagesQuery |
Filters and paging for the message list | listMessages |
PageQuery |
Paging on its own | listUserChats, listParticipants |
SendMessageOptions |
Participants, extra fields and buttons for a new message | sendMessage |
UpdateMessageOptions |
The same for an edit, plus the merge mode | updateMessage |
CreateChatOptions |
Ask for the created chat back | createChat |
UpdateChatOptions |
Ask for the updated chat back | updateChat |
CreateUserOptions |
Ask for the created user back | createUser |
UpdateUserOptions |
Ask for the updated user back | updateUser |
Two more input objects are described where they are used:
ApiRequest for unwrapped endpoints,
and RequestOptions / RequestControl for timeouts and
retries.
Arguments to url(...) and to the full form of
urlByChatId(...). A user is always required; urlByChatId also
needs a chat.
| Setter | What it sets |
|---|---|
chat(Chat) / chat(String chatId) |
The chat to open |
user(User) |
Who the URL is for — required |
participant(Recipient) |
Adds one participant; call it again to add more |
participants(List<Recipient>) |
Replaces the participant list |
extra(String key, Object value) / extra(Map<String, Object>) |
Extra query params. Added after the signature, so they are not signed — display hints only, never permissions |
A chat to open, create or update. Shorthand: Chat.of("support-42") when the id
is all you have.
| Setter | What it sets |
|---|---|
id(String) |
Chat id |
title(String) |
Chat title |
type(Chat.Type) |
PRIVATE, GROUP, SUPERGROUP or CHANNEL |
create(boolean) |
Create the chat if it does not exist yet |
metadata(Map<String, Object>) |
Chat metadata; scalar values |
set(String, Object) |
Any field with no typed setter |
SUPERGROUP is a group past the 255-participant mark — it behaves less like a
conversation and more like a comment thread.
The person a signed URL, a message or a user call is about. Shorthand:
User.of("u-1"). A user with no id is anonymous: the URL builders give it
a random 40-character session so the browser stays recognisable across page
loads.
| Setter | What it sets |
|---|---|
id(String) |
User id |
name(String) |
Display name |
email(String) |
|
link(String) |
Profile link |
picture(String) |
Avatar URL |
session(String) |
Anonymous-session token; used only when no id is set |
isBot(boolean) |
Marks the user as a bot |
rights(Rights) |
What the user may do — see Rights |
set(String, Object) |
Any field with no typed setter |
Somebody other than the main user: a participant of a signed URL, a member added
when a chat is created, or the target of addParticipants. Shorthand:
Recipient.of("u-2", "Bob").
| Setter | What it sets |
|---|---|
id(String) |
User id |
name(String) |
Display name |
email(String) / link(String) / picture(String) |
The other identity fields |
isBot(boolean) |
Marks the participant as a bot |
rights(Rights) |
Per-chat rights — see Rights |
set(String, Object) |
Any field with no typed setter |
What a person may do in a chat; attach it to a User or a
Recipient. Insertion order is preserved and shows up in the
generated URL, so the same rights added in a different order give different (both
valid) query strings.
Three rights take an enum, and each has a varargs overload that appends params:
| Setter | Base values |
|---|---|
editMessages(Rights.Scope) |
NONE, MY, ANY |
deleteMessages(Rights.Scope) |
NONE, MY, ANY |
pinMessages(Rights.Pin) |
NONE, FOR_ME, FOR_EVERYONE |
The other sixteen take a boolean: sendMessages, reactMessages,
canPressButtons, sendTyping, trackPresence, sendPhotos, sendVoices,
sendAudio, sendDocuments, sendLocation, createPool, participatePool,
kickUsers, trackReadState, sendReadState, leaveChats. A right this SDK
version has no setter for goes through set(key, value).
The table above lists the base values, not the whole value space a signed
link accepts. There an enum right goes on the wire as value:param:param…, and
only the part before the first colon is validated — by this SDK, by the node SDK
and by the backend alike. The full string is what gets signed and sent, and the
chat UI reads the params back off it.
The form in use today is edit_messages = "my:extra": scope my, plus the
extra param, which is what allows editing a message's extra payload rather
than its text. Pass params as extra arguments to the enum setter:
Rights consultant = Rights.builder()
.sendMessages(true)
.editMessages(Rights.Scope.MY, "extra") // → "my:extra"
.deleteMessages(Rights.Scope.MY) // → "my", no params
.kickUsers(true)
.build();A param has to be non-empty and free of both : and whitespace. A : would
split one param into two, since only the head is validated; whitespace would stop
the chat UI matching it, because it compares each piece for exact equality. The
setter throws GetChatException instead of trimming, so a typo fails
at the call site rather than becoming a right that silently never applies.
set("edit_messages", "my:extra") still works and is unchecked, if you want to
write the raw string yourself.
Params belong to the signed link only. One Rights object serves two
destinations, and they do not validate it the same way — the REST side has its
own schema (ParticipantRights in the backend's openapi.yml):
Signed link — url(...) |
REST — createChat, addParticipants |
|
|---|---|---|
| Enum rights | value:param…; only the head is checked |
strict none / my / any — a params form is rejected |
| Boolean rights | go out as 1 / 0, and a colon tail is dropped |
go out as real JSON booleans |
A null value |
a boolean becomes 0, an explicit denial; an enum is dropped |
dropped — an override is only set here, never cleared |
So editMessages(Scope.MY, "extra") belongs on a User you pass to
url(...). The same rights on a Recipient handed to createChat
or addParticipants fail validation on the server — this SDK does not check it
for you, so the params overloads are for the signing path.
Clearing a per-chat override — sending null and having the participant fall
back to the value baked into the signed link — is what the dedicated
chat.updateParticipantRights endpoint does. This SDK does not wrap it; reach it
through requestApi.
An inline button under a message — the input counterpart of
ButtonDetails, with which it shares its enums. Shorthand:
Button.of(Button.Type.URL, "Open").
| Setter | What it sets |
|---|---|
type(Button.Type) |
URL, CALL, LOCAL or REMOTE |
label(String) |
Button text |
action(String) |
Action payload — what pressing the button does, interpreted per type |
state(Button.State) |
DEFAULT, LOADING or DISABLED |
style(Button.Style) |
PRIMARY, POSITIVE, NEGATIVE or NEUTRAL |
set(String, Object) |
Any field with no typed setter |
Filters and paging for listChats. Always set a limit — without one
the server sends back a single chat.
| Setter | What it sets |
|---|---|
page(int) / limit(int) |
Paging; page ≥ 1 and limit in 1..1000, checked at build() |
type(Chat.Type) / type(String) |
Only chats of one kind |
owner(String) |
Only chats owned by one user |
createdFrom / createdTo |
Creation-time window; takes a LocalDateTime or the wire string |
lastMessageFrom / lastMessageTo |
Last-message window, same two forms |
withOwner(boolean) |
Embed each chat's owner, which fills ChatDetails.owner() |
withOwners(boolean) |
Put the owners in a separate users map on the raw response instead |
metadata(Map<String, Object>) |
Filter by metadata key/value pairs |
set(String, Object) |
Any query key with no typed setter |
The date setters format a LocalDateTime to the strict
yyyy-MM-dd'T'HH:mm:ss the backend requires — no timezone, seconds precision.
Filters and paging for listMessages.
| Setter | What it sets |
|---|---|
page(int) / limit(int) |
Paging, the same ranges as ChatsQuery |
deleted(boolean) |
true — only deleted messages; false — only live ones |
edited(boolean) |
true — only edited messages; false — only never-edited ones |
withUsers(boolean) |
Include the users map alongside the messages; read it through Page.raw() |
extra(String key, Object value) / extra(Map<String, Object>) |
Filter by the message's extra fields (scalars) |
set(String, Object) |
Any query key with no typed setter |
Paging on its own, for listUserChats and
listParticipants, which take no other filters. It has
page(int) and limit(int), validated at build() like the other two query
builders. Leave it out and the call defaults to page 1 with 50 items.
Everything optional about sendMessage — the chat, the user and the
text are passed as arguments, so they are not here.
| Setter | What it sets |
|---|---|
participant(Recipient) |
Adds one participant; call it again to add more |
participants(List<Recipient>) |
Replaces the participant list |
extra(String key, Object value) / extra(Map<String, Object>) |
Extra fields stored with the message |
buttons(Button...) / buttons(List<Map<String, Object>>) |
The message's inline buttons — see Button |
The same for updateMessage, plus how extra is applied and
whether the message comes back.
| Setter | What it sets |
|---|---|
extra(String key, Object value) / extra(Map<String, Object>) |
The message's extra fields |
extraMode(UpdateMessageOptions.ExtraMode) |
MERGE (the default) merges into the existing extra; REPLACE overwrites it wholesale |
buttons(Button...) / buttons(List<Map<String, Object>>) |
Replaces the message's buttons |
returnResource(boolean) |
Ask for the updated message back, which fills UpdatedMessage.message() |
set(String, Object) |
A field on the message object with no typed setter, such as set("is_deleted", true); applied after the typed ones, so it can override them |
One setter, returnResource(boolean). Turning it on sends
Prefer: return=representation, so the ChatDetails you get
back is filled in instead of empty. Off by default, and passing null options
sends no header either.
returnResource(boolean), exactly as in CreateChatOptions
— it fills the ChatDetails returned by updateChat.
returnResource(boolean) — fills the UserDetails returned by
createUser.
returnResource(boolean) — fills the UserDetails returned by
updateUser.
Every list method returns a Page<T>. A Page<T> is iterable, so you can loop
over it directly with a for-each (for (ChatDetails c : chats)) or call
stream() to process its items — both go over items() in the same order:
items()— the elements as typed objects, in the server's order. Always a list, nevernull; empty when the page carries none.size()— how many items are on this page (the same asitems().size()). Not the same astotalCount()(all pages) orpageCount()(number of pages).isEmpty()—truewhen this page carries no items.stream()— a stream over the items, initems()order.currentPage()— the current page number.itemsPerPage()— the page size that was requested.pageCount()— how many pages there are in total.totalCount()— how many items match across all pages.outputCount()— how many items are on this page. The participant list does not fill this in (it stays 0) — countitems()instead there.nextPageUrl()/prevPageUrl()— the next/previous page URL, ornullwhen there is none (alsonullon the participant list).raw()— the whole response, for anything without a typed getter.
The participant and user-chats lists default to page 1 with 50 items when you
pass no PageQuery; use one to page through them.
Page<T>, the models it holds — ChatDetails,
Message, UserDetails, Participant —
and the write results SentMessages and
UpdatedMessage are read-only wrappers over the returned JSON. Each accessor reads its field when you call it, every model has a
raw() for anything without a typed accessor, and one rule covers absent data:
if the server did not send a field the accessor gives back null (or an
empty string / empty list where noted, and a JsonValue you can read with the
accessors below). Dates arrive as java.time.Instant.
A Page<T> is Iterable<T>, so you can use it directly in a for-each loop
(for (ChatDetails c : chats)) or call stream(); both walk items() in order.
| Accessor | Type | What it holds |
|---|---|---|
items() |
List<T> |
The page's elements as typed models, in the server's order; empty list, never null |
size() |
int |
How many items are on this page; 0 when empty. Not totalCount() (all pages) or pageCount() (number of pages) |
isEmpty() |
boolean |
true when this page carries no items |
stream() |
Stream<T> |
A stream over the items, in items() order |
currentPage() |
int |
The current page number; 0 when there are no results |
itemsPerPage() |
int |
The page size that was requested; 0 if missing |
pageCount() |
int |
Total number of pages; 0 if missing |
totalCount() |
int |
Total items matching across all pages; 0 if missing |
outputCount() |
int |
Items on this page; 0 if missing — the participant list leaves it 0, so count items() there |
nextPageUrl() |
String |
URL of the next page, or null on the last page (and on the participant list) |
prevPageUrl() |
String |
URL of the previous page, or null on the first page (and on the participant list) |
raw() |
JsonValue |
The whole response, for fields without a typed accessor |
| Accessor | Type | What it holds |
|---|---|---|
id() |
String |
Chat id; empty string if the server ever omits it |
type() |
Chat.Type |
Chat type, or null for a chat with no type or a type this SDK version does not recognise |
title() |
String |
Chat title, or null when unset |
createdAt() |
Instant |
When the chat was created, or null if the value cannot be read |
updatedAt() |
Instant |
When the chat last changed, or null if the value cannot be read |
lastMessageAt() |
Instant |
Time of the newest message, or null when the chat has none |
lastMessage() |
Message |
The newest message, or null unless it was requested (with with_last_message) |
ownerId() |
String |
Owner id, or null when the chat has no owner |
owner() |
UserDetails |
The chat owner, or null unless the chat carries an embedded owner (see the note) |
metadata() |
Map<String, Object> |
Chat metadata as a map of scalar values (String, Number or Boolean); empty map when absent |
raw() |
JsonValue |
The whole chat object, for fields without a typed accessor |
owner() is filled only when the server embeds the owner in the chat — reading
one chat with getChat, or a listing requested with ChatsQuery.withOwner(true)
(the singular with_owner flag). It is null otherwise. Note that
ChatsQuery.withOwners(true) is a different, plural flag: it attaches the owners
to a separate users map on the raw list response rather than to each chat, so it
does not populate owner() (reach that map through the page's raw()).
| Accessor | Type | What it holds |
|---|---|---|
id() |
String |
Message id; empty string if the server ever omits it |
seq() |
long |
Per-chat sequence number (the sort order); 0 if missing |
userId() |
String |
Sender's id; empty string if the server ever omits it |
text() |
String |
Message text, or null for a deleted message |
createdAt() |
Instant |
When the message was sent, or null if the value cannot be read |
updatedAt() |
Instant |
When the message was last edited, or null before the first edit |
isDeleted() |
boolean |
Whether the message is deleted |
isEdited() |
boolean |
Whether the message was edited |
versions() |
int |
Number of stored earlier versions |
recipientId() |
String |
Recipient id, or null when unset |
extra() |
JsonValue |
The message's extra fields; empty value when absent |
buttons() |
List<ButtonDetails> |
The message's buttons, in order; empty list when it has none |
raw() |
JsonValue |
The whole message object, for fields without a typed accessor |
| Accessor | Type | What it holds |
|---|---|---|
id() |
String |
User id; empty string if the server ever omits it |
name() |
String |
Display name; empty string if the server ever omits it |
email() |
String |
Email, or null when unset |
link() |
String |
Profile link, or null when unset |
picture() |
Avatar |
The user's avatar, or null when absent |
createdAt() |
Instant |
When the user was created, or null if the value cannot be read |
updatedAt() |
Instant |
When the user last changed, or null if the value cannot be read |
metadata() |
Map<String, Object> |
User metadata as a map of scalar values (String, Number or Boolean); empty map when absent |
raw() |
JsonValue |
The whole user object, for fields without a typed accessor |
Carries a person's identity fields but, unlike UserDetails, has no
metadata, and the participant list does not include a person's per-chat rights.
| Accessor | Type | What it holds |
|---|---|---|
id() |
String |
User id; empty string if the server ever omits it |
name() |
String |
Display name; empty string if the server ever omits it |
email() |
String |
Email, or null when unset |
link() |
String |
Profile link, or null when unset |
picture() |
Avatar |
The participant's avatar, or null when absent |
createdAt() |
Instant |
When the participant was created, or null if the value cannot be read |
updatedAt() |
Instant |
When the participant last changed, or null if the value cannot be read |
raw() |
JsonValue |
The whole participant object, for fields without a typed accessor |
Returned by UserDetails.picture() and
Participant.picture(). A person's avatar is one of two shapes: a
plain image URL, or a generated placeholder (initials on a coloured background).
isUrl() tells them apart — for a URL only url() is set; for a placeholder only
the object fields are.
| Accessor | Type | What it holds |
|---|---|---|
isUrl() |
boolean |
true for a URL avatar, false for a generated placeholder |
url() |
String |
The image URL, or null for a placeholder |
kind() |
String |
Placeholder kind (e.g. "auto"), or null for a URL |
color() |
String |
Placeholder background colour, or null for a URL or when unset |
initials() |
String |
Placeholder initials, or null for a URL or when unset |
raw() |
JsonValue |
The whole avatar value, for fields without a typed accessor |
A message's inline button, returned by Message.buttons(). The
reading counterpart of the Button input
builder, sharing its Button.Type / Button.State / Button.Style enums. An
enum accessor returns null when the value is absent or is one this SDK version
does not recognise, so an unknown future value never throws.
| Accessor | Type | What it holds |
|---|---|---|
type() |
Button.Type |
Button behaviour, or null when absent or unrecognised |
label() |
String |
Button label; empty string if the server ever omits it |
action() |
String |
Button action payload, or null when unset |
state() |
Button.State |
Interaction state, or null when absent or unrecognised |
style() |
Button.Style |
Colour treatment, or null when absent or unrecognised |
raw() |
JsonValue |
The whole button object, for fields without a typed accessor |
Returned by sendMessage.
| Accessor | Type | What it holds |
|---|---|---|
messageIds() |
List<String> |
Ids of the messages just created, in send order; empty list when none |
raw() |
JsonValue |
The whole response, for fields without a typed accessor |
Returned by updateMessage.
| Accessor | Type | What it holds |
|---|---|---|
isUpdated() |
boolean |
Whether the edit actually changed the message |
message() |
Message |
The updated message, or null unless you asked for it with returnResource(true) |
raw() |
JsonValue |
The whole response, for fields without a typed accessor |
requestApi and every model's raw()
return a JsonValue: the SDK's own
read-only wrapper over JSON. It is safe to walk without null checks — a step
that does not exist gives back an empty value instead of throwing, so a chain of
lookups never fails on missing data.
JsonValue chat = client.getChat("support-42").raw();
// Read a field with a fallback for when it is missing:
String title = chat.get("title").asString("(untitled)");
// A path that does not exist falls through to the default, with no error:
String missing = chat.get("nope").get("deeper").asString("fallback");
// Reach deep in one step with a JSON Pointer:
String plan = chat.at("/metadata/plan").asString("");
// Iterate an array; values() is empty (never null) for a non-array:
for (JsonValue hook : chat.get("webhooks").values()) {
System.out.println(hook.get("url").asString(""));
}get(field)/get(index)step into an object or array;at("/a/b/0")follows a JSON Pointer. A pointer that does not resolve gives an empty value; a malformed pointer (one without a leading/) throwsGetChatException.- The
as*(default)readers —asString(def),asInt(def),asLong(def),asDouble(def),asBoolean(def)— return the default when the value is missing or cannot be converted, and never throw. The no-argumentasString()is strict: it throws when the value is not a present string. values()iterates an array,fieldNames()lists an object's keys, andtoMap()/toList()convert to plain Java collections. The predicatesisMissing(),isNull(),isObject(),isArray(),isString(),isNumber(),isBoolean(),has(field)andsize()inspect the value.
For an endpoint without a typed method, describe the call with an ApiRequest
and send it through requestApi, which returns a
JsonValue:
// GET a URL query string:
JsonValue hooks = client.requestApi(ApiRequest.get("chats/support-42/webhooks")
.query("with_disabled", 1)
.build());
// PUT with a JSON body, a URL query param and a custom header:
client.requestApi(ApiRequest.put("chats/support-42/webhook")
.body(Map.of("url", "https://example.com/hook"))
.query("dry_run", 1)
.header("X-Request-Id", "abc-123")
.build());Start from ApiRequest.get, post, put or delete with the path (the part
after /api/{version}/), then add query, body, header, version or
control. For GET and DELETE, query is the URL query string; for POST
and PUT, body is the JSON payload and query the URL query string. Setting
a body on a GET or DELETE is rejected at build() rather than dropped.
The typed input builders (ChatsQuery,
MessagesQuery, Chat, User,
Recipient, Rights, Button) each have a
set(key, value) method for a field that has no typed setter, so you can send a
new field without waiting for the SDK to add one.
Every error the SDK throws is unchecked and shares GetChatException as its
base, so a single catch (GetChatException e) still covers all of them. Below
that root there are now separate types for the different kinds of failure:
GetChatException the base; thrown on its own only for a mistake in your code
├── GetChatApiException the server replied with an error status
├── GetChatTransportException the request never completed (could not connect, DNS
│ │ failure, or the connection broke)
│ └── GetChatTimeoutException an attempt ran past its timeout
├── GetChatSerializationException a JSON body could not be written or read back
└── GetChatInterruptedException the thread was interrupted while the call was in progress
| Exception | When it is thrown |
|---|---|
GetChatApiException |
The server replied with an error status; carries status(), body(), rawBody(), method(), uri() and requestId() |
GetChatTransportException |
The request never reached a reply — a failed connection, a DNS failure, or a broken connection |
GetChatTimeoutException |
An attempt ran past its timeout (a kind of transport failure, so it extends GetChatTransportException) |
GetChatSerializationException |
A JSON body could not be written (the request) or read (the response) |
GetChatInterruptedException |
The calling thread was interrupted mid-request |
GetChatException |
Thrown on its own only for a mistake in your code: bad input, bad configuration, or misuse of a JsonValue |
Which to catch, in one line each:
- transport (including timeout) — safe to try again.
- api — look at
status()to decide what to do. - serialization — the server sent something that is not the agreed shape.
- the base
GetChatExceptionon its own — fix the calling code.
Every input check throws the base GetChatException (for example, a missing
chat id, empty message text, or a builder missing a required field at
build()). A plain NullPointerException is reserved for passing null where a
required argument — such as the ApiRequest given to requestApi — is expected.
GetChatApiException describes both the failed request and the server's reply:
status()— the HTTP status code.body()— the parsed error payload as aJsonValuewhen the response was JSON, or an empty value (body().isMissing()istrue) when it was not.rawBody()— the response text exactly as received.method()— the HTTP method of the request, such as"GET".uri()— the request URI (ajava.net.URI).requestId()— the server's request id, taken from theX-Request-Idresponse header. It isnullwhen the server did not send one; include it when you report a problem to support.
try {
client.getChat("does-not-exist");
} catch (GetChatApiException e) {
int status = e.status(); // e.g. 404
String code = e.body().get("error").asString(""); // safe to read on any body
String id = e.requestId(); // may be null
}Defaults: 30 seconds per attempt, 2 retries, and a 200 ms base backoff with jitter between attempts.
timeout and retryDelay take a java.time.Duration; Duration.ZERO turns
off the per-attempt timeout. retries is a plain int (up to 10).
import java.time.Duration;
GetChatClient client = GetChatClient.builder()
.apiUrl("...")
.apiToken("...")
.options(RequestOptions.builder().timeout(Duration.ofSeconds(5)).retries(3).build())
.build();
// Override per call — a field left unset keeps the instance default:
client.listChats(
ChatsQuery.builder().limit(20).build(),
RequestControl.builder().timeout(Duration.ofSeconds(1)).retries(0).build());Set these for the whole client through RequestOptions on the builder. To
override them for a single call, listChats takes a RequestControl, and any
ApiRequest carries one through .control(...).
Which calls retry, in plain terms:
- Calls that only read (
GET,DELETE) retry on network errors and on5xx/429responses. - Calls that change data (
POST,PUT) retry only when the SDK is certain the request never reached the server (a connection that failed before anything was sent), plus on a429rate-limit reply. So a write is never sent twice.
A Retry-After header is respected, capped at 30 seconds. Because a timeout
counts as a network error, a hung read can take up to about
(retries + 1) × timeout plus backoff before it gives up.
Both entry points are immutable and safe to share — build one of each and reuse
them rather than creating one per request. GetChatUrlSigner holds no network
resources at all. GetChatClient makes its own HttpClient; supply your own
through GetChatClient.builder().httpClient(...) for a proxy or custom TLS.
Most applications keep one long-lived client and never close it. GetChatClient
is AutoCloseable for the short-lived case:
try (GetChatClient client = GetChatClient.builder()
.apiUrl("https://chat.example.com")
.apiToken("your-api-token")
.build()) {
client.getChat("support-42");
}close() releases only an HttpClient the SDK created itself; a client you
supplied stays open, since its lifecycle is yours. It can be called more than
once safely and never throws. HttpClient became AutoCloseable only in
JDK 21, so on JDK 17–20 close() does nothing and the garbage collector
reclaims the client instead.
The value types (User, Chat, Button, the query builders, …) implement
equals, hashCode and toString. The two entry points are not value types,
but both give a safe-to-log toString: GetChatUrlSigner.toString() hides
secret and GetChatClient.toString() hides apiToken (each prints as ***).
Building from source, the code formatter, and the rules around changing the signing code are in CONTRIBUTING.md.
MIT — see LICENSE.