INTEGRATIONS

Tapbacks for your iMessage agent.

Emote chooses a reaction, and your iMessage provider attaches it to the original message.

Prerequisites

An Emote API key and an iMessage setup that already receives messages and can send Tapbacks to a specific message. For example:

  • Photon provides managed iMessage infrastructure and reaction methods through its Spectrum SDK.
  • BlueBubbles runs on your Mac. Reactions require its Private API features to be configured.

Use your provider’s credentials and message IDs. The example below leaves the send and message-lookup functions for you to connect to that provider.

Copy the TypeScript or Python example into your server code. TypeScript uses Node.js 20 or later; Python uses Python 3.10 or later with pip install httpx. Each example includes the Emote request function. Keep API keys in server environment variables.

Reaction options

Define the Tapbacks as custom reaction options, each with an ID and description. Emote returns the chosen ID directly. These IDs belong to this example; map them to your provider’s documented API values.

SYMBOLTAPBACKRETURNED ID
Heartlove
Thumbs-uplike
Thumbs-downdislike
Laughter / Ha halaugh
Double exclamationemphasize
Question markquestion
noneNo actionNo provider call

Classic Tapback artwork from Apple’s Messages guide. Your provider renders the native Tapback. If your provider also supports other emoji, add them with their own custom IDs and descriptions. Emote’s choices can match the options your provider supports.

Integration example

Set EMOTE_API_KEY. Connect the send-Tapback and active-message callbacks to your provider’s authenticated send method and message lookup. The callback names follow the language you choose below. The example offers the six classic Tapbacks above: heart, thumbs-up, thumbs-down, Ha ha, double exclamation, and question mark. Pass frequency to control how often the agent reacts.

import { createHash } from "node:crypto";

// These IDs belong to this example. Map them to your provider's Tapback names.
export const TAPBACKS = [
  { id: "love", description: "Heart: warmth, affection, or support." },
  { id: "like", description: "Thumbs-up: acknowledge or agree without implying a task is done." },
  { id: "dislike", description: "Thumbs-down: disagree with an idea or express disapproval; never dismiss distress." },
  { id: "laugh", description: "Ha ha: something funny. Laugh with the user, not at their distress." },
  { id: "emphasize", description: "Double exclamation: strong surprise, excitement, or emphasis." },
  { id: "question", description: "Question mark: confusion or uncertainty about what was said." },
];

export type ReactionOption = string | { id: string; description: string };
export type ContextMessage = { role: "user" | "assistant"; content: string; reaction?: string };
export type ReactionInput = {
  eventId: string; message: string; agent?: string;
  context?: ContextMessage[]; reactions?: ReactionOption[];
  frequency?: "reserved" | "balanced" | "expressive";
};

// Server only. eventId must identify this message across all your conversations.
export async function chooseReaction({
  eventId, message, agent, context = [], reactions = TAPBACKS, frequency = "balanced",
}: ReactionInput): Promise<string> {
  const key = process.env.EMOTE_API_KEY;
  if (!key) throw new Error("Set EMOTE_API_KEY on your server.");
  if (!eventId) throw new Error("A stable eventId is required.");

  const response = await fetch("https://useemote.com/v1/react", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${key}`,
      "Content-Type": "application/json",
      "Idempotency-Key": createHash("sha256").update(eventId).digest("hex"),
    },
    body: JSON.stringify({ message, agent, context, reactions, frequency }),
    signal: AbortSignal.timeout(3000),
  });
  if (!response.ok) throw new Error(`Emote: HTTP ${response.status}`);
  const reaction = await response.json();
  const allowed = reactions.map((option) => typeof option === "string" ? option : option.id);
  if (typeof reaction !== "string" || (reaction !== "none" && !allowed.includes(reaction))) {
    throw new Error("Unexpected Emote response.");
  }
  return reaction;
}

type IMessageInput = Omit<ReactionInput, "eventId" | "message"> & {
  accountId: string; chatId: string; messageId: string; text: string;
  sendTapback: (target: { chatId: string; messageId: string; kind: string }) => Promise<void>;
  isMessageActive: (target: { chatId: string; messageId: string }) => Promise<boolean>;
};

export async function reactOnIMessage({
  accountId, chatId, messageId, text, agent, context = [], frequency = "balanced", reactions = TAPBACKS,
  sendTapback, isMessageActive,
}: IMessageInput) {
  const startedAt = Date.now();
  const reaction = await chooseReaction({
    eventId: JSON.stringify(["imessage", accountId, chatId, messageId]),
    message: text, agent, context, frequency, reactions,
  });
  if (reaction === "none" || Date.now() - startedAt > 5000) return;
  if (!(await isMessageActive({ chatId, messageId }))) return;

  // Implement this callback using your provider. Preserve any message-part ID
  // in your adapter when the provider requires one to target a Tapback.
  await sendTapback({ chatId, messageId, kind: reaction });
}

Message handler

Start the reaction function alongside your usual reply using the example. Catch reaction failures separately and keep both tasks alive until they finish. Restrict this to inbound user messages so your own reactions and replies cannot trigger a loop.

Verification

Verify all six reaction mappings against a test conversation. Confirm that the Tapback lands on the correct bubble, that "none" sends no request, and that provider errors leave your main reply running. Deduplicate incoming events and persist delivery state; Emote’s idempotency key does not prevent your provider from sending a reaction twice.

API reference · Get an API key