# Build an iMessage Workflow in n8n

Canonical: https://figment.so/blog/imessage-n8n-workflow

There is no dedicated Figment node in n8n. You connect to iMessage by calling Figment's REST API with n8n's standard HTTP Request node. The workflow pattern here adds two layers on top of that HTTP call: a human approval gate that holds every outgoing message until a reviewer confirms it, and an idempotency design that prevents duplicate sends when a step fails and is retried.

The workflow structure and node names are your design. The API endpoints and response shapes come from Figment.

## What you need

- **A Figment connection with Messages `ready`.** Check and start setup at [/apple-connect/account](/apple-connect/account). A signed-in Apple Account and a ready Messages service are distinct states; the API rejects send and read operations when the Messages service is not ready.
- **Both iMessage scopes granted to your app:** `messages:read` (covers history and search) and `messages:send` (covers sends). Both scopes can be declared in one app registration and approved within a single grant, but they are issued separately. A conversation found in a read result is not automatically authorized for sends; the target conversation must be included in your `messages:send` scope when you register at [/apple-connect/developers](/apple-connect/developers).
- **For SMS, MMS, and RCS:** an eligible iPhone online, signed into the same Apple Account, with Messages in iCloud active. Apple's current guide ([HT102545](https://support.apple.com/en-us/102545)) confirms that Messages in iCloud covers SMS, MMS, and RCS alongside iMessage — a separate Text Message Forwarding toggle is redundant when Messages in iCloud is already on.
- **An n8n instance** — self-hosted or n8n Cloud. The HTTP Request, Wait, Webhook, and Code nodes used here ship with every n8n edition.

Your `connection_id` (a `con_...` value) and a Figment access token are the two runtime credentials. Access tokens expire after 900 seconds — far shorter than a 24-hour approval wait. Because the approval gate suspends execution, the workflow must refresh the access token after approval and before the send request. The initial webhook payload must include the caller's current `refreshToken`. Your calling application must persist the rotated `refresh_token` returned by each refresh call; the previous value cannot be reused.

## Store your credential in n8n

Create a **Generic → Header Auth** credential in n8n. Set the Name field to `Authorization` and the Value field to `Bearer <your_access_token>`. Use this credential for read operations, where your calling application supplies a current token at invocation time. The send step in the approval-gate workflow uses a dynamically refreshed token instead of this static credential.

## The approval-gate workflow

| Step | Node | Purpose |
|---|---|---|
| 1 | Webhook Trigger | Receives intent — `chatGuid`, `text`, `actionKey`, `connectionId`, `refreshToken` — from your application |
| 2 | Code | Validates all required fields; throws on any missing value |
| 3 | Notification (your design) | Sends reviewer an approve/reject link embedding `$execution.resumeUrl` |
| 4 | Wait — On Webhook Call | Pauses until the reviewer responds or the timeout fires |
| 5 | IF | Routes on `{"action":"approve"}` in the resume callback body |
| 6 | HTTP Request | Refreshes the Figment access token using the stored `refreshToken` |
| 7 | HTTP Request | POSTs send with the fresh token and `Idempotency-Key` header |
| 8 | HTTP Request | GETs receipt using the same `Idempotency-Key` (requires `messages:send`) |

**Webhook Trigger.** n8n generates a production URL when the workflow is activated and a test URL while editing. Your application POSTs the message intent here. The `chatGuid` value must come from a prior call to the chats endpoint — not from a manually constructed phone string.

**Wait node.** Set the resume option to **On Webhook Call**. The node exposes `$execution.resumeUrl`, an execution-scoped URL to embed in the Step 3 notification alongside the proposed message text and `chatGuid`. Set a reasonable timeout; 24 hours suits most team-review scenarios. If the timeout fires without a callback, the execution ends without sending.

**IF node.** Check that the resume body contains `{"action":"approve"}`. Route the false branch to a Stop and Error node so rejected attempts appear in n8n's execution history. Only the true branch continues to the refresh and send steps.

**HTTP Request — refresh token (Step 6).**
- Method: POST
- URL: `https://figment.so/api/apple-connect/oauth/token`
- Body content type: `application/x-www-form-urlencoded`
- Body fields: `grant_type=refresh_token`, `client_id=<your_client_id>`, `refresh_token` referenced from the Code node's output

