← All posts
Tutorial

How to Process Message Reactions in a Unified Webhook

To process a reaction, subscribe to message.reaction, verify the webhook signature against the raw request body, and use data.message.target_message_id as the message being reacted to. The reaction itself is in data.event.reaction; an empty string means it was removed. Deduplicate deliveries with the top-level event ID before updating your stored state.

Key takeaways

  • data.message.id identifies the reaction event’s message record, not the original message.
  • data.message.target_message_id identifies the message that received the reaction.
  • data.event.reaction contains the emoji; "" means removal.
  • Verify X-Device-Signature before parsing JSON, then process retries idempotently.
  • Provider support varies, so check the provider event matrix before making reactions a required workflow input.

Understand the message.reaction payload

UnifyPort exposes reactions as the standard message.reaction event. This is useful when an inbox treats 👍 as acknowledgement, routes 👎 to review, or simply mirrors reaction state beside the original message. Those are workflow choices; the event itself reports what changed.

The official standard webhook event reference shows this message.reaction payload:

{
  "id": "evt_2f9c1a4b7e",
  "type": "message.reaction",
  "provider": "whatsapp",
  "account_id": "acc_8c21d0",
  "occurred_at": "2026-06-08T12:35:40Z",
  "data": {
    "conversation": {
      "id": "8613912345678",
      "type": "user"
    },
    "sender": {
      "id": "8613912345678",
      "type": "user",
      "name": "Jordan Lee"
    },
    "message": {
      "id": "wamid.HBgZ",
      "target_message_id": "wamid.HBgM"
    },
    "event": {
      "kind": "message_reaction",
      "reaction": "👍"
    }
  }
}

Two IDs are deliberately separate. data.message.id identifies the reaction itself, while data.message.target_message_id points to the original message. Do not attach the emoji to wamid.HBgZ; attach it to wamid.HBgM for this sample.

The top-level id serves a different purpose again: it identifies the standard webhook event and is the correct key for ordinary delivery deduplication.

Model reaction state, not just reaction events

An append-only event log is valuable for audit and debugging, but a user interface usually needs current state. A practical state key is the combination of:

  • provider
  • account_id
  • data.conversation.id
  • data.message.target_message_id
  • data.sender.id

Store the emoji as the current value. When data.event.reaction is an empty string, delete that sender’s reaction for the target message. This avoids treating removal as a new blank reaction.

Keep the received top-level event in a durable inbox before changing the projection. A unique constraint on the top-level id prevents a retry from applying the same mutation twice. If your event collector currently subscribes only to inbound messages, the event-filter tutorial explains how to add message.reaction without switching every endpoint to the wildcard.

Verify and apply the event in Node.js

The receiver below verifies the documented HMAC-SHA256 contract and maintains an in-memory projection for demonstration. Replace the Map with a transactional database table in production.

import crypto from 'node:crypto';
import express from 'express';

const app = express();
const secret = process.env.WEBHOOK_SIGNING_SECRET;
const seenEvents = new Set();
const reactionState = new Map();

if (!secret) throw new Error('WEBHOOK_SIGNING_SECRET is required');

app.post(
  '/webhooks/unifyport',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const timestamp = req.get('X-Device-Timestamp') ?? '';
    const signature = req.get('X-Device-Signature') ?? '';

    const expected = crypto
      .createHmac('sha256', secret)
      .update(timestamp + '.')
      .update(req.body)
      .digest();

    const validHex = /^[0-9a-f]{64}$/i.test(signature);
    const supplied = validHex ? Buffer.from(signature, 'hex') : Buffer.alloc(0);
    const valid =
      supplied.length === expected.length &&
      crypto.timingSafeEqual(supplied, expected);

    if (!valid) return res.sendStatus(401);

    const event = JSON.parse(req.body.toString('utf8'));
    if (seenEvents.has(event.id)) return res.sendStatus(200);
    seenEvents.add(event.id);

    if (event.type !== 'message.reaction') return res.sendStatus(204);

    const { conversation, sender, message, event: detail } = event.data;
    if (!message?.target_message_id || typeof detail?.reaction !== 'string') {
      return res.sendStatus(422);
    }

    const key = [
      event.provider,
      event.account_id,
      conversation.id,
      message.target_message_id,
      sender.id,
    ].join(':');

    if (detail.reaction === '') {
      reactionState.delete(key);
    } else {
      reactionState.set(key, {
        emoji: detail.reaction,
        reactionMessageId: message.id,
        occurredAt: event.occurred_at,
      });
    }

    return res.sendStatus(204);
  },
);

app.listen(3000);

The example keeps the raw body intact until signature verification finishes. The signature is a hex-encoded HMAC-SHA256 of <X-Device-Timestamp>.<raw request body> when the endpoint has a signing_secret. The full webhook delivery guide also documents acknowledgement and retry behavior. For production freshness checks and durable idempotency, use the deeper HMAC replay-protection tutorial.

Choose the right subscription and failure behavior

For a reaction-only consumer, configure:

{
  "subscribed_events": ["message.reaction"]
}

For an inbox that also stores messages, include both message.received and message.reaction. A wildcard is appropriate only when the endpoint is a general collector prepared to accept every public standard event.

Return 2xx after durable acceptance. Invalid signatures should fail closed. A structurally invalid reaction should go to an observable error path rather than silently modifying the wrong message. Because provider event availability differs, consult the provider webhook event matrix before using an emoji as the only approval mechanism.

Where UnifyPort fits

UnifyPort provides an unofficial interface with a normalized event envelope across supported messaging platforms. That lets one consumer branch on event.type and use the same reaction-state logic where message.reaction is available, instead of placing provider-specific parsing throughout the application.

Normalization does not create an upstream capability that a provider does not expose. If reaction events are mandatory for compliance, approvals, or records, verify support for every connected provider and use an official platform API where its contract is the better fit.

FAQ

Which field identifies the original message?

Use data.message.target_message_id. The neighboring data.message.id identifies the reaction itself.

How do I detect that a reaction was removed?

Check data.event.reaction. An empty string means the reaction was removed; a non-empty string contains the current emoji.

Should I deduplicate by target_message_id?

No. Different people can react to the same message, and one person can change a reaction over time. Deduplicate delivery by the top-level event id; use the target message and sender as part of the current-state key.

Does every connected platform emit message.reaction?

No. Valid event names and actual provider support are separate. Check the provider webhook event matrix before depending on the event.

Next step

Open the Create webhook endpoint reference, add message.reaction to subscribed_events, enable a signing_secret, and test add and remove operations before connecting the handler to production state.

Sources

Official sources checked on August 20, 2026: