Threadwire by TriTech

← API reference

Webhooks and events

An event is something that happened on one of your instances: someone wrote, a message you sent was delivered, a broadcast paused. We post each one to your endpoint as JSON, signed.

Receive your first event

  1. Set the URL. In your panel, open Developers → Webhooks (admins only) and press "Set webhook URL". It must be a public https:// address.
  2. Copy the signing secret. It starts with whsec_, and is shown when you save the URL. To see it again, press "Signing secret → Show signing secret" on the same page.
  3. Run an endpoint where your https:// address reaches it. This one is whole: it reads the raw body, checks the signature, answers 200 at once, and drops repeats. It listens on port 3000.
// Node.js 18 or later, no packages. Run: THREADWIRE_WEBHOOK_SECRET=whsec_… node endpoint.mjs
import crypto from 'node:crypto';
import http from 'node:http';

const key = Buffer.from(process.env.THREADWIRE_WEBHOOK_SECRET.replace(/^whsec_/, ''), 'base64');
const handled = new Set(); // in production: your database or cache

http.createServer((req, res) => {
  const chunks = [];
  req.on('data', (chunk) => chunks.push(chunk));
  req.on('end', () => {
    // The raw body, exactly as it arrived: the signature covers these bytes.
    const body = Buffer.concat(chunks);
    const id = req.headers['webhook-id'];
    const timestamp = req.headers['webhook-timestamp'];

    // 1. Verify: sent in the last 5 minutes, and signed with your secret.
    const expected = Buffer.from('v1,' + crypto.createHmac('sha256', key)
      .update(`${id}.${timestamp}.`).update(body).digest('base64'));
    const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300;
    const signed = (req.headers['webhook-signature'] ?? '').split(' ').some((signature) => {
      const given = Buffer.from(signature);
      return given.length === expected.length && crypto.timingSafeEqual(given, expected);
    });

    if (!fresh || !signed) {
      res.writeHead(401).end();
      return;
    }

    // 2. Answer 200 at once, before any slow work.
    res.writeHead(200).end();

    // 3. Drop repeats: an event sent again keeps its webhook-id.
    if (handled.has(id)) return;
    handled.add(id);

    const event = JSON.parse(body);
    console.log(event.type);
    if (event.type === 'message.received') {
      console.log(`${event.data.phone}: ${event.data.body}`);
    }
  });
}).listen(3000);
  1. Press "Send test event" on the Webhooks page. A webhook.test event goes to your endpoint, and the list on that page shows how it answered. Then write to your instance from another phone: a message.received event arrives.

No public URL yet? Read the same events with GET /v1/events: see polling.

Every event has this shape:

{
  "id": "evt_01K…",
  "type": "message.received",
  "created_at": "2026-10-01T10:00:00+00:00",
  "data": { "…": "the message, as in GET /v1/messages/{id}" }
}

Answer with any 2xx within 15 seconds. That is all you need to start; the rest of this page is the detail.

Events

Event When data
message.received Someone wrote to one of your instances the message
message.sent_from_phone Someone typed a message on the linked phone itself, not through the API the message, with origin phone
message.status A message you sent was sent, delivered, read, or failed (or cancelled before it went: failed, with that error and error_code) the message
message.edited A message was edited on WhatsApp, by them, on the phone or through the API the message, with its new body and edited_at
message.deleted A message was deleted for everyone the message, with deleted_at and no content
message.poll_vote Someone voted on a poll you sent, changed their vote, or took it back the poll, with its results and votes, and vote: phone and selected_options (empty when taken back)
message.reaction Someone reacted to a message with an emoji, changed it, or took it back the message, with its reactions, and reacted: phone (who; their private WhatsApp id when WhatsApp hides the number), emoji (null when taken back) and at. In a group, only when you also receive group.message
group.message Someone wrote in a group one of your instances is in (or your team did, on the phone) the message: chat_id is the group, participant who wrote it
group.update Your instance joined or left a group, people came, went or became admins, or its name or settings changed instance_id, chat_id, change (joined or left for your instance; join, leave, promote or demote for participants; updated), participants (each an id, the person's WhatsApp id rather than a phone, and a role), subject, description, at. A field with nothing to say is left out
call.received Someone called one of your instances on WhatsApp id, instance_id, chat_id, phone, is_video, at, and rejected (true when the instance declines calls)
call.rejected A call was declined (by you, the instance's setting, or the phone) id, instance_id, chat_id, phone, is_video, at
instance.status An instance was linked, lost its link, or changed state id, external_ref, status, phone
otp.completed An OTP was completed: the code arrived from the person (link), or was checked right (code) id, instance_id, channel, reference, phone (who verified; null if WhatsApp hid it), verified_at
otp.failed An OTP failed: its code could not go out (with the reason), or too many wrong codes id, instance_id, channel, reference, phone, reason, failed_at
broadcast.paused A broadcast paused: by you, or by itself to protect the instance (Broadcasts) id, instance_id, name, status, reason (code and text), progress, started_at, completed_at; never its text or anyone's number
broadcast.completed Everyone in a broadcast has had their message the same as broadcast.paused, with reason null
webhook.test You pressed "Send test event" in your panel message

You choose which events you receive in your panel (Developers → Webhooks → Events, admins only). It is one list for your whole account: it applies to every webhook URL, and to GET /v1/events. By default you get all of them except group.message and group.update, which you tick to receive; a test event always goes.

In a message: contact_name is the name the person shows on WhatsApp (null when they have none). written_at is when WhatsApp says a received message, or one typed on the phone, was written; created_at is when we stored it, up to 10 seconds later for one typed on the phone. written_at is null on messages you send, which have sent_at. origin is api (you sent it), auto (an auto-reply you set up in your panel), ai (your AI agent answered), panel (your team replied on the panel's Chats page), phone (typed on the phone), otp (an OTP code, its digits masked, or a link's confirmation), broadcast (one person's message of a broadcast) or contact (they wrote it). Auto-replies, AI answers and panel replies send message.status events like any message you send. Received stickers, polls, locations and contact cards arrive with type sticker, poll, location or contact (the first card, when they share several; on a received card name or phone may be missing, and phone is the number as written on the card when it has no WhatsApp number, so it may be local).

Files in received messages are at data.media.url, a link that works for 24 hours. A message with a file is sent once we have the file, so it can arrive after events about later messages, or about itself (a receipt, an edit, a deletion). Download what you want to keep: messages and files are deleted after 30 days. A file over 100 MB is not kept: its message arrives with media null and error_code file_too_large.

Check that an event came from us

Events are signed per the Standard Webhooks spec, so any of its libraries can verify them in one call with your whsec_… secret. Each request has the headers webhook-id, webhook-timestamp and webhook-signature: one or more space-separated values, each v1, followed by a base64 HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{body}, keyed with the base64-decoded part of your secret after whsec_. Accept the event if any one of them matches. Use the raw body exactly as it arrived, before any JSON parsing:

// PHP: $id, $timestamp and $signatures are the webhook-id, webhook-timestamp and
// webhook-signature headers ($_SERVER['HTTP_WEBHOOK_ID'] ?? '' and so on); $body
// is file_get_contents('php://input').
function threadwireEventIsGenuine(string $id, string $timestamp, string $signatures, string $body, string $secret): bool
{
    // Reject old or future timestamps: a replayed request is not a new event.
    if (abs(time() - (int) $timestamp) > 300) {
        return false;
    }

    $key = base64_decode(substr($secret, strlen('whsec_')));
    $expected = 'v1,'.base64_encode(hash_hmac('sha256', "{$id}.{$timestamp}.{$body}", $key, true));

    foreach (explode(' ', $signatures) as $signature) {
        if (hash_equals($expected, $signature)) {
            return true;
        }
    }

    return false;
}
// Node.js: headers as lower-case names, rawBody as a string (in Express, express.text({ type: '*/*' })).
import crypto from 'node:crypto';

function isGenuine(headers, rawBody, secret) {
  // Reject old or future timestamps: a replayed request is not a new event.
  if (Math.abs(Date.now() / 1000 - Number(headers['webhook-timestamp'])) > 300) return false;

  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const expected = Buffer.from('v1,' + crypto.createHmac('sha256', key)
    .update(`${headers['webhook-id']}.${headers['webhook-timestamp']}.${rawBody}`)
    .digest('base64'));

  return (headers['webhook-signature'] ?? '').split(' ').some((signature) => {
    const given = Buffer.from(signature);
    return given.length === expected.length && crypto.timingSafeEqual(given, expected);
  });
}
# Python: headers as lower-case names, raw_body as bytes (in Flask, request.get_data()).
import base64, hashlib, hmac, time

def is_genuine(headers: dict, raw_body: bytes, secret: str) -> bool:
    # Reject old or future timestamps: a replayed request is not a new event.
    if abs(time.time() - int(headers['webhook-timestamp'])) > 300:
        return False

    key = base64.b64decode(secret.removeprefix('whsec_'))
    signed = f"{headers['webhook-id']}.{headers['webhook-timestamp']}.".encode() + raw_body
    expected = 'v1,' + base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()

    return any(hmac.compare_digest(expected.encode(), signature.encode()) for signature in headers.get('webhook-signature', '').split(' '))

Each try is signed at the time it is sent, so a retry hours later still passes the 5-minute check.

After you make a new signing secret, events carry two signatures for 24 hours, one per secret, separated by a space as the spec allows, so your system keeps verifying while you switch it to the new secret.

To have your endpoint also check a key of your own (an API gateway, for example), set an authentication header on your Webhooks page ("Authentication header"): its name and value go with every event to that URL, beside the signature. An instance with its own URL has its own header, set on its page. Moving a URL to another host, or removing it, removes its header.

Delivery

  • Every request names itself: User-Agent: Threadwire-Webhooks/1 (+https://threadwire.net/docs/guides/webhooks). Use it to find our requests in your logs or to let them through a firewall, never as proof: only the signature shows an event came from us.
  • Delivered means any 2xx within 15 seconds. Anything else is a failed try: another status, no answer in time, or a redirect. Redirects are not followed: give us the final URL.
  • Retries. An event is tried 10 times in all: again after 10 seconds, then after 1 minute, 5 minutes, 30 minutes, 1 hour, 2 hours, 4 hours, 6 hours and 12 hours. That is a little over 25 hours from the first try to the last. An event that used all its tries shows as "Failed" on your Webhooks page, where "Send again" sends it once more. A test event is tried once.
  • Two at a time. We post at most two events at once to each webhook URL; the rest wait their turn.
  • Deduplicate on webhook-id. An event sent again (a retry, or by request) keeps its id. Events can arrive out of order: use created_at and the message's own times.
  • If your endpoint accepts nothing for 24 hours, webhooks to it pause, and your admins get an email. New events are kept (for up to 30 days), and all of them are sent, oldest first, as soon as it answers again. It is tried every hour, and "Try now" on the Webhooks page tries it at once.

One URL per instance

An instance can have a webhook URL of its own ("Webhook" on its Settings tab in your panel): its events then go there instead of to your account's URL, signed with that instance's own whsec_… secret. The events you chose apply to it as to the account. Its authentication header is its own (set beside its URL), and your account's header is never sent to it, so a key meant for one system never reaches another. Each URL is retried, paused and resumed on its own, so one system being down never holds back another's events. Remove it, and the instance's events, any kept ones too, go to your account's URL again.

By API

A system that makes its instances by API can set their webhooks the same way, with a full-access key for the whole account. /v1/webhook is your account's webhook; /v1/instances/{id}/webhook is an instance's own, with the same five requests. A key made for one instance can read that instance's webhook, and change none.

Request What it does
GET /v1/webhook Its url, the events sent, the name of its auth_header, whether it is paused and why (paused_reason), and how many events are kept for it (events_kept). Never the secret, nor the header's value
PATCH /v1/webhook Send only what changes: url (null stops sending), events (null for the default set; on the account's webhook only) and auth_header (its name and value, or null to remove it). At most 6 changes a minute per account
POST /v1/webhook/secret A new signing secret, in the answer once. The old one keeps signing until previous_valid_until, 24 hours later; until then a second replacement answers 409
POST /v1/webhook/test Sends a webhook.test event, at most one a minute
POST /v1/webhook/try Tries a paused webhook at once, at most once a minute: if it answers, the events kept go out
curl -X PATCH https://threadwire.net/api/v1/webhook \
  -H "Authorization: Bearer YOUR_API_KEY" -H "Content-Type: application/json" \
  -d '{"url": "https://your-system.example/webhooks/whatsapp", "events": ["message.received", "message.status"]}'

When you get the secret. A secret is in an answer only when it has just been made: by the PATCH that sets a url on a webhook that had no secret yet, and by POST …/secret. Store it then: no later request returns it, though your panel still shows it under "Signing secret". An account we set up for you already has a secret, so its first url answers without one: replace it once to learn it. Send an Idempotency-Key with POST …/secret: if the answer is lost, a retry with the same key answers 200 without the secret (and the header Idempotent-Replayed: true) instead of replacing it again, which would stop the secret your system still checks with. The rules are the Webhooks page's: an https address on the public internet, and a header that is not one of ours. A request is taken whole or refused whole. Each change shows in your activity log with the key that made it.

Polling instead: GET /v1/events

Can your system not take webhooks (it sits behind a firewall, or n8n or Make has no public address), or did it miss some while it was down? Fetch the same events with GET /v1/events. They are kept 30 days whether or not you set a webhook URL: the events you chose under Developers → Webhooks → Events. Each is exactly what a webhook carries (id, type, created_at, data), so one handler serves both. Your panel's Event log (under Developers) shows the same events, newest first, with what each held.

{
  "data": [{ "id": "evt_01K…", "type": "message.received", "created_at": "…", "data": { "…": "the message" } }],
  "next_after": "evt_01K…",
  "has_more": false
}
  • The loop: start without after (you get the oldest kept first), then always pass the last answer's next_after as after. Ask again at once while has_more is true; otherwise wait a few seconds (5 to 60 is plenty). Keep next_after somewhere that survives a restart, so you carry on where you stopped. Nothing is removed when you read it: it simply comes after the cursor no more.
  • Oldest first. An event shows here a second or two after it happens, so a cursor never skips one that was still on its way.
  • Deduplicate on id. An event your webhook also got has the same id, and a retry of a poll may give you one again; skip ids you have handled.
  • Narrow it with limit (1 to 100, 50 by default), types[] (such as types[]=message.received) and instance_id; next_after still moves past the events they leave out. A key made for one instance sees only that instance's events.
  • Limits: each poll is an API request like any other (it counts toward a plan's monthly allowance), and a key may poll at most 60 times a minute (then 429 with Retry-After). If the event in after is no longer kept (older than 30 days, or its instance was removed), you carry on from the next event after it in time.

Webhooks are still the quickest way to hear about an event; use both if you like, polling to catch up on anything your endpoint missed.