Integrations

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

EventSent whenScope the connection must hold
conversation.endedA conversation in your organisation ends, once it has at least one turn.conversations:read
summary.readyA conversation's summary is written or rewritten.summaries:read
document.translatedA document sent through the API is translated.documents:read
user.joinedA 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.

MethodPathDoes
GET/webhooksLists the connection's webhooks.
POST/webhooksAdds a webhook, and answers its secret once.
GET/webhooks/{id}Reads one webhook.
DELETE/webhooks/{id}Removes a webhook.
POST/webhooks/{id}/testSends 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
  }
}
  1. 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.
  2. A repeat does not show it again. The same request repeated with the same Idempotency-Key answers the same webhook with "secret": null and "secret_shown": false. InMyWords keeps no copy of the secret for replays.
  3. switched_off_at is the time the webhook was switched off after failing for three days, or null.
  4. Up to 10 webhooks. A connection holds at most 10.

The address you give

  1. It must be https, on port 443 only. An address on any other port is refused.
  2. It must be public. A private or internal address is refused: loopback, link-local, private ranges, and names that resolve to them.
  3. 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.
  4. 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.

Eventdata carries
conversation.endedconversation_id
summary.readyconversation_id
document.translateddocument_id, case_id, language
user.joineduser_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.

AttemptAfter the previous failure
21 minute
35 minutes
430 minutes
52 hours
66 hours
712 hours
824 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.

  1. Use the raw body. Take it exactly as received, before any JSON parsing.
  2. Check the time. Refuse the delivery if t is more than five minutes from your clock.
  3. 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.

  1. The body has the usual shape. Its data holds webhook_id and connection_id.
  2. 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.
  3. A test is tried once. It is not retried, and a failed test does not count towards switching the webhook off.
  4. A webhook switched off can be tested, so you can check its address before switching it back on.
  5. One test every five seconds per webhook. A test sooner answers 429 rate_limited.
  6. Nobody subscribes to webhook.test. It is sent only when asked for.