The response includes a new `access_token` and a new `refresh_token`. Persist the new `refresh_token` in your application before proceeding; the previous value cannot be reused.

**HTTP Request — send (Step 7).** Set `Authorization` to `Bearer <access_token>` using an expression that references your Step 6 node's output by name — for example `Bearer {{$('Refresh Token').first().json.access_token}}`, where `Refresh Token` is your actual Step 6 node name. Reference `connectionId`, `chatGuid`, `text`, and `actionKey` from the Code node's output the same way. Do not use the static Header Auth credential here; it holds the token from before the approval wait.
- Method: POST
- URL: `https://figment.so/api/apple-connect/v1/connections/{connectionId}/messages/send`
- Headers: `Authorization: Bearer <refreshed_access_token>`, `Idempotency-Key: <actionKey>`
- Body (JSON): `{"chatGuid":"<chatGuid>","text":"<text>"}`

The response envelope is `{data:{idempotencyKey,state,guid?,delivery?,retrySend?},mode,replayed}`.

**HTTP Request — receipt (Step 8).** Poll after a `submitted` or `unknown` state response. Use the same fresh token as Step 7.
- Method: GET
- URL: `https://figment.so/api/apple-connect/v1/connections/{connectionId}/messages/receipt`
- Headers: `Authorization: Bearer <refreshed_access_token>`, `Idempotency-Key: <actionKey>`

Receipt states: `submitted`, `delivered`, `read`, `failed`, `unknown`, `simulated` (sandbox only). `submitted` confirms the bridge accepted the request — not that iMessage delivered it to the recipient. An `unknown` state means no recorded outcome; poll the receipt and do not resubmit under a new key.

## Idempotency keys: generate once, persist first

The bridge binds each idempotency key to one fixed combination of app, connection, and request body. A second request with the same key and identical body returns the already-recorded result; a changed body is rejected.

The key must be generated by your calling application and persisted to a durable store **before** the first send request reaches the network. Do not derive the key from a hash of the message body — two separate decisions to send identical text to the same person would share a key, suppressing the second send. Do not regenerate the key on retry; use the original persisted value.

This Code node validates that the upstream contract was met (design example — not executed):

```js
// actionKey must be generated and persisted by your application
// before it POSTs to this workflow's webhook URL.
const { chatGuid, text, actionKey, connectionId, refreshToken } = $input.first().json;
if (!actionKey || !refreshToken) {
  throw new Error(
    'actionKey and refreshToken are required. Generate and persist the actionKey; ' +
    'supply the current refreshToken from your credential store.'
  );
}
if (!chatGuid || !text || !connectionId) {
  throw new Error('chatGuid, text, and connectionId are all required.');
}
return [{ json: { chatGuid, text, actionKey, connectionId, refreshToken } }];
```

If the send request at Step 7 times out before you receive a response, your persisted `actionKey` lets you poll the receipt endpoint safely — without risking a duplicate send under a new key.

## Read conversations and messages

The same Header Auth credential works for all read operations. Use n8n's query-parameter fields rather than hand-crafting query strings: a bare `+` in a URL query decodes as a space, corrupting phone-number-shaped `chatGuid` values.

| Operation | Method + path |
|---|---|
| List conversations | `GET /v1/connections/{connection_id}/chats?limit=20&offset=0` |
| Messages in a chat | `GET /v1/connections/{connection_id}/messages?chatGuid=<encoded>&limit=20` |
| Search messages | `GET /v1/connections/{connection_id}/messages/search?query=<encoded>&limit=20` |

List and search responses use the envelope `{data:[...],mode,offset,nextOffset,hasMore,historyComplete,scope}`. Paginate using `hasMore` and `nextOffset`; check `historyComplete` before assuming a full corpus — history on a recently connected Mac may be incomplete. The API returns a `guid` on each chat item; use that value as `chatGuid` in subsequent requests.

## Pricing

Active live connections cost $29 per month per connection. Sandbox access is free and uses synthetic data; sandbox receipt states appear as `simulated`. Access starts after setup is complete.

---

[Connect Apple Account](/apple-connect/developers) · [Manage connections and service status](/apple-connect/account)
