Skip to content
Features

Custom Desk

Connect your own agent inbox: the signed events Gydr sends, the events you send back, and a go-live checklist.

On this page

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

A conversation can come from WhatsApp or Instagram as easily as from web chat. Your agent does not need to care, because Gydr delivers each reply to wherever the visitor is. That is why a conversation's requests carry a source whenever Gydr knows it: always when a conversation opens and on mirrored messages.

The conversation lifecycle#

  1. With mirroring on, Gydr calls POST /sessions/{sessionId}/messages (phase mirror) while the bot answers.
  2. The visitor asks for a person. If the business turned the availability check on, Gydr first calls GET /availability.
  3. Gydr calls POST /conversations with the transcript. You answer with your conversation ID.
  4. Each visitor message arrives as POST /sessions/{sessionId}/messages (phase handoff). Your agent replies with the agent.message event.
  5. 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 sends conversation.release (the bot resumes), or Gydr calls POST /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 be https, 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 only POST /tickets and 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.close from 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.

After saving, the Console shows the integration's Gydr events URL:

https://integration.api.gydr.ai/custom-desk/integrations/{integrationId}/events

The 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, 202 or 204
Timeout
5 s
Mode
Live Support
Request
POST /sessions/{sessionId}/handoff/end
Success answer
200, 201, 202 or 204
Timeout
10 s
Mode
Live Support
Request
POST /sessions/{sessionId}/end
Success answer
200, 201, 202 or 204
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
200 or 204
Timeout
10 s

Transport, signature and idempotency#

  • Content-Type: application/json on every POST. The two GETs 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. rawBody is empty on a GET.
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.
Verify before parsing. Recompute the signature over the raw body bytes, compare in constant time, and reject when the timestamp is more than 300 seconds from your clock. Answer 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.

Verify a Gydr request
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 handoff phase 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/403 tell Gydr your side refused its credentials, another 4xx that the request was wrong, 429 to slow down, and a 5xx or 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); 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 (and sourceId for a messaging app), plus widgetName and whatever visitor details 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. userId appears 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, and signInSourceId names 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 its external_id. A guest carries no userId. Never treat visitorDetails.email as 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. 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) or handoff (your agent owns it).
Field
author
Notes
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.
Field
direction
Notes
Deprecated since 2026-10-25, use author. Still sent unchanged: visitor, or business (every author that is not visitor).
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" }]. 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), or handoff_aborted (the session ended while a handoff was being set up). Treat it as an open set.
Field
hadHandoff
Notes
true if 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.

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"? }. 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.
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) or visitor_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 present assistant lines 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.

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 sequence during a handoff, and sentAt otherwise.
  • 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#

  • POST to the Gydr events URL from the Console with Content-Type: application/json. The body is at most 64 KB.
  • Sign exactly as Gydr signs its requests to you: X-Gydr-Signature is the lower-case hex HMAC-SHA256 of {timestamp}.{rawBody} with the signing secret, X-Gydr-Timestamp is Unix seconds, and X-Gydr-Event is the body's event.
  • 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.

Send an event to Gydr
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 gets 409 in_progress. Gydr remembers each eventId for 24 hours. An answer in the 5xx range, 409 conflict or 409 in_progress is never remembered, so a retry with the same eventId runs again. Every other answer, success or refusal, is final for that eventId. Every event (a reply, a close, a release, a takeover) is acted on, and charged, at most once per eventId. In the rare case Gydr cannot return the original answer, a retried reply or release answers 200 {"duplicate": true}, a retried close answers 200 {"alreadyEnded": true}, and a retried takeover answers 200 {"status":"agent","ticketId"} while your desk still holds the conversation, otherwise 200 {"duplicate": true}.
  • sessionId is 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.

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 answered POST /conversations with (if you answered none, Gydr uses the sessionId, 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 answers 409 no_active_handoff. Leave it out and the answer is 400 conversation_id_required and nothing is sent; resend with a new eventId.
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.

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.

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

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 agent transcript lines and author: agent messages as a human agent, and assistant transcript lines and author: assistant messages 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 held as "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-test verifies the signature over the empty body; a wrong secret gets 401
  • The same Idempotency-Key delivered twice produces one record and the same answer
  • A /sessions/{sessionId}/… request whose body sessionId differs from the path is refused
  • POST /conversations opens one conversation, seeded with the transcript; a second handoff in the same session (new Idempotency-Key) opens a new one
  • A signed-in visitor's userId lands on your existing customer record (Link signed-in visitors on); a guest carries none
  • Visitor messages during a handoff arrive in sequence order, 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.close closes and the visitor is told; sending it twice is safe
  • conversation.release: the bot answers the next visitor message
  • conversation.takeover while 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 eventId acts once
  • A 429 is retried after Retry-After with the same eventId
  • POST /tickets creates one ticket and answers { "ticketId" }; the same Idempotency-Key twice returns the same ticket (only if Ticket is turned on)
  • POST …/handoff/end (timeout) and POST …/end close the conversation in your inbox; ticketKept: true keeps it open
  • GET /availability answers within 3 seconds with a JSON boolean