Webhooks
InMyWords tells your system when something happens. This page covers the events, how they are delivered and retried, how to check a notice came from us, and how to test a webhook.
This page is for the developer whose system receives notices from InMyWords.
It covers the four events, adding a webhook, what each delivery carries, the retry timetable, checking a signature in PHP, Python or Node, and sending a test.
Events
| Event | Sent when | Scope the connection must hold |
|---|---|---|
conversation.ended | A conversation in your organisation ends, once it has at least one turn. | conversations:read |
summary.ready | A conversation's summary is written or rewritten. | summaries:read |
document.translated | A document sent through the API is translated. | documents:read |
user.joined | A person invited through the API accepts the invitation. | users:read |
A webhook cannot be subscribed to an event whose scope the connection does not hold. The answer is 422 validation_failed, naming the scope. If the connection loses the scope later, the event is no longer sent to it.
Adding and removing webhooks
Calls go to https://app.inmywords.chat/api/integrations/v1/, and every request carries Authorization: Bearer imw_<64 hex characters>.
Managing webhooks needs the scope webhooks:manage. A manager can also add and remove webhooks on the connection's page in InMyWords.
| Method | Path | Does |
|---|---|---|
| GET | /webhooks | Lists the connection's webhooks. |
| POST | /webhooks | Adds a webhook, and answers its secret once. |
| GET | /webhooks/{id} | Reads one webhook. |
| DELETE | /webhooks/{id} | Removes a webhook. |
| POST | /webhooks/{id}/test | Sends the webhook a signed webhook.test event now, and answers how its address responded. |
Adding a webhook:
POST /api/integrations/v1/webhooks
Authorization: Bearer imw_3f9a...c41e
Content-Type: application/json
{
"url": "https://crm.example.org/hooks/inmywords",
"events": ["conversation.ended", "summary.ready"]
}
201 Created
{
"data": {
"id": "17",
"url": "https://crm.example.org/hooks/inmywords",
"events": ["conversation.ended", "summary.ready"],
"secret": "whsec_6b1d0e...9a2f",
"created_at": "2026-10-03T14:02:11Z",
"switched_off_at": null
}
}
- Keep the secret when it is shown. It appears in this answer only, so store it before you close the response. If it is lost, remove the webhook and add it again.
- A repeat does not show it again. The same request repeated with the same
Idempotency-Keyanswers the same webhook with"secret": nulland"secret_shown": false. InMyWords keeps no copy of the secret for replays. switched_off_atis the time the webhook was switched off after failing for three days, or null.- Up to 10 webhooks. A connection holds at most 10.
The address you give
- It must be
https, on port 443 only. An address on any other port is refused. - It must be public. A private or internal address is refused: loopback, link-local, private ranges, and names that resolve to them.
- It is checked again at every delivery. The name is looked up once for each delivery, and every address it answers is checked. A name that now answers a private or internal address is refused at that delivery, and nothing is sent.
- Redirects are not followed. A 3xx answer counts as a failure.
What arrives
Each delivery is a POST with a JSON body. The body carries ids and the event only, never the words of a conversation, summary or document. Your system reads the content back through the API with its own key.
| Event | data carries |
|---|---|
conversation.ended | conversation_id |
summary.ready | conversation_id |
document.translated | document_id, case_id, language |
user.joined | user_id |
For example:
POST /hooks/inmywords HTTP/1.1
Host: crm.example.org
Content-Type: application/json
InMyWords-Signature: t=1791036131,v1=5a1f0c7e9b2d4a8e3c6f1b0d9e7a2c4f8b6d1e3a5c7f9b0d2e4a6c8f1b3d5e7a
{"id":"80412","event":"conversation.ended","occurred_at":"2026-10-03T14:02:11Z","data":{"conversation_id":"5f0c2e9a7b3d41c8a6e1f4b2d9c07a3e"}}
Answer with a 2xx within 10 seconds and the delivery counts as a success. Anything else, or no answer in 10 seconds, is a failure.
A delivery can arrive more than once, and deliveries can arrive out of order. Use id to discard a repeat, and occurred_at for order.
Retries
A failed delivery is tried again on this timetable.
| Attempt | After the previous failure |
|---|---|
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 6 hours |
| 7 | 12 hours |
| 8 | 24 hours |
After the eighth attempt the delivery is not retried. A webhook that has failed for three days is switched off, and your organisation's integration managers are emailed.
Every attempt is logged with its status and duration. A manager can see each one, and resend it, on the connection's page.
A delivery already queued is not sent once its connection is revoked, its webhook is switched off or removed, your organisation's integrations are switched off, its integrations data policy is withdrawn, or the connection no longer holds the event's scope. It is marked given up, with the reason, before any attempt.
Delivered and given-up deliveries are deleted after 90 days.
Checking a notice came from us
Every delivery carries the header InMyWords-Signature: t=<unix seconds>,v1=<signature>. The signature is the hex HMAC-SHA256 of <t>.<raw body>, keyed with the webhook's secret.
- Use the raw body. Take it exactly as received, before any JSON parsing.
- Check the time. Refuse the delivery if
tis more than five minutes from your clock. - Compare safely. Compare signatures in constant time.
PHP:
<?php
function inmywords_verify(string $header, string $rawBody, string $secret): bool
{
$parts = [];
foreach (explode(",", $header) as $pair) {
[$k, $v] = array_pad(explode("=", trim($pair), 2), 2, "");
$parts[$k] = $v;
}
if (!isset($parts["t"], $parts["v1"]) || !ctype_digit($parts["t"])) return false;
if (abs(time() - (int)$parts["t"]) > 300) return false;
$expected = hash_hmac("sha256", $parts["t"] . "." . $rawBody, $secret);
return hash_equals($expected, $parts["v1"]);
}
$raw = file_get_contents("php://input");
if (!inmywords_verify($_SERVER["HTTP_INMYWORDS_SIGNATURE"] ?? "", $raw, getenv("INMYWORDS_WEBHOOK_SECRET"))) {
http_response_code(400);
exit;
}
http_response_code(204);
Python:
import hashlib
import hmac
import time
def inmywords_verify(header: str, raw_body: bytes, secret: str) -> bool:
parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
t, v1 = parts.get("t", ""), parts.get("v1", "")
if not t.isdigit() or not v1:
return False
if abs(time.time() - int(t)) > 300:
return False
expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
Node:
const crypto = require("crypto");
function inmywordsVerify(header, rawBody, secret) {
const parts = Object.fromEntries(
header.split(",").map((p) => p.trim().split("=", 2)).filter((p) => p.length === 2)
);
const t = parts.t || "";
const v1 = parts.v1 || "";
if (!/^\d+$/.test(t) || !v1) return false;
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = crypto.createHmac("sha256", secret).update(t + "." + rawBody).digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(v1, "utf8");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
In Node, rawBody must be the body as received, for example from express.raw({ type: "application/json" }), not a re-serialised object.
Testing a webhook
POST /webhooks/{id}/test, or Send test beside the webhook on the connection's Webhooks page, sends one webhook.test event at once, signed like any other delivery.
- The body has the usual shape. Its
dataholdswebhook_idandconnection_id. - The answer says how it went. It is
{"data": {"delivery_id", "delivered", "status", "error", "duration_ms"}}: whether the address answered 2xx, its status, and what went wrong if it did not. - A test is tried once. It is not retried, and a failed test does not count towards switching the webhook off.
- A webhook switched off can be tested, so you can check its address before switching it back on.
- One test every five seconds per webhook. A test sooner answers 429
rate_limited. - Nobody subscribes to
webhook.test. It is sent only when asked for.