
Send an iMessage from Node.js by calling POST /v1/connections/{connection_id}/messages/send with a stable Idempotency-Key header. The bridge binds that key to exactly one request body: a retry carrying the same key and the same body returns the recorded result without touching iMessage again. A retry carrying the same key and a different body is rejected. When the outcome is unknown, do not resend under a fresh key — poll GET /v1/connections/{connection_id}/messages/receipt with the original key instead.
iMessage has no built-in deduplication for programmatic sends. If your code posts the same payload twice, there is no mechanism at the iMessage layer to detect the duplicate, and a second delivery may result. In a job queue with exponential backoff, the same payload fires again after a timeout; an await fetch(...) inside a retry loop sends it without knowing whether the first attempt reached the bridge at all.
The Idempotency-Key header solves this at the bridge layer. One key maps to exactly one request body. The bridge processes only the first arrival; every subsequent request with the same key and body returns the recorded result. That holds only if your code generates one stable key per send intent and persists it before the first network request, so a thrown timeout does not lose the key before you can reconcile the outcome.
ready status. Connection status is per service; a signed-in Apple account does not automatically mean the iMessage connector is ready.messages:send scope on your OAuth grant, plus messages:read to list chats and retrieve chatGuid values. The receipt poll endpoint also requires messages:send. A chat returned by a read result is not automatically authorized for sends; your grant must explicitly include that existing conversation.connection_id and access_token from completing the Figment OAuth flow. Register your app at figment.so/apple-connect/developers. Access tokens expire after 900 seconds. Serialize refresh requests per grant: do not fire concurrent refreshes for the same grant, as the first successful refresh invalidates the token used by any racing call. After each refresh, save the replacement refresh token returned in the response; Figment rotates refresh tokens on each use and the previous token is immediately invalid.node:sqlite's DatabaseSync, which carries Stability 1.2 (Release Candidate) in Node 24 — feature-complete but not yet fully stable. Import from node:sqlite; it ships built-in with no external package required. See the official Node 24 docs.The key rule is: write the intent to durable storage before any network call. If the process crashes or the fetch times out, the persisted intent lets you poll the receipt after restart without generating a new key — and without risking a second send.
The module below stores each send intent in a SQLite file, keyed by an intentId you supply — a job ID, an event ID, or any other stable per-message business identifier. Two separate send intents for identical message text must use different intentId values so their keys remain distinct; a body hash alone would wrongly merge them. The derived key is the SHA-256 hex digest of the intentId. A BEGIN IMMEDIATE transaction and a UNIQUE primary key ensure only one row wins per intentId even under concurrent callers on the same host, and that a changed chatGuid, text, or connection_id for a known intentId is rejected locally before any HTTP request leaves the process.
The store also freezes the connection_id for each intent. Figment's server-side idempotency keys are connection-scoped: the same key on a different connection is a different key to the server. If CONNECTION_ID changes between process runs — a new connection replacing a revoked one, for example — registerIntent compares the stored connection_id against the current env value and throws before making any HTTP request. pollReceipt resolves the connection from the stored intent row; it never blindly uses the current CONNECTION_ID for the request URL.
Scope this database to one app. Never share an intent DB across different registered developer app clients. intentId values (job IDs, event IDs, etc.) are not globally unique; two apps sharing the same DB could silently reuse a key from the wrong app's intent, with unpredictable results.
Fresh database for this example. CREATE TABLE IF NOT EXISTS does not add new columns to an existing table. This example starts with a new intents.db. Adapting an existing system requires a schema migration that preserves every existing intent row and its keys; discarding those rows would destroy the frozen payload and connection bindings that outstanding receipts depend on.
Single-host only. A SQLite file works when every worker shares the same filesystem path. For multi-host deployments — serverless functions, multiple containers — replace the SQLite store with one durable shared transactional database (Postgres or equivalent) accessible to every worker.
Never store access tokens in this database. The intent store holds only your application's message payload: the intentId, derived key, connection_id, chatGuid, and text. Treat it as application data subject to your own retention and access controls.
A local persistence failure — disk full, schema error, constraint violation — stops the function before any HTTP request is made.
// send-imessage.mjs — Node.js 24
// node:sqlite DatabaseSync is Stability 1.2 (Release Candidate) in Node 24.
// Official docs: https://nodejs.org/docs/latest-v24.x/api/sqlite.html
// Assumes a FRESH intents.db (see prose). CREATE TABLE IF NOT EXISTS does not
// add new columns to an existing table; upgrading an existing system requires a migration
// that preserves all intent rows and keys — do not drop the table or discard existing rows.
import { DatabaseSync } from 'node:sqlite';
import crypto from 'node:crypto';
const BASE = 'https://figment.so/api/apple-connect';
const TOKEN = process.env.ACCESS_TOKEN; // opaque Figment access token — never persisted here
const CONN = process.env.CONNECTION_ID; // con_... from token exchange
const DB_PATH = process.env.INTENT_DB_PATH ?? 'intents.db';
// Fail fast at import time if required env vars are absent.
if (!TOKEN) throw new Error('ACCESS_TOKEN environment variable is required');
if (!CONN) throw new Error('CONNECTION_ID environment variable is required');
// ---------------------------------------------------------------------------
// Intent store (single-host SQLite file — never stores credentials)
// Scope this DB to ONE registered developer app (one client_id).
// Never share an intent DB across different app clients; intentId values are
// not globally unique and collisions between apps could silently reuse keys.
// ---------------------------------------------------------------------------
const db = new DatabaseSync(DB_PATH);
db.exec(`
CREATE TABLE IF NOT EXISTS intents (
intent_id TEXT PRIMARY KEY,
key TEXT NOT NULL UNIQUE,
connection_id TEXT NOT NULL,
chat_guid TEXT NOT NULL,
text TEXT NOT NULL
)
`);
/**
* Persist one send intent before any network call.
* BEGIN IMMEDIATE prevents concurrent inserts for the same intent_id.
* Returns the stable 64-hex key derived from intentId.
* Throws — before any HTTP request — if the same intentId arrives with a
* different connection_id, chatGuid, or text.
* Never stores access tokens or other credentials.
*/
function registerIntent(intentId, connectionId, chatGuid, text) {
const key = crypto.createHash('sha256').update(intentId).digest('hex');
db.exec('BEGIN IMMEDIATE');
try {
const row = db.prepare(
'SELECT key, connection_id, chat_guid, text FROM intents WHERE intent_id = ?'
).get(intentId);
if (row) {
if (row.connection_id !== connectionId) {
throw new Error(
`intentId "${intentId}" is bound to connection "${row.connection_id}". ` +
'Current CONNECTION_ID is different. Refusing send before HTTP: ' +
'server keys are connection-scoped; reusing this intent on a new connection ' +
'could cause an unintended send. Restore the original connection or use a new intentId.',
);
}
if (row.chat_guid !== chatGuid || row.text !== text) {
throw new Error(
`intentId "${intentId}" is already bound to a different payload. ` +
'Use a new intentId for a different send intent.',
);
}
db.exec('COMMIT');
return row.key;
}
db.prepare(
'INSERT INTO intents (intent_id, key, connection_id, chat_guid, text) VALUES (?, ?, ?, ?, ?)'
).run(intentId, key, connectionId, chatGuid, text);
db.exec('COMMIT');
} catch (err) {
try { db.exec('ROLLBACK'); } catch (_) {}
throw err;
}
return key;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Persist the intent and return its stable key before any network activity.
* Idempotent: same intentId + same connection + same payload → same key on every call or restart.
* Same intentId + changed connection or payload → throws; use a new intentId for a new intent.
*/
export function resolveIntent(intentId, chatGuid, text) {
return registerIntent(intentId, CONN, chatGuid, text);
}
/**
* Send an iMessage via Figment Apple Connect.
*
* Writes the intent (including connection_id) to SQLite before the fetch.
* If CONNECTION_ID changed since the intent was first registered, throws before HTTP.
* AbortSignal.timeout bounds the request; the stored key survives a timeout.
*
* Response envelope: { data: { idempotencyKey, state, guid?, delivery?, retrySend? }, mode, replayed }
*
* data.state values:
* submitted — bridge accepted; not a delivery confirmation
* delivered — native connector confirmed delivery
* read — recipient opened the message
* failed — bridge recorded a failure; does not prove the message was
* rejected before any delivery attempt; retrying the same key
* returns this recorded failure without issuing a new send
* unknown — outcome unconfirmed; call pollReceipt(key) — do NOT resend under a new key
* simulated — sandbox only; no real message sent
*/
export async function sendIMessage({ intentId, chatGuid, text, timeoutMs = 15_000 }) {
// Write intent (with connection_id) before network.
// Changed connection → throws here, before any HTTP request is made.
const key = registerIntent(intentId, CONN, chatGuid, text);
const res = await fetch(
`${BASE}/v1/connections/${encodeURIComponent(CONN)}/messages/send`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${TOKEN}`,
'Content-Type': 'application/json',
'Idempotency-Key': key,
},
body: JSON.stringify({ chatGuid, text }),
signal: AbortSignal.timeout(timeoutMs),
},
);
if (!res.ok) throw new Error(`Send failed with HTTP ${res.status}`);
const { data, mode, replayed } = await res.json();
return { key, state: data?.state ?? 'unknown', mode, replayed };
}
/**
* Poll the receipt for a previously stored intent key.
* Resolves the connection_id from the stored intent row — never blindly uses the
* current env CONNECTION_ID for the URL, which may differ after a restart.
* Requires messages:send scope. Does not retry the underlying send.
* A 'failed' state polled again returns the same recorded failure, no resend.
* An 'unknown' state polled again may resolve; there is no guarantee.
* A sandbox receipt polled after the initial response returns replayed: true.
*/
export async function pollReceipt(key, { timeoutMs = 15_000 } = {}) {
// Resolve the connection this intent was originally registered against.
const row = db.prepare(
'SELECT connection_id FROM intents WHERE key = ?'
).get(key);
if (!row) throw new Error(`No stored intent found for key "${key}"`);
const conn = row.connection_id;
const res = await fetch(
`${BASE}/v1/connections/${encodeURIComponent(conn)}/messages/receipt`,
{
headers: {
Authorization: `Bearer ${TOKEN}`,
'Idempotency-Key': key,
},
signal: AbortSignal.timeout(timeoutMs),
},
);
if (!res.ok) throw new Error(`Receipt poll failed with HTTP ${res.status}`);
const { data, mode, replayed } = await res.json();
return { key, state: data?.state ?? 'unknown', mode, replayed };
}
Caller:
// job-handler.mjs
import { resolveIntent, sendIMessage, pollReceipt } from './send-imessage.mjs';
async function handleSendJob(job) {
const { intentId, chatGuid, text } = job;
// Persist the intent and obtain the key before any network activity.
// Same intentId + same connection + same payload → same key (idempotent across restarts).
// Same intentId + changed connection or payload → throws; correct the caller, do not continue.
const key = resolveIntent(intentId, chatGuid, text);
let result;
try {
result = await sendIMessage({ intentId, chatGuid, text });
} catch (err) {
// Network error or timeout. The intent is durably stored; the key is preserved.
// Report the uncertain outcome. Do NOT generate a new intentId automatically.
// A deliberate follow-up send is a distinct owner-approved intent.
return { outcome: 'uncertain', key, error: err.name };
}
if (result.state === 'unknown') {
// Outcome unconfirmed; poll before any further action. Do NOT resend under a new key.
const receipt = await pollReceipt(key);
return { outcome: receipt.state, key };
}
return { outcome: result.state, key };
}
Retrieve chatGuid values from GET /v1/connections/{connection_id}/chats; the API returns the guids you need. Use encodeURIComponent or a URL builder whenever a guid appears in a query string.
unknown means the bridge could not confirm whether iMessage accepted or rejected the request. It does not mean the message was not delivered. A blind resend under a new key risks a duplicate if the original did go through.
pollReceipt(key) to check the recorded state. If it resolves to delivered, read, or failed, act on that confirmed outcome.failed state is a recorded failure; it does not prove the message was rejected before any delivery attempt. Polling the same key again returns the same recorded failure — it does not retry the send. An unknown receipt polled again may resolve as the bridge catches up; there is no guarantee of resolution within any given timeframe.intentId and treat it as an owner-approved send, not automatic retry logic.The intent row persists in SQLite across process restarts. Any stored key — including those for failed or unknown outcomes — remains available for pollReceipt calls after a restart.
| State | Meaning |
|---|---|
ready | Bridge accepting sends |
connecting / syncing | Not yet ready; sends will be rejected |
needs_approval | User action required on the Mac |
offline | Mac unreachable |
reconnect_required | Connection lost; must be restored via account setup |
revoked | Grant revoked; new user approval required, not token restore |
Check connection.services.messages.state from GET /v1/connections/{connection_id}. Note: this endpoint requires a valid access token and can return an error for revoked connections or when the token is invalid; treat it as a best-effort status check, not an unconditional diagnostic. The account page is the primary source for setup and reconnection. A ready status indicates the bridge is accepting sends; it does not prove that iMessage Automation is authorized on the Mac or guarantee delivery. A revoked grant requires new user approval — restoring the previous token does not restore access.
Sandbox access is free. The developer pays $29 per month per active live connection; access starts after setup. Connect Apple Account to register your app and get your client_id.