On this page
- Custom Desk
- How it works
- The conversation lifecycle
- What the business configures in the Console
- The API Gydr calls (you host it)
- Transport, signature and idempotency
- Conversation context and the signed-in visitor
- POST /conversations
- POST /sessions/{sessionId}/messages
- POST /sessions/{sessionId}/handoff/end
- POST /sessions/{sessionId}/end
- POST /tickets
- GET /availability?widgetId={widgetId}
- GET /connection-test
- Ordering, mirroring and privacy
- Events you send to Gydr
- Transport and signature
- Envelope, idempotency and errors
- agent.message
- conversation.close
- conversation.release
- conversation.takeover
- Rate limits
- Credits
- Rules for your agent UI
- Go-live checklist
Custom Desk#
Custom Desk lets you connect an agent inbox you built or run yourself, without waiting for a Gydr connector. Gydr publishes one contract, you implement it, and a business connects the two in the Console. Nothing is built per partner on Gydr's side.
When a visitor needs a person, the bot steps aside and a human agent takes the conversation. That moment is a handoff. Your inbox is where that agent works, and the agent's replies reach the visitor wherever they are: website chat, WhatsApp, Messenger, Instagram or Telegram.
How it works#
It has the shape of the professional desks Gydr connects to (Zendesk, LiveChat, Freshchat): Gydr calls your REST API, and you send signed webhooks to Gydr. One shared secret signs both directions with the same scheme.
- Gydr to you (REST). Gydr calls the endpoints you host under one base URL: open a conversation, post a message, end it, ask whether an agent is online, file a ticket, and a connection test.
- You to Gydr (webhooks). You POST signed events to your integration's Gydr events URL: an agent replied, the agent closed the conversation, handed it back to the bot, or took it over.
- The business connects the two in the Console. It pastes your base URL and a shared signing secret, chooses which options to allow, and gives you the Gydr events URL.
The visitor may not be on a website
source whenever Gydr knows it: always when a conversation opens and on mirrored messages.The conversation lifecycle#
- With mirroring on, Gydr calls
POST /sessions/{sessionId}/messages(phasemirror) while the bot answers. - The visitor asks for a person. If the business turned the availability check on, Gydr first calls
GET /availability. - Gydr calls
POST /conversationswith the transcript. You answer with your conversation ID. - Each visitor message arrives as
POST /sessions/{sessionId}/messages(phasehandoff). Your agent replies with theagent.messageevent. - The conversation ends one of three ways: your agent sends
conversation.close(the session ends, or the bot carries on if the business chose that), your agent sendsconversation.release(the bot resumes), or Gydr callsPOST /sessions/{sessionId}/end(the visitor left or the session timed out).
While a handoff is active the bot never replies. Every visitor message goes to you.
What the business configures in the Console#
In Console → Integrations → Custom Desk the business sets:
- Setting
- Base URL
- Meaning
- Where your API for Gydr starts, for example
https://inbox.example.com/gydr. Must behttps, publicly reachable, with no query string. Gydr appends the paths below.
- Setting
- Signing secret
- Meaning
- At least 32 characters. Shared between the business and your inbox. It signs every request in both directions.
- Setting
- Mirror conversations
- Meaning
- Off by default. On: you receive every message, including those the bot handles alone. Off: you receive conversations only from the moment of handoff.
- Setting
- Allow takeover
- Meaning
- Lets your agents take over a conversation the bot is handling. Needs mirroring on.
- Setting
- Allow release
- Meaning
- Lets your agents hand a conversation back to the bot.
- Setting
- Link signed-in visitors
- Meaning
- When the business signs visitors in on its site or app, a signed-in visitor's conversations and tickets carry their User ID and the sign-in source it came from, for you to link to your own customer record. An optional sign-in source limits it to visitors that source signed in. Never for a visitor who opted out of conversation history.
- Setting
- Visitor experience: modes
- Meaning
- Live Support, Ticket, or both. Gydr calls only the operations of the modes that are on: with Ticket off you never receive
POST /tickets; with Live Support off you receive onlyPOST /ticketsand the connection test.
- Setting
- Visitor experience: request a ticket while an agent is free
- Meaning
- Only meaningful with both modes on. Off (the default): the visitor is offered a ticket only when no agent is free. On: the visitor chooses chat or ticket every time.
- Setting
- Visitor experience: signed-in visitors only
- Meaning
- Only a visitor the business has signed in can reach your inbox. A guest is never offered it, so you never receive a request for one.
- Setting
- Visitor experience: when your agent closes
- Meaning
- End the session (the default), or hand the conversation back to the bot. With the hand-back you still receive
conversation.closefrom your agent, and the bot carries on with the visitor.
- Setting
- Visitor experience: visitor may leave the chat
- Meaning
- Lets the visitor end a live chat themselves. On by default. Gydr then calls
POST /sessions/{sessionId}/end.
- Setting
- Visitor experience: also close the ticket when the visitor leaves
- Meaning
- Only with the visitor allowed to leave. On by default: you receive the ordinary end request, to close it as finished. Off: the ticket is kept and the end request carries
ticketKept: true.
| Setting | Meaning |
|---|---|
| Base URL | Where your API for Gydr starts, for example https://inbox.example.com/gydr. Must be https, publicly reachable, with no query string. Gydr appends the paths below. |
| Signing secret | At least 32 characters. Shared between the business and your inbox. It signs every request in both directions. |
| Mirror conversations | Off by default. On: you receive every message, including those the bot handles alone. Off: you receive conversations only from the moment of handoff. |
| Allow takeover | Lets your agents take over a conversation the bot is handling. Needs mirroring on. |
| Allow release | Lets your agents hand a conversation back to the bot. |
| Link signed-in visitors | When the business signs visitors in on its site or app, a signed-in visitor's conversations and tickets carry their User ID and the sign-in source it came from, for you to link to your own customer record. An optional sign-in source limits it to visitors that source signed in. Never for a visitor who opted out of conversation history. |
| Visitor experience: modes | Live Support, Ticket, or both. Gydr calls only the operations of the modes that are on: with Ticket off you never receive POST /tickets; with Live Support off you receive only POST /tickets and the connection test. |
| Visitor experience: request a ticket while an agent is free | Only meaningful with both modes on. Off (the default): the visitor is offered a ticket only when no agent is free. On: the visitor chooses chat or ticket every time. |
| Visitor experience: signed-in visitors only | Only a visitor the business has signed in can reach your inbox. A guest is never offered it, so you never receive a request for one. |
| Visitor experience: when your agent closes | End the session (the default), or hand the conversation back to the bot. With the hand-back you still receive conversation.close from your agent, and the bot carries on with the visitor. |
| Visitor experience: visitor may leave the chat | Lets the visitor end a live chat themselves. On by default. Gydr then calls POST /sessions/{sessionId}/end. |
| Visitor experience: also close the ticket when the visitor leaves | Only with the visitor allowed to leave. On by default: you receive the ordinary end request, to close it as finished. Off: the ticket is kept and the end request carries ticketKept: true. |
After saving, the Console shows the integration's Gydr events URL:
https://integration.api.gydr.ai/custom-desk/integrations/{integrationId}/eventsThe business gives you that URL together with the signing secret. One base URL, one events URL and one secret identify one integration. A business that connects two inboxes gets two of each. Custom Desk works on every Gydr plan.
When the business saves the integration, Gydr calls your connection test. If it does not answer 200 or 204 after checking the signature, the save fails, so a wrong URL or secret is caught at setup rather than when the first visitor asks for help.
The API Gydr calls (you host it)#
Every path below is relative to the base URL. The operations are grouped by mode, as on Zendesk: a business with only Ticket on is only ever sent POST /tickets (and the connection test); one with only Live Support on is never sent it.
- Mode
- Live Support
- Request
POST /conversations- Success answer
200/201{ "conversationId"? }- Timeout
- 12 s
- Mode
- Live Support
- Request
POST /sessions/{sessionId}/messages- Success answer
200,201,202or204- Timeout
- 5 s
- Mode
- Live Support
- Request
POST /sessions/{sessionId}/handoff/end- Success answer
200,201,202or204- Timeout
- 10 s
- Mode
- Live Support
- Request
POST /sessions/{sessionId}/end- Success answer
200,201,202or204- Timeout
- 10 s
- Mode
- Live Support
- Request
GET /availability?widgetId={widgetId}- Success answer
200{ "available": true | false }- Timeout
- 3 s
- Mode
- Ticket
- Request
POST /tickets- Success answer
200/201{ "ticketId", "ticketUrl"? }- Timeout
- 12 s
- Mode
- Either
- Request
GET /connection-test- Success answer
200or204- Timeout
- 10 s
| Mode | Request | Success answer | Timeout |
|---|---|---|---|
| Live Support | POST /conversations | 200/201 { "conversationId"? } | 12 s |
| Live Support | POST /sessions/{sessionId}/messages | 200, 201, 202 or 204 | 5 s |
| Live Support | POST /sessions/{sessionId}/handoff/end | 200, 201, 202 or 204 | 10 s |
| Live Support | POST /sessions/{sessionId}/end | 200, 201, 202 or 204 | 10 s |
| Live Support | GET /availability?widgetId={widgetId} | 200 { "available": true | false } | 3 s |
| Ticket | POST /tickets | 200/201 { "ticketId", "ticketUrl"? } | 12 s |
| Either | GET /connection-test | 200 or 204 | 10 s |
Transport, signature and idempotency#
Content-Type: application/jsonon everyPOST. The twoGETs have no body. Gydr never follows a redirect.
- Header
X-Gydr-Signature- Value
- Lower-case hex HMAC-SHA256 of
{timestamp}.{rawBody}, keyed with the signing secret.rawBodyis empty on aGET.
- Header
X-Gydr-Timestamp- Value
- Unix epoch seconds at signing time.
- Header
X-Gydr-Operation- Value
- The operation name:
create_conversation, post_message, end_handoff, end_session, check_availability, file_ticket, connection_test.
- Header
Idempotency-Key- Value
- Stable across Gydr's retries of the same operation.
| Header | Value |
|---|---|
X-Gydr-Signature | Lower-case hex HMAC-SHA256 of {timestamp}.{rawBody}, keyed with the signing secret. rawBody is empty on a GET. |
X-Gydr-Timestamp | Unix epoch seconds at signing time. |
X-Gydr-Operation | The operation name: create_conversation, post_message, end_handoff, end_session, check_availability, file_ticket, connection_test. |
Idempotency-Key | Stable across Gydr's retries of the same operation. |
401 to a bad signature. Every /sessions/{sessionId}/… body also carries sessionId, and the signature covers the body, not the path: refuse (400) a request whose body sessionId is not the one in its path.Test vector. Secret test_secret_0123456789abcdef0123456789abcdef, timestamp 1760774400, and this exact end-request body:
{"sessionId":"sess_abc123","widgetId":"w_1","reason":"inactivity","hadHandoff":false}The signature is 33490e3eb515650a251006176a35bab2006af7a8f4b246d453a19f9ddd5f5683.
import { createHmac, timingSafeEqual } from "node:crypto"
export function verifyGydr(rawBody, headers, secret) {
const ts = headers["x-gydr-timestamp"]
const sig = headers["x-gydr-signature"] ?? ""
if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false
// rawBody is "" on Gydr's GET requests.
const expected = createHmac("sha256", secret)
.update(ts + "." + (rawBody ?? ""))
.digest("hex")
return (
sig.length === expected.length &&
timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
)
}- Delivery is at least once; de-duplicate on Idempotency-Key. Gydr sends the same key every time it retries the same operation: a conversation open is keyed per handoff (a new handoff in the same session gets a new key), a message per message, a ticket per filing. Store the first answer against the key and return it unchanged to a repeat.
- Stay available during handoffs. If a message in the
handoffphase fails, the visitor sees that their message could not be passed on. A handoff your side never answers ends at the business's handoff timeout. - Answer quickly (timeouts in the table above). Store first, process after.
- Return real status codes. Only the success statuses above count.
401/403tell Gydr your side refused its credentials, another4xxthat the request was wrong,429to slow down, and a5xxor no answer that you are unavailable.
Conversation context and the signed-in visitor#
Every conversation request carries the same context, with keys left out when unknown:
{
"sessionId": "sess_mnhaeMbe2z2_1YWCAcne3",
"widgetId": "6650a1b2c3d4e5f6a7b8c9d0",
"widgetName": "Acme Support",
"source": "whatsapp",
"sourceId": "chv_3kq9XcA1…",
"visitor": {
"visitorId": "vis_…",
"userId": "cust_42",
"signInSourceId": "idp_7Qm2AbCd",
"visitorDetails": {
"name": "Aina R.",
"email": "aina@example.com",
"phone": "+60123456789"
}
}
}- Field
sessionId- Notes
- The Gydr conversation. Every event you send back uses it.
- Field
source- Notes
widget(website chat),whatsapp,fb_messenger,instagram,telegram. This is an open set: display an unknown value, do not reject it.
- Field
sourceId- Notes
- For a messaging app: a private ID we make for the visitor (chv_…, the same for the same person on this chatbot). With your desk's "Share the visitor's messaging-app user ID" switch on, the app's own ID instead (for example a WhatsApp number). Absent for website chat.
- Field
visitor- Notes
visitorId(Gydr's id for the visitor);userIdandsignInSourceIdonly for a linked signed-in visitor (below);visitorDetailswith only what the visitor has shared (name,email,phone).
| Field | Notes |
|---|---|
sessionId | The Gydr conversation. Every event you send back uses it. |
source | widget (website chat), whatsapp, fb_messenger, instagram, telegram. This is an open set: display an unknown value, do not reject it. |
sourceId | For a messaging app: a private ID we make for the visitor (chv_…, the same for the same person on this chatbot). With your desk's "Share the visitor's messaging-app user ID" switch on, the app's own ID instead (for example a WhatsApp number). Absent for website chat. |
visitor | visitorId (Gydr's id for the visitor); userId and signInSourceId only for a linked signed-in visitor (below); visitorDetails with only what the visitor has shared (name, email, phone). |
- There when known: opening a conversation and a mirror-phase message always carry
source(andsourceIdfor a messaging app), pluswidgetNameand whatevervisitordetails the visitor has shared. A handoff-phase message and the end requests may leave any of them out. - Key everything on sessionId. Keep the channel and visitor details from the first request, and never treat a missing field later as a change.
- The signed-in visitor is a link.
userIdappears only when the business turned on Link signed-in visitors and its own sign-in proved who the visitor is. It is the business's own ID for that customer, andsignInSourceIdnames the sign-in it came from. You must resolve it to your own customer record (create the customer on first sight, then reuse it), exactly as Zendesk matches itsexternal_id. A guest carries nouserId. Never treatvisitorDetails.emailas proof of who someone is.
POST /conversations#
A visitor asked for a person, or the bot decided one is needed. From this moment the bot is silent for this session. The body is the context plus:
- Field
reason- Notes
- Short reason, when known.
- Field
transcript- Notes
[{ "author": "visitor" | "assistant" | "agent", "text": "…" }]: the recent conversation, oldest first.assistantlines were written by the AI. Never present them as written by a human.agentlines are earlier replies from a human agent. Treat the author as an open set.
| Field | Notes |
|---|---|
reason | Short reason, when known. |
transcript | [{ "author": "visitor" | "assistant" | "agent", "text": "…" }]: the recent conversation, oldest first. assistant lines were written by the AI. Never present them as written by a human. agent lines are earlier replies from a human agent. Treat the author as an open set. |
Answer 200 or 201 with {"conversationId": "<your id>"}. conversationId is optional; if you leave it out, Gydr uses sessionId. Make this idempotent on Idempotency-Key, never on sessionId: a retry of the same handoff repeats its key and must not open a second conversation, while a new handoff in the same session (your agent closed or released the first and the visitor asked again) arrives with a new key and must open a new conversation.
POST /sessions/{sessionId}/messages#
Addressed by session because a mirrored message arrives before any conversation exists. The body is the context plus:
- Field
conversationId- Notes
- Your id from POST /conversations, during a handoff. Absent on a mirrored message.
- Field
messageId- Notes
- Unique within the session. Use it to de-duplicate as well.
- Field
phase- Notes
mirror(the bot is handling the chat) orhandoff(your agent owns it).
- Field
author- Notes
- Who wrote it, in the same words as a transcript line:
visitor,assistantoragent.assistantcovers an AI reply and a Gydr system note; system notes start with[Gydr].agentis a person on the business side: a reply the business sent from its own messaging app.
- Field
direction- Notes
- Deprecated since 2026-10-25, use
author. Still sent unchanged:visitor, orbusiness(every author that is notvisitor).
- Field
text- Notes
- Plain text. It can be empty when the message has attachments.
- Field
sentAt- Notes
- When it was sent (ISO 8601).
- Field
sequence- Notes
- Handoff phase only: increases for each visitor message in the session. Order by sequence, because calls can arrive out of order.
- Field
attachments- Notes
[{ "name", "mimeType", "url" }].urlis an HTTPS link valid for up to 7 days: download or display it promptly and do not rely on it after expiry.
| Field | Notes |
|---|---|
conversationId | Your id from POST /conversations, during a handoff. Absent on a mirrored message. |
messageId | Unique within the session. Use it to de-duplicate as well. |
phase | mirror (the bot is handling the chat) or handoff (your agent owns it). |
author | Who wrote it, in the same words as a transcript line: visitor, assistant or agent. assistant covers an AI reply and a Gydr system note; system notes start with [Gydr]. agent is a person on the business side: a reply the business sent from its own messaging app. |
direction | Deprecated since 2026-10-25, use author. Still sent unchanged: visitor, or business (every author that is not visitor). |
text | Plain text. It can be empty when the message has attachments. |
sentAt | When it was sent (ISO 8601). |
sequence | Handoff phase only: increases for each visitor message in the session. Order by sequence, because calls can arrive out of order. |
attachments | [{ "name", "mimeType", "url" }]. url is an HTTPS link valid for up to 7 days: download or display it promptly and do not rely on it after expiry. |
An empty text with attachments is a real message. Do not reject it.
POST /sessions/{sessionId}/handoff/end#
The handoff ended on Gydr's side but the conversation continues with the bot. Today the only cause is reason: "timeout": your side was silent past the business's handoff timeout. The body is the context, conversationId when known, and reason. Close or archive the conversation in your inbox. Your agent can no longer reply to it; agent.message answers 409 no_active_handoff.
POST /sessions/{sessionId}/end#
The Gydr conversation is over. The body is the context, conversationId when a handoff was in progress, and:
- Field
reason- Notes
ended_by_business_or_visitor,inactivity,assistant_ended(the AI closed a finished conversation), orhandoff_aborted(the session ended while a handoff was being set up). Treat it as an open set.
- Field
hadHandoff- Notes
trueif your agent held the conversation at the time.
- Field
ticketKept- Notes
- Present only as
true. The visitor left a live chat the business let them leave, and the business chose to keep the ticket open: keep your ticket open instead of closing it as a finished conversation.
| Field | Notes |
|---|---|
reason | ended_by_business_or_visitor, inactivity, assistant_ended (the AI closed a finished conversation), or handoff_aborted (the session ended while a handoff was being set up). Treat it as an open set. |
hadHandoff | true if your agent held the conversation at the time. |
ticketKept | Present only as true. The visitor left a live chat the business let them leave, and the business chose to keep the ticket open: keep your ticket open instead of closing it as a finished conversation. |
Close the conversation (unless ticketKept), and be idempotent: closing something already closed must succeed quietly. You only receive this for conversations you knew about, meaning ones you held or ones that were mirrored to you. After your own conversation.close, no end request follows.
POST /tickets#
Ticket mode only. A visitor chose to leave a ticket instead of talking to a person now. No conversation is opened and the bot keeps answering: create one ticket in your helpdesk and tell Gydr its ID.
- Field
requester- Notes
{ "email", "name"?, "phone"?, "userId"?, "signInSourceId"?, "verifiedEmail"? }.emailis where your team replies (the address the visitor typed).userIdandsignInSourceIdare the signed-in visitor's link: file the ticket under your customer for that ID.verifiedEmailappears only besideuserId, when the business's own sign-in verified the visitor's email; it is never something the visitor typed and never sent from a messaging channel.
- Field
subject- Notes
- A short title.
- Field
description- Notes
- The summary the visitor read and confirmed: plain text with paragraph breaks.
- Field
descriptionOrigin- Notes
ai_confirmed(the bot's draft, unchanged),ai_edited(the visitor changed it) orvisitor_written. Treat it as an open set.
- Field
transcript- Notes
[{ "author": "visitor" | "assistant" | "agent", "text", "sentAt" }], oldest first. Put it in an internal note. Never presentassistantlines as written by a human.
- Field
language- Notes
- The visitor's language, for example en.
- Field
sessionId,visitorId,widgetId,widgetName,channel- Notes
- The conversation the ticket came from.
| Field | Notes |
|---|---|
requester | { "email", "name"?, "phone"?, "userId"?, "signInSourceId"?, "verifiedEmail"? }. email is where your team replies (the address the visitor typed). userId and signInSourceId are the signed-in visitor's link: file the ticket under your customer for that ID. verifiedEmail appears only beside userId, when the business's own sign-in verified the visitor's email; it is never something the visitor typed and never sent from a messaging channel. |
subject | A short title. |
description | The summary the visitor read and confirmed: plain text with paragraph breaks. |
descriptionOrigin | ai_confirmed (the bot's draft, unchanged), ai_edited (the visitor changed it) or visitor_written. Treat it as an open set. |
transcript | [{ "author": "visitor" | "assistant" | "agent", "text", "sentAt" }], oldest first. Put it in an internal note. Never present assistant lines as written by a human. |
language | The visitor's language, for example en. |
sessionId, visitorId, widgetId, widgetName, channel | The conversation the ticket came from. |
Answer 200 or 201 with {"ticketId": "<your id>", "ticketUrl": "<https link>"}. ticketId is required, 1 to 64 characters from A-Z a-z 0-9 . _ - only, and Gydr shows it to the visitor. Any other id is never stored or shown and counts as a failure, but because you answered with success Gydr treats the ticket as possibly created and will not file another. ticketUrl is optional and kept only when it is an https link (the business sees it, the visitor does not). Make this idempotent on Idempotency-Key: Gydr sends the same key again after a timeout, and a retry must not create a second ticket, so answer it with the same ticketId. A 401, 403 or other 4xx tells Gydr the ticket was not created. A timeout, a 5xx, another 2xx or a success without a valid ticketId tells it the ticket may exist, so it will not file another.
GET /availability?widgetId={widgetId}#
"Is at least one agent online right now?" Asked only when the business turned the availability check on. widgetId is left out when Gydr does not know it.
- Answer
200 {"available": true}or{"available": false}. The value must be a JSON boolean. - Anything else, including a slow answer (over 3 s), an error, or a non-boolean, counts as offline. The bot then apologises and offers the business's other contact options instead of a handoff.
- Gydr caches your answer for 30 seconds.
GET /connection-test#
Called when the business saves the integration, whatever modes it turned on. Answer 200 or 204 once the signature (over the empty body) verifies.
Ordering, mirroring and privacy#
- Gydr sends one session's requests in the order they happen, but separate calls can still arrive out of order. Use
sequenceduring a handoff, andsentAtotherwise. - With mirroring on, you receive bot conversations as they happen. Visitors who opted out of conversation history are never mirrored and never carry a
userId. You will only hear about them if they ask for a person. - You receive personal data (names, contact details, message content) so an agent can help. The business is the data controller, so handle it under your agreement with them.
Events you send to Gydr#
Transport and signature#
POSTto the Gydr events URL from the Console withContent-Type: application/json. The body is at most 64 KB.- Sign exactly as Gydr signs its requests to you:
X-Gydr-Signatureis the lower-case hex HMAC-SHA256 of{timestamp}.{rawBody}with the signing secret,X-Gydr-Timestampis Unix seconds, andX-Gydr-Eventis the body'sevent. - Compute a new timestamp and signature on every retry. Gydr rejects a timestamp more than 300 seconds off.
Test vector. Secret test_secret_0123456789abcdef0123456789abcdef, timestamp 1760774400, and this exact body:
{"eventId":"desk-evt-0001","event":"agent.message","sessionId":"sess_abc123","timestamp":"2025-10-18T08:00:00.000Z","data":{"message":"Hi!","sender":{"name":"Alice"}}}The signature is 41b77e2f14cd09f422d0568133c398554aa4c884bd3488d991c2399b43a1f707.
import { createHmac } from "node:crypto"
export async function sendToGydr(eventsUrl, secret, event) {
const body = JSON.stringify(event)
const ts = String(Math.floor(Date.now() / 1000))
const sig = createHmac("sha256", secret)
.update(ts + "." + body)
.digest("hex")
return fetch(eventsUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Gydr-Signature": sig,
"X-Gydr-Timestamp": ts,
"X-Gydr-Event": event.event,
},
body,
})
}Envelope, idempotency and errors#
{
"eventId": "desk-evt-0001",
"event": "agent.message",
"sessionId": "sess_abc123",
"timestamp": "2026-10-18T07:20:00.000Z",
"data": {}
}eventId(required, 1 to 128 characters) is your unique ID for this event. Reuse it when you retry. Gydr returns the original result instead of acting twice. While the first attempt is still running, a retry gets409 in_progress. Gydr remembers eacheventIdfor 24 hours. An answer in the5xxrange,409 conflictor409 in_progressis never remembered, so a retry with the sameeventIdruns again. Every other answer, success or refusal, is final for thateventId. Every event (a reply, a close, a release, a takeover) is acted on, and charged, at most once pereventId. In the rare case Gydr cannot return the original answer, a retried reply or release answers200 {"duplicate": true}, a retried close answers200 {"alreadyEnded": true}, and a retried takeover answers200 {"status":"agent","ticketId"}while your desk still holds the conversation, otherwise200 {"duplicate": true}.sessionIdis the Gydr session from the events you received.
Errors have the shape { "error": { "code": "…", "message": "…" } }. These can come back for any event:
- Status
401- Meaning
- Bad or missing signature, or a stale timestamp.
- Status
404 session_not_found- Meaning
- No such Gydr session.
- Status
409 session_ended- Meaning
- The conversation was closed or has ended.
- Status
403 not_authorized- Meaning
- This integration does not hold the session.
- Status
400 unsupported_event- Meaning
- Unknown event name.
- Status
429 rate_limited- Meaning
- Over a limit; see Rate limits below.
- Status
500 internal_error- Meaning
- Gydr's fault. Retry with the same eventId.
| Status | Meaning |
|---|---|
401 | Bad or missing signature, or a stale timestamp. |
404 session_not_found | No such Gydr session. |
409 session_ended | The conversation was closed or has ended. |
403 not_authorized | This integration does not hold the session. |
400 unsupported_event | Unknown event name. |
429 rate_limited | Over a limit; see Rate limits below. |
500 internal_error | Gydr's fault. Retry with the same eventId. |
agent.message#
Send an agent reply.
"data": { "message": "Hi Aina, sorry about that! Could you send a photo?", "sender": { "name": "Alice" }, "conversationId": "your-id-123" }message: 1 to 10,000 characters of plain text.sender.name: shown to the visitor as the agent's name on website chat.conversationId(required): your own id for the conversation, the one you answeredPOST /conversationswith (if you answered none, Gydr uses thesessionId, so send that). The reply reaches the visitor only if that conversation is the one now being handled; after a release and a second handoff on the same session, a late reply from the first conversation answers409 no_active_handoff. Leave it out and the answer is400 conversation_id_requiredand nothing is sent; resend with a neweventId.
- Response
200 {"status":"delivered","messageId","messageSequence"}- Meaning
- The visitor has it.
- Response
200 {"status":"held", …}- Meaning
- The visitor is on a messaging app whose reply window has closed (typically 24 hours after their last message). Gydr holds the reply, delivers it when they next write, and posts a
[Gydr]note into the conversation saying so. Nothing is lost.
- Response
200 {"status":"saved", …}- Meaning
- Stored and shown in the visitor's chat history, but not pushed live right now.
- Response
200 {"duplicate": true}- Meaning
- This eventId was already delivered, and Gydr could not return the original answer. Nothing was sent again and nothing was charged again.
- Response
400 {"limit","limitUnit"}- Meaning
- Too long for this visitor's channel (for example WhatsApp about 4,096 characters, Messenger 2,000, Instagram 1,000 bytes). Gydr never splits or trims a human's words. Shorten it and resend.
- Response
400 conversation_id_required- Meaning
- data.conversationId is missing. Nothing was looked up or sent. Resend with a new eventId.
- Response
409 no_active_handoff- Meaning
- The conversation is live but no handoff is active (it was released or timed out). A closed or ended conversation answers 409 session_ended.
| Response | Meaning |
|---|---|
200 {"status":"delivered","messageId","messageSequence"} | The visitor has it. |
200 {"status":"held", …} | The visitor is on a messaging app whose reply window has closed (typically 24 hours after their last message). Gydr holds the reply, delivers it when they next write, and posts a [Gydr] note into the conversation saying so. Nothing is lost. |
200 {"status":"saved", …} | Stored and shown in the visitor's chat history, but not pushed live right now. |
200 {"duplicate": true} | This eventId was already delivered, and Gydr could not return the original answer. Nothing was sent again and nothing was charged again. |
400 {"limit","limitUnit"} | Too long for this visitor's channel (for example WhatsApp about 4,096 characters, Messenger 2,000, Instagram 1,000 bytes). Gydr never splits or trims a human's words. Shorten it and resend. |
400 conversation_id_required | data.conversationId is missing. Nothing was looked up or sent. Resend with a new eventId. |
409 no_active_handoff | The conversation is live but no handoff is active (it was released or timed out). A closed or ended conversation answers 409 session_ended. |
Gydr does not echo your agent's messages back to you as events.
conversation.close#
Close the conversation.
"data": { "reason": "Resolved: replacement sent", "conversationId": "your-id-123" }conversationId is required, as on agent.message: without it the answer is 400 conversation_id_required and nothing is closed, and a close that names an earlier conversation is answered 409 no_active_handoff and closes nothing.
By default this ends the Gydr session; under "Let the chatbot carry on" it hands the conversation back instead. With the business's default ("End the conversation"), the visitor is told your team has closed the conversation, and a new message from them starts a fresh conversation. If the business chose "Let the chatbot carry on", your close ends the handoff, the session stays open, the visitor is told they are back with the assistant, and the bot answers their next message. Which applies is fixed when the handoff starts. Either way, the answer is 200 {"status":"handoff_ended"}, or 200 {"alreadyEnded": true} if the conversation was already over (both sides often close at once, and this is safe). If the conversation is still live but no handoff is active (the bot is handling it, or your agent already released it), nothing is closed and the answer is 409 no_active_handoff. No end request follows your own close.
conversation.release#
Hand the conversation back to the bot.
"data": { "reason": "Order question answered" }The handoff ends, the conversation continues, and the bot answers the visitor's next message. The visitor is told they are back with the assistant. Mirroring, if on, continues. Answers: 200 {"status":"released"}, 403 release_disabled, 409 no_active_handoff (the conversation is live but no handoff is active: already released, or timed out), and 409 session_ended (the conversation was closed or has ended).
conversation.takeover#
Take over a conversation the bot is handling.
"data": { "conversationId": "your-id-123", "agent": { "name": "Alice" } }Your agent takes a conversation the bot is handling (you know about it from mirrored messages). The bot stops at once, and a reply it was still writing is discarded. In rare cases a reply already being sent at that exact moment may still arrive. The visitor is told they are connected to a person. From here it behaves exactly like a handoff your desk accepted.
- Response
200 {"status":"agent","ticketId"}- Meaning
- Done. A repeat is also 200.
- Response
403 takeover_disabled- Meaning
- The business did not allow takeover.
- Response
403 not_attached- Meaning
- This integration is not connected to the chatbot that owns the session.
- Response
409 not_mirrored- Meaning
- Mirroring is off, or the visitor opted out of history.
- Response
409 held_by_other_desk- Meaning
- Another desk already holds this conversation.
- Response
409 conflict- Meaning
- The conversation changed while Gydr handled the request. Check its state, then retry with the same eventId: a conflict is never remembered, so the retry runs again.
- Response
409 session_ended- Meaning
- The conversation is over.
- Response
422 unsupported_source- Meaning
- The conversation is driven by the business's own backend and cannot be taken over.
| Response | Meaning |
|---|---|
200 {"status":"agent","ticketId"} | Done. A repeat is also 200. |
403 takeover_disabled | The business did not allow takeover. |
403 not_attached | This integration is not connected to the chatbot that owns the session. |
409 not_mirrored | Mirroring is off, or the visitor opted out of history. |
409 held_by_other_desk | Another desk already holds this conversation. |
409 conflict | The conversation changed while Gydr handled the request. Check its state, then retry with the same eventId: a conflict is never remembered, so the retry runs again. |
409 session_ended | The conversation is over. |
422 unsupported_source | The conversation is driven by the business's own backend and cannot be taken over. |
Rate limits#
Every plan gets the same limits.
- Scope
- Per integration, all events
- Limit
- 50 per second, 1,500 per minute
- Scope
- Per session, agent.message
- Limit
- 3 per second, 60 per minute
- Scope
- Per session, close + release + takeover
- Limit
- 10 per minute
| Scope | Limit |
|---|---|
| Per integration, all events | 50 per second, 1,500 per minute |
| Per session, agent.message | 3 per second, 60 per minute |
| Per session, close + release + takeover | 10 per minute |
Over a limit you get 429 rate_limited with a Retry-After header in seconds. Wait that long and retry with the same eventId.
Credits#
Everything Gydr sends to your desk is free. Actions your agent takes on a conversation use credits. The amounts live on How Credits Work.
Rules for your agent UI#
- Label
agenttranscript lines andauthor: agentmessages as a human agent, andassistanttranscript lines andauthor: assistantmessages as bot or business, never as a human agent. - Show
source, because "this customer is on WhatsApp" changes how agents write. On messaging channels, warn when a draft is close to the channel's length limit. - Treat
heldas "sent, waiting for the customer". It is not a failure.
Go-live checklist#
Test each of these on a Gydr sandbox before going live.
GET /connection-testverifies the signature over the empty body; a wrong secret gets401- The same
Idempotency-Keydelivered twice produces one record and the same answer - A
/sessions/{sessionId}/…request whose bodysessionIddiffers from the path is refused POST /conversationsopens one conversation, seeded with the transcript; a second handoff in the same session (newIdempotency-Key) opens a new one- A signed-in visitor's
userIdlands on your existing customer record (Link signed-in visitors on); a guest carries none - Visitor messages during a handoff arrive in
sequenceorder, including attachment-only ones - An agent reply reaches website chat and WhatsApp; an over-limit reply shows the limit
- A WhatsApp reply after the window closed comes back
held conversation.closecloses and the visitor is told; sending it twice is safeconversation.release: the bot answers the next visitor messageconversation.takeoverwhile the bot is mid-reply: the bot's reply never reaches the visitor- Your outgoing events are signed (test vector above); a retried event with the same
eventIdacts once - A
429is retried afterRetry-Afterwith the sameeventId POST /ticketscreates one ticket and answers{ "ticketId" }; the sameIdempotency-Keytwice returns the same ticket (only if Ticket is turned on)POST …/handoff/end(timeout) andPOST …/endclose the conversation in your inbox;ticketKept: truekeeps it openGET /availabilityanswers within 3 seconds with a JSON boolean